mcp-wordpress 1.1.2

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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +568 -0
  3. package/bin/mcp-wordpress.js +12 -0
  4. package/bin/setup.js +302 -0
  5. package/bin/status.js +359 -0
  6. package/dist/client/WordPressClient.d.ts +81 -0
  7. package/dist/client/WordPressClient.d.ts.map +1 -0
  8. package/dist/client/WordPressClient.js +354 -0
  9. package/dist/client/WordPressClient.js.map +1 -0
  10. package/dist/client/api.d.ts +140 -0
  11. package/dist/client/api.d.ts.map +1 -0
  12. package/dist/client/api.js +727 -0
  13. package/dist/client/api.js.map +1 -0
  14. package/dist/client/auth.d.ts +121 -0
  15. package/dist/client/auth.d.ts.map +1 -0
  16. package/dist/client/auth.js +430 -0
  17. package/dist/client/auth.js.map +1 -0
  18. package/dist/client/managers/AuthenticationManager.d.ts +39 -0
  19. package/dist/client/managers/AuthenticationManager.d.ts.map +1 -0
  20. package/dist/client/managers/AuthenticationManager.js +159 -0
  21. package/dist/client/managers/AuthenticationManager.js.map +1 -0
  22. package/dist/client/managers/BaseManager.d.ts +22 -0
  23. package/dist/client/managers/BaseManager.d.ts.map +1 -0
  24. package/dist/client/managers/BaseManager.js +47 -0
  25. package/dist/client/managers/BaseManager.js.map +1 -0
  26. package/dist/client/managers/RequestManager.d.ts +45 -0
  27. package/dist/client/managers/RequestManager.d.ts.map +1 -0
  28. package/dist/client/managers/RequestManager.js +161 -0
  29. package/dist/client/managers/RequestManager.js.map +1 -0
  30. package/dist/client/managers/index.d.ts +8 -0
  31. package/dist/client/managers/index.d.ts.map +1 -0
  32. package/dist/client/managers/index.js +8 -0
  33. package/dist/client/managers/index.js.map +1 -0
  34. package/dist/index.d.ts +19 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +264 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/server.d.ts +7 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +7 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/tools/auth.d.ts +44 -0
  43. package/dist/tools/auth.d.ts.map +1 -0
  44. package/dist/tools/auth.js +126 -0
  45. package/dist/tools/auth.js.map +1 -0
  46. package/dist/tools/base.d.ts +37 -0
  47. package/dist/tools/base.d.ts.map +1 -0
  48. package/dist/tools/base.js +60 -0
  49. package/dist/tools/base.js.map +1 -0
  50. package/dist/tools/comments.d.ts +33 -0
  51. package/dist/tools/comments.d.ts.map +1 -0
  52. package/dist/tools/comments.js +228 -0
  53. package/dist/tools/comments.js.map +1 -0
  54. package/dist/tools/index.d.ts +9 -0
  55. package/dist/tools/index.d.ts.map +1 -0
  56. package/dist/tools/index.js +9 -0
  57. package/dist/tools/index.js.map +1 -0
  58. package/dist/tools/media.d.ts +29 -0
  59. package/dist/tools/media.d.ts.map +1 -0
  60. package/dist/tools/media.js +208 -0
  61. package/dist/tools/media.js.map +1 -0
  62. package/dist/tools/pages.d.ts +30 -0
  63. package/dist/tools/pages.d.ts.map +1 -0
  64. package/dist/tools/pages.js +211 -0
  65. package/dist/tools/pages.js.map +1 -0
  66. package/dist/tools/posts.d.ts +30 -0
  67. package/dist/tools/posts.d.ts.map +1 -0
  68. package/dist/tools/posts.js +240 -0
  69. package/dist/tools/posts.js.map +1 -0
  70. package/dist/tools/site.d.ts +31 -0
  71. package/dist/tools/site.d.ts.map +1 -0
  72. package/dist/tools/site.js +192 -0
  73. package/dist/tools/site.js.map +1 -0
  74. package/dist/tools/taxonomies.d.ts +37 -0
  75. package/dist/tools/taxonomies.d.ts.map +1 -0
  76. package/dist/tools/taxonomies.js +280 -0
  77. package/dist/tools/taxonomies.js.map +1 -0
  78. package/dist/tools/users.d.ts +28 -0
  79. package/dist/tools/users.d.ts.map +1 -0
  80. package/dist/tools/users.js +201 -0
  81. package/dist/tools/users.js.map +1 -0
  82. package/dist/types/client.d.ts +215 -0
  83. package/dist/types/client.d.ts.map +1 -0
  84. package/dist/types/client.js +72 -0
  85. package/dist/types/client.js.map +1 -0
  86. package/dist/types/index.d.ts +157 -0
  87. package/dist/types/index.d.ts.map +1 -0
  88. package/dist/types/index.js +12 -0
  89. package/dist/types/index.js.map +1 -0
  90. package/dist/types/mcp.d.ts +178 -0
  91. package/dist/types/mcp.d.ts.map +1 -0
  92. package/dist/types/mcp.js +7 -0
  93. package/dist/types/mcp.js.map +1 -0
  94. package/dist/types/wordpress.d.ts +443 -0
  95. package/dist/types/wordpress.d.ts.map +1 -0
  96. package/dist/types/wordpress.js +7 -0
  97. package/dist/types/wordpress.js.map +1 -0
  98. package/dist/utils/debug.d.ts +63 -0
  99. package/dist/utils/debug.d.ts.map +1 -0
  100. package/dist/utils/debug.js +195 -0
  101. package/dist/utils/debug.js.map +1 -0
  102. package/dist/utils/error.d.ts +19 -0
  103. package/dist/utils/error.d.ts.map +1 -0
  104. package/dist/utils/error.js +71 -0
  105. package/dist/utils/error.js.map +1 -0
  106. package/dist/utils/toolWrapper.d.ts +36 -0
  107. package/dist/utils/toolWrapper.d.ts.map +1 -0
  108. package/dist/utils/toolWrapper.js +90 -0
  109. package/dist/utils/toolWrapper.js.map +1 -0
  110. package/package.json +115 -0
  111. package/src/client/api.ts +1043 -0
  112. package/src/client/auth.ts +527 -0
  113. package/src/client/managers/AuthenticationManager.ts +190 -0
  114. package/src/client/managers/BaseManager.ts +73 -0
  115. package/src/client/managers/RequestManager.ts +214 -0
  116. package/src/client/managers/index.ts +8 -0
  117. package/src/index.ts +337 -0
  118. package/src/server.ts +7 -0
  119. package/src/tools/auth.ts +153 -0
  120. package/src/tools/comments.ts +263 -0
  121. package/src/tools/index.ts +8 -0
  122. package/src/tools/media.ts +240 -0
  123. package/src/tools/pages.ts +246 -0
  124. package/src/tools/posts.ts +277 -0
  125. package/src/tools/site.ts +227 -0
  126. package/src/tools/taxonomies.ts +322 -0
  127. package/src/tools/users.ts +233 -0
  128. package/src/types/client.ts +304 -0
  129. package/src/types/index.ts +207 -0
  130. package/src/types/mcp.ts +247 -0
  131. package/src/types/wordpress.ts +491 -0
  132. package/src/utils/debug.ts +258 -0
  133. package/src/utils/error.ts +88 -0
  134. package/src/utils/toolWrapper.ts +105 -0
@@ -0,0 +1,1043 @@
1
+ /**
2
+ * WordPress API Client
3
+ * Handles all REST API communication with WordPress
4
+ */
5
+
6
+ import fetch from "node-fetch";
7
+ import FormData from "form-data";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import type {
11
+ IWordPressClient,
12
+ WordPressClientConfig,
13
+ AuthConfig,
14
+ AuthMethod,
15
+ HTTPMethod,
16
+ RequestOptions,
17
+ ClientStats,
18
+ } from "../types/client.js";
19
+ import {
20
+ WordPressAPIError,
21
+ AuthenticationError,
22
+ RateLimitError,
23
+ } from "../types/client.js";
24
+ import type {
25
+ WordPressPost,
26
+ WordPressPage,
27
+ WordPressMedia,
28
+ WordPressUser,
29
+ WordPressComment,
30
+ WordPressCategory,
31
+ WordPressTag,
32
+ WordPressSiteSettings,
33
+ WordPressApplicationPassword,
34
+ PostQueryParams,
35
+ MediaQueryParams,
36
+ UserQueryParams,
37
+ CommentQueryParams,
38
+ CreatePostRequest,
39
+ UpdatePostRequest,
40
+ CreatePageRequest,
41
+ UpdatePageRequest,
42
+ CreateUserRequest,
43
+ UpdateUserRequest,
44
+ CreateCommentRequest,
45
+ UpdateCommentRequest,
46
+ CreateCategoryRequest,
47
+ UpdateCategoryRequest,
48
+ CreateTagRequest,
49
+ UpdateTagRequest,
50
+ UploadMediaRequest,
51
+ UpdateMediaRequest,
52
+ } from "../types/wordpress.js";
53
+ import { debug, logError, startTimer } from "../utils/debug.js";
54
+
55
+ export class WordPressClient implements IWordPressClient {
56
+ private baseUrl: string;
57
+ private apiUrl: string;
58
+ private timeout: number;
59
+ private maxRetries: number;
60
+ private auth: AuthConfig;
61
+ private requestQueue: any[] = [];
62
+ private lastRequestTime: number = 0;
63
+ private requestInterval: number;
64
+ private authenticated: boolean = false;
65
+ private jwtToken: string | null = null;
66
+ private _stats: ClientStats;
67
+
68
+ constructor(options: Partial<WordPressClientConfig> = {}) {
69
+ this.baseUrl = options.baseUrl || process.env.WORDPRESS_SITE_URL || "";
70
+ this.apiUrl = "";
71
+ this.timeout =
72
+ options.timeout || parseInt(process.env.WORDPRESS_TIMEOUT || "30000");
73
+ this.maxRetries =
74
+ options.maxRetries || parseInt(process.env.WORDPRESS_MAX_RETRIES || "3");
75
+
76
+ // Authentication configuration
77
+ this.auth = options.auth || this.getAuthFromEnv();
78
+
79
+ // Rate limiting
80
+ this.requestInterval = 60000 / parseInt(process.env.RATE_LIMIT || "60");
81
+
82
+ // Initialize stats
83
+ this._stats = {
84
+ totalRequests: 0,
85
+ successfulRequests: 0,
86
+ failedRequests: 0,
87
+ averageResponseTime: 0,
88
+ rateLimitHits: 0,
89
+ authFailures: 0,
90
+ };
91
+
92
+ // Validate configuration
93
+ this.validateConfig();
94
+ }
95
+
96
+ get config(): WordPressClientConfig {
97
+ return {
98
+ baseUrl: this.baseUrl,
99
+ auth: this.auth,
100
+ timeout: this.timeout,
101
+ maxRetries: this.maxRetries,
102
+ };
103
+ }
104
+
105
+ get isAuthenticated(): boolean {
106
+ return this.authenticated;
107
+ }
108
+
109
+ get stats(): ClientStats {
110
+ return { ...this._stats };
111
+ }
112
+
113
+ private getAuthFromEnv(): AuthConfig {
114
+ const authMethod = process.env.WORDPRESS_AUTH_METHOD as AuthMethod;
115
+
116
+ // Use explicit auth method if set
117
+ if (
118
+ authMethod === "app-password" &&
119
+ process.env.WORDPRESS_USERNAME &&
120
+ process.env.WORDPRESS_APP_PASSWORD
121
+ ) {
122
+ return {
123
+ method: "app-password",
124
+ username: process.env.WORDPRESS_USERNAME,
125
+ appPassword: process.env.WORDPRESS_APP_PASSWORD,
126
+ };
127
+ }
128
+
129
+ // Try Application Password first (fallback)
130
+ if (process.env.WORDPRESS_USERNAME && process.env.WORDPRESS_APP_PASSWORD) {
131
+ return {
132
+ method: "app-password",
133
+ username: process.env.WORDPRESS_USERNAME,
134
+ appPassword: process.env.WORDPRESS_APP_PASSWORD,
135
+ };
136
+ }
137
+
138
+ // Try JWT
139
+ if (
140
+ process.env.WORDPRESS_JWT_SECRET &&
141
+ process.env.WORDPRESS_USERNAME &&
142
+ process.env.WORDPRESS_PASSWORD
143
+ ) {
144
+ return {
145
+ method: "jwt",
146
+ secret: process.env.WORDPRESS_JWT_SECRET,
147
+ username: process.env.WORDPRESS_USERNAME,
148
+ password: process.env.WORDPRESS_PASSWORD,
149
+ };
150
+ }
151
+
152
+ // Try API Key
153
+ if (process.env.WORDPRESS_API_KEY) {
154
+ return {
155
+ method: "api-key",
156
+ apiKey: process.env.WORDPRESS_API_KEY,
157
+ };
158
+ }
159
+
160
+ // Try Cookie
161
+ if (process.env.WORDPRESS_COOKIE_NONCE) {
162
+ return {
163
+ method: "cookie",
164
+ nonce: process.env.WORDPRESS_COOKIE_NONCE,
165
+ };
166
+ }
167
+
168
+ // Default to basic authentication
169
+ return {
170
+ method: "basic",
171
+ username: process.env.WORDPRESS_USERNAME || "",
172
+ password:
173
+ process.env.WORDPRESS_PASSWORD ||
174
+ process.env.WORDPRESS_APP_PASSWORD ||
175
+ "",
176
+ };
177
+ }
178
+
179
+ private validateConfig(): void {
180
+ if (!this.baseUrl) {
181
+ throw new Error(
182
+ "WordPress configuration is incomplete: baseUrl is required",
183
+ );
184
+ }
185
+
186
+ // Ensure URL doesn't end with slash and add API path
187
+ this.baseUrl = this.baseUrl.replace(/\/$/, "");
188
+ this.apiUrl = `${this.baseUrl}/wp-json/wp/v2`;
189
+
190
+ debug.log(`WordPress API Client initialized for: ${this.apiUrl}`);
191
+ }
192
+
193
+ async initialize(): Promise<void> {
194
+ await this.authenticate();
195
+ }
196
+
197
+ async disconnect(): Promise<void> {
198
+ this.authenticated = false;
199
+ this.jwtToken = null;
200
+ debug.log("WordPress client disconnected");
201
+ }
202
+
203
+ /**
204
+ * Add authentication headers to request
205
+ */
206
+ private addAuthHeaders(headers: Record<string, string>): void {
207
+ const method = this.auth.method?.toLowerCase() as AuthMethod;
208
+
209
+ switch (method) {
210
+ case "app-password":
211
+ if (this.auth.username && this.auth.appPassword) {
212
+ const credentials = Buffer.from(
213
+ `${this.auth.username}:${this.auth.appPassword}`,
214
+ ).toString("base64");
215
+ headers["Authorization"] = `Basic ${credentials}`;
216
+ }
217
+ break;
218
+ case "basic":
219
+ if (this.auth.username && this.auth.password) {
220
+ const credentials = Buffer.from(
221
+ `${this.auth.username}:${this.auth.password}`,
222
+ ).toString("base64");
223
+ headers["Authorization"] = `Basic ${credentials}`;
224
+ }
225
+ break;
226
+
227
+ case "jwt":
228
+ if (this.jwtToken) {
229
+ headers["Authorization"] = `Bearer ${this.jwtToken}`;
230
+ }
231
+ break;
232
+
233
+ case "api-key":
234
+ if (this.auth.apiKey) {
235
+ headers["X-API-Key"] = this.auth.apiKey;
236
+ }
237
+ break;
238
+
239
+ case "cookie":
240
+ if (this.auth.nonce) {
241
+ headers["X-WP-Nonce"] = this.auth.nonce;
242
+ }
243
+ break;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Rate limiting implementation
249
+ */
250
+ private async rateLimit(): Promise<void> {
251
+ const now = Date.now();
252
+ const timeSinceLastRequest = now - this.lastRequestTime;
253
+
254
+ if (timeSinceLastRequest < this.requestInterval) {
255
+ const delay = this.requestInterval - timeSinceLastRequest;
256
+ await this.delay(delay);
257
+ }
258
+
259
+ this.lastRequestTime = Date.now();
260
+ }
261
+
262
+ /**
263
+ * Delay utility
264
+ */
265
+ private delay(ms: number): Promise<void> {
266
+ return new Promise((resolve) => setTimeout(resolve, ms));
267
+ }
268
+
269
+ async authenticate(): Promise<boolean> {
270
+ const method = this.auth.method?.toLowerCase() as AuthMethod;
271
+
272
+ try {
273
+ switch (method) {
274
+ case "app-password":
275
+ case "basic":
276
+ return await this.authenticateWithBasic();
277
+ case "jwt":
278
+ return await this.authenticateWithJWT();
279
+ case "cookie":
280
+ return await this.authenticateWithCookie();
281
+ case "api-key":
282
+ // API key auth doesn't require separate authentication step
283
+ this.authenticated = true;
284
+ return true;
285
+ default:
286
+ throw new Error(`Unsupported authentication method: ${method}`);
287
+ }
288
+ } catch (error) {
289
+ this._stats.authFailures++;
290
+ logError(error as Error, { method });
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Authenticate using Basic/Application Password
297
+ */
298
+ private async authenticateWithBasic(): Promise<boolean> {
299
+ const hasCredentials =
300
+ this.auth.username &&
301
+ (this.auth.method === "app-password"
302
+ ? this.auth.appPassword
303
+ : this.auth.password);
304
+
305
+ if (!hasCredentials) {
306
+ const methodName =
307
+ this.auth.method === "app-password" ? "Application Password" : "Basic";
308
+ const passwordField =
309
+ this.auth.method === "app-password" ? "app password" : "password";
310
+ throw new AuthenticationError(
311
+ `Username and ${passwordField} are required for ${methodName} authentication`,
312
+ this.auth.method,
313
+ );
314
+ }
315
+
316
+ try {
317
+ // Test authentication by getting current user
318
+ await this.request<WordPressUser>("GET", "users/me");
319
+ this.authenticated = true;
320
+ debug.log("Basic/Application Password authentication successful");
321
+ return true;
322
+ } catch (error) {
323
+ throw new AuthenticationError(
324
+ `Basic authentication failed: ${(error as Error).message}`,
325
+ this.auth.method,
326
+ );
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Authenticate using JWT
332
+ */
333
+ private async authenticateWithJWT(): Promise<boolean> {
334
+ if (!this.auth.secret || !this.auth.username || !this.auth.password) {
335
+ throw new AuthenticationError(
336
+ "JWT secret, username, and password are required for JWT authentication",
337
+ this.auth.method,
338
+ );
339
+ }
340
+
341
+ try {
342
+ const response = await fetch(
343
+ `${this.baseUrl}/wp-json/jwt-auth/v1/token`,
344
+ {
345
+ method: "POST",
346
+ headers: {
347
+ "Content-Type": "application/json",
348
+ },
349
+ body: JSON.stringify({
350
+ username: this.auth.username,
351
+ password: this.auth.password,
352
+ }),
353
+ },
354
+ );
355
+
356
+ if (!response.ok) {
357
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
358
+ }
359
+
360
+ const data = (await response.json()) as { token: string };
361
+ this.jwtToken = data.token;
362
+ this.authenticated = true;
363
+ debug.log("JWT authentication successful");
364
+ return true;
365
+ } catch (error) {
366
+ throw new AuthenticationError(
367
+ `JWT authentication failed: ${(error as Error).message}`,
368
+ this.auth.method,
369
+ );
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Authenticate using Cookie
375
+ */
376
+ private async authenticateWithCookie(): Promise<boolean> {
377
+ if (!this.auth.nonce) {
378
+ throw new AuthenticationError(
379
+ "Nonce is required for cookie authentication",
380
+ this.auth.method,
381
+ );
382
+ }
383
+ this.authenticated = true;
384
+ debug.log("Cookie authentication configured");
385
+ return true;
386
+ }
387
+
388
+ /**
389
+ * Make authenticated request to WordPress REST API
390
+ */
391
+ async request<T = any>(
392
+ method: HTTPMethod,
393
+ endpoint: string,
394
+ data: any = null,
395
+ options: RequestOptions = {},
396
+ ): Promise<T> {
397
+ const timer = startTimer();
398
+ this._stats.totalRequests++;
399
+
400
+ // Handle endpoint properly - remove leading slash if present to avoid double slashes
401
+ const cleanEndpoint = endpoint.replace(/^\/+/, "");
402
+ const url = endpoint.startsWith("http")
403
+ ? endpoint
404
+ : `${this.apiUrl}/${cleanEndpoint}`;
405
+
406
+ const headers: Record<string, string> = {
407
+ "Content-Type": "application/json",
408
+ "User-Agent": "MCP-WordPress/1.0.0",
409
+ ...options.headers,
410
+ };
411
+
412
+ // Add authentication headers
413
+ this.addAuthHeaders(headers);
414
+
415
+ // Set up timeout using AbortController - use options timeout if provided
416
+ const controller = new AbortController();
417
+ const requestTimeout = options.timeout || this.timeout;
418
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
419
+
420
+ const fetchOptions: any = {
421
+ method,
422
+ headers,
423
+ signal: controller.signal,
424
+ ...options,
425
+ };
426
+
427
+ // Add body for POST/PUT/PATCH requests
428
+ if (data && ["POST", "PUT", "PATCH"].includes(method)) {
429
+ if (
430
+ data instanceof FormData ||
431
+ (data && typeof data.append === "function")
432
+ ) {
433
+ // For FormData, don't set Content-Type (let fetch set it with boundary)
434
+ delete headers["Content-Type"];
435
+ fetchOptions.body = data;
436
+ } else if (Buffer.isBuffer(data)) {
437
+ // For Buffer data (manual multipart), keep Content-Type from headers
438
+ fetchOptions.body = data;
439
+ } else if (typeof data === "string") {
440
+ fetchOptions.body = data;
441
+ } else {
442
+ fetchOptions.body = JSON.stringify(data);
443
+ }
444
+ }
445
+
446
+ // Rate limiting
447
+ await this.rateLimit();
448
+
449
+ let lastError: Error = new Error("Unknown error");
450
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
451
+ try {
452
+ debug.log(
453
+ `API Request: ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`,
454
+ );
455
+
456
+ const response = await fetch(url, fetchOptions);
457
+ clearTimeout(timeoutId);
458
+
459
+ // Handle different response types
460
+ if (!response.ok) {
461
+ const errorText = await response.text();
462
+ let errorMessage: string;
463
+
464
+ try {
465
+ const errorData = JSON.parse(errorText);
466
+ errorMessage =
467
+ errorData.message || errorData.error || `HTTP ${response.status}`;
468
+ } catch {
469
+ errorMessage =
470
+ errorText || `HTTP ${response.status}: ${response.statusText}`;
471
+ }
472
+
473
+ // Handle rate limiting
474
+ if (response.status === 429) {
475
+ this._stats.rateLimitHits++;
476
+ throw new RateLimitError(errorMessage, Date.now() + 60000);
477
+ }
478
+
479
+ // Handle permission errors specifically for uploads
480
+ if (
481
+ response.status === 403 &&
482
+ endpoint.includes("media") &&
483
+ method === "POST"
484
+ ) {
485
+ throw new AuthenticationError(
486
+ `Media upload blocked: WordPress REST API media uploads appear to be disabled or restricted by a plugin/security policy. ` +
487
+ `Error: ${errorMessage}. ` +
488
+ `Common causes: W3 Total Cache, security plugins, or custom REST API restrictions. ` +
489
+ `Please check WordPress admin settings or contact your system administrator.`,
490
+ this.auth.method,
491
+ );
492
+ }
493
+
494
+ // Handle general upload permission errors
495
+ if (
496
+ errorMessage.includes("Beiträge zu erstellen") &&
497
+ endpoint.includes("media")
498
+ ) {
499
+ throw new AuthenticationError(
500
+ `WordPress REST API media upload restriction detected: ${errorMessage}. ` +
501
+ `This typically indicates that media uploads via REST API are disabled by WordPress configuration, ` +
502
+ `a security plugin (like W3 Total Cache, Borlabs Cookie), or server policy. ` +
503
+ `User has sufficient permissions but WordPress/plugins are blocking the upload.`,
504
+ this.auth.method,
505
+ );
506
+ }
507
+
508
+ throw new WordPressAPIError(errorMessage, response.status);
509
+ }
510
+
511
+ // Parse response
512
+ const responseText = await response.text();
513
+ if (!responseText) {
514
+ this._stats.successfulRequests++;
515
+ const duration = timer.end();
516
+ this.updateAverageResponseTime(duration);
517
+ return null as T;
518
+ }
519
+
520
+ try {
521
+ const result = JSON.parse(responseText);
522
+ this._stats.successfulRequests++;
523
+ const duration = timer.end();
524
+ this.updateAverageResponseTime(duration);
525
+ return result as T;
526
+ } catch (parseError) {
527
+ // For authentication requests, malformed JSON should be an error
528
+ if (endpoint.includes("users/me") || endpoint.includes("jwt-auth")) {
529
+ throw new WordPressAPIError(
530
+ `Invalid JSON response: ${(parseError as Error).message}`,
531
+ );
532
+ }
533
+ this._stats.successfulRequests++;
534
+ const duration = timer.end();
535
+ this.updateAverageResponseTime(duration);
536
+ return responseText as T;
537
+ }
538
+ } catch (error) {
539
+ clearTimeout(timeoutId);
540
+ lastError = error as Error;
541
+
542
+ // Handle timeout errors
543
+ if ((error as any).name === "AbortError") {
544
+ lastError = new Error(`Request timeout after ${requestTimeout}ms`);
545
+ }
546
+
547
+ // Handle network errors
548
+ if (
549
+ lastError.message.includes("socket hang up") ||
550
+ lastError.message.includes("ECONNRESET")
551
+ ) {
552
+ lastError = new Error(
553
+ `Network connection lost during upload: ${lastError.message}`,
554
+ );
555
+ }
556
+
557
+ debug.log(
558
+ `Request failed (attempt ${attempt + 1}): ${lastError.message}`,
559
+ );
560
+
561
+ // Don't retry on authentication errors, timeouts, or critical network errors
562
+ if (
563
+ lastError.message.includes("401") ||
564
+ lastError.message.includes("403") ||
565
+ lastError.message.includes("timeout") ||
566
+ lastError.message.includes("Network connection lost")
567
+ ) {
568
+ break;
569
+ }
570
+
571
+ if (attempt < this.maxRetries - 1) {
572
+ await this.delay(1000 * (attempt + 1)); // Exponential backoff
573
+ }
574
+ }
575
+ }
576
+
577
+ this._stats.failedRequests++;
578
+ timer.end();
579
+ throw new WordPressAPIError(
580
+ `Request failed after ${this.maxRetries} attempts: ${lastError.message}`,
581
+ );
582
+ }
583
+
584
+ private updateAverageResponseTime(duration: number): void {
585
+ const totalSuccessful = this._stats.successfulRequests;
586
+ this._stats.averageResponseTime =
587
+ (this._stats.averageResponseTime * (totalSuccessful - 1) + duration) /
588
+ totalSuccessful;
589
+ this._stats.lastRequestTime = Date.now();
590
+ }
591
+
592
+ // HTTP method helpers
593
+ async get<T = any>(endpoint: string, options?: RequestOptions): Promise<T> {
594
+ return this.request<T>("GET", endpoint, null, options);
595
+ }
596
+
597
+ async post<T = any>(
598
+ endpoint: string,
599
+ data?: any,
600
+ options?: RequestOptions,
601
+ ): Promise<T> {
602
+ return this.request<T>("POST", endpoint, data, options);
603
+ }
604
+
605
+ async put<T = any>(
606
+ endpoint: string,
607
+ data?: any,
608
+ options?: RequestOptions,
609
+ ): Promise<T> {
610
+ return this.request<T>("PUT", endpoint, data, options);
611
+ }
612
+
613
+ async patch<T = any>(
614
+ endpoint: string,
615
+ data?: any,
616
+ options?: RequestOptions,
617
+ ): Promise<T> {
618
+ return this.request<T>("PATCH", endpoint, data, options);
619
+ }
620
+
621
+ async delete<T = any>(
622
+ endpoint: string,
623
+ options?: RequestOptions,
624
+ ): Promise<T> {
625
+ return this.request<T>("DELETE", endpoint, null, options);
626
+ }
627
+
628
+ // WordPress API Methods
629
+
630
+ // Posts
631
+ async getPosts(params?: PostQueryParams): Promise<WordPressPost[]> {
632
+ const queryString = params
633
+ ? "?" + new URLSearchParams(params as any).toString()
634
+ : "";
635
+ return this.get<WordPressPost[]>(`posts${queryString}`);
636
+ }
637
+
638
+ async getPost(
639
+ id: number,
640
+ context: "view" | "embed" | "edit" = "view",
641
+ ): Promise<WordPressPost> {
642
+ return this.get<WordPressPost>(`posts/${id}?context=${context}`);
643
+ }
644
+
645
+ async createPost(data: CreatePostRequest): Promise<WordPressPost> {
646
+ return this.post<WordPressPost>("posts", data);
647
+ }
648
+
649
+ async updatePost(data: UpdatePostRequest): Promise<WordPressPost> {
650
+ const { id, ...updateData } = data;
651
+ return this.put<WordPressPost>(`posts/${id}`, updateData);
652
+ }
653
+
654
+ async deletePost(
655
+ id: number,
656
+ force = false,
657
+ ): Promise<{ deleted: boolean; previous?: WordPressPost }> {
658
+ return this.delete(`posts/${id}?force=${force}`);
659
+ }
660
+
661
+ async getPostRevisions(id: number): Promise<WordPressPost[]> {
662
+ return this.get<WordPressPost[]>(`posts/${id}/revisions`);
663
+ }
664
+
665
+ // Pages
666
+ async getPages(params?: PostQueryParams): Promise<WordPressPage[]> {
667
+ const queryString = params
668
+ ? "?" + new URLSearchParams(params as any).toString()
669
+ : "";
670
+ return this.get<WordPressPage[]>(`pages${queryString}`);
671
+ }
672
+
673
+ async getPage(
674
+ id: number,
675
+ context: "view" | "embed" | "edit" = "view",
676
+ ): Promise<WordPressPage> {
677
+ return this.get<WordPressPage>(`pages/${id}?context=${context}`);
678
+ }
679
+
680
+ async createPage(data: CreatePageRequest): Promise<WordPressPage> {
681
+ return this.post<WordPressPage>("pages", data);
682
+ }
683
+
684
+ async updatePage(data: UpdatePageRequest): Promise<WordPressPage> {
685
+ const { id, ...updateData } = data;
686
+ return this.put<WordPressPage>(`pages/${id}`, updateData);
687
+ }
688
+
689
+ async deletePage(
690
+ id: number,
691
+ force = false,
692
+ ): Promise<{ deleted: boolean; previous?: WordPressPage }> {
693
+ return this.delete(`pages/${id}?force=${force}`);
694
+ }
695
+
696
+ async getPageRevisions(id: number): Promise<WordPressPage[]> {
697
+ return this.get<WordPressPage[]>(`pages/${id}/revisions`);
698
+ }
699
+
700
+ // Media
701
+ async getMedia(params?: MediaQueryParams): Promise<WordPressMedia[]> {
702
+ const queryString = params
703
+ ? "?" + new URLSearchParams(params as any).toString()
704
+ : "";
705
+ return this.get<WordPressMedia[]>(`media${queryString}`);
706
+ }
707
+
708
+ async getMediaItem(
709
+ id: number,
710
+ context: "view" | "embed" | "edit" = "view",
711
+ ): Promise<WordPressMedia> {
712
+ return this.get<WordPressMedia>(`media/${id}?context=${context}`);
713
+ }
714
+
715
+ async uploadMedia(data: UploadMediaRequest): Promise<WordPressMedia> {
716
+ if (!fs.existsSync(data.file_path)) {
717
+ throw new Error(`File not found: ${data.file_path}`);
718
+ }
719
+
720
+ const stats = fs.statSync(data.file_path);
721
+ const filename = data.title || path.basename(data.file_path);
722
+ const fileBuffer = fs.readFileSync(data.file_path);
723
+
724
+ // Check if file is too large (WordPress default is 2MB for most installs)
725
+ const maxSize = 10 * 1024 * 1024; // 10MB reasonable limit
726
+ if (stats.size > maxSize) {
727
+ throw new Error(
728
+ `File too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB. Maximum allowed: ${maxSize / 1024 / 1024}MB`,
729
+ );
730
+ }
731
+
732
+ debug.log(
733
+ `Uploading file: ${filename} (${(stats.size / 1024).toFixed(2)}KB)`,
734
+ );
735
+
736
+ return this.uploadFile(
737
+ fileBuffer,
738
+ filename,
739
+ this.getMimeType(data.file_path),
740
+ data,
741
+ );
742
+ }
743
+
744
+ async uploadFile(
745
+ fileData: Buffer,
746
+ filename: string,
747
+ mimeType: string,
748
+ meta: Partial<UploadMediaRequest> = {},
749
+ options?: RequestOptions,
750
+ ): Promise<WordPressMedia> {
751
+ debug.log(`Uploading file: ${filename} (${fileData.length} bytes)`);
752
+
753
+ // Use FormData but with correct configuration for node-fetch
754
+ const formData = new FormData();
755
+ formData.setMaxListeners(20);
756
+
757
+ // Add file with correct options
758
+ formData.append("file", fileData, {
759
+ filename,
760
+ contentType: mimeType,
761
+ });
762
+
763
+ // Add metadata
764
+ if (meta.title) formData.append("title", meta.title);
765
+ if (meta.alt_text) formData.append("alt_text", meta.alt_text);
766
+ if (meta.caption) formData.append("caption", meta.caption);
767
+ if (meta.description) formData.append("description", meta.description);
768
+ if (meta.post) formData.append("post", meta.post.toString());
769
+
770
+ // Use longer timeout for file uploads
771
+ const uploadTimeout =
772
+ options?.timeout !== undefined ? options.timeout : 600000; // 10 minutes default
773
+ const uploadOptions: RequestOptions = {
774
+ ...options,
775
+ timeout: uploadTimeout,
776
+ };
777
+
778
+ debug.log(`Upload prepared with FormData, timeout: ${uploadTimeout}ms`);
779
+
780
+ // Use the regular post method which handles FormData correctly
781
+ return this.post<WordPressMedia>("media", formData, uploadOptions);
782
+ }
783
+
784
+ async updateMedia(data: UpdateMediaRequest): Promise<WordPressMedia> {
785
+ const { id, ...updateData } = data;
786
+ return this.put<WordPressMedia>(`media/${id}`, updateData);
787
+ }
788
+
789
+ async deleteMedia(
790
+ id: number,
791
+ force = false,
792
+ ): Promise<{ deleted: boolean; previous?: WordPressMedia }> {
793
+ return this.delete(`media/${id}?force=${force}`);
794
+ }
795
+
796
+ // Users
797
+ async getUsers(params?: UserQueryParams): Promise<WordPressUser[]> {
798
+ const queryString = params
799
+ ? "?" + new URLSearchParams(params as any).toString()
800
+ : "";
801
+ return this.get<WordPressUser[]>(`users${queryString}`);
802
+ }
803
+
804
+ async getUser(
805
+ id: number | "me",
806
+ context: "view" | "embed" | "edit" = "view",
807
+ ): Promise<WordPressUser> {
808
+ return this.get<WordPressUser>(`users/${id}?context=${context}`);
809
+ }
810
+
811
+ async createUser(data: CreateUserRequest): Promise<WordPressUser> {
812
+ return this.post<WordPressUser>("users", data);
813
+ }
814
+
815
+ async updateUser(data: UpdateUserRequest): Promise<WordPressUser> {
816
+ const { id, ...updateData } = data;
817
+ return this.put<WordPressUser>(`users/${id}`, updateData);
818
+ }
819
+
820
+ async deleteUser(
821
+ id: number,
822
+ reassign?: number,
823
+ ): Promise<{ deleted: boolean; previous?: WordPressUser }> {
824
+ const params = reassign
825
+ ? `?reassign=${reassign}&force=true`
826
+ : "?force=true";
827
+ return this.delete(`users/${id}${params}`);
828
+ }
829
+
830
+ async getCurrentUser(): Promise<WordPressUser> {
831
+ return this.getUser("me");
832
+ }
833
+
834
+ // Comments
835
+ async getComments(params?: CommentQueryParams): Promise<WordPressComment[]> {
836
+ const queryString = params
837
+ ? "?" + new URLSearchParams(params as any).toString()
838
+ : "";
839
+ return this.get<WordPressComment[]>(`comments${queryString}`);
840
+ }
841
+
842
+ async getComment(
843
+ id: number,
844
+ context: "view" | "embed" | "edit" = "view",
845
+ ): Promise<WordPressComment> {
846
+ return this.get<WordPressComment>(`comments/${id}?context=${context}`);
847
+ }
848
+
849
+ async createComment(data: CreateCommentRequest): Promise<WordPressComment> {
850
+ return this.post<WordPressComment>("comments", data);
851
+ }
852
+
853
+ async updateComment(data: UpdateCommentRequest): Promise<WordPressComment> {
854
+ const { id, ...updateData } = data;
855
+ return this.put<WordPressComment>(`comments/${id}`, updateData);
856
+ }
857
+
858
+ async deleteComment(
859
+ id: number,
860
+ force = false,
861
+ ): Promise<{ deleted: boolean; previous?: WordPressComment }> {
862
+ return this.delete(`comments/${id}?force=${force}`);
863
+ }
864
+
865
+ async approveComment(id: number): Promise<WordPressComment> {
866
+ return this.put<WordPressComment>(`comments/${id}`, { status: "approved" });
867
+ }
868
+
869
+ async rejectComment(id: number): Promise<WordPressComment> {
870
+ return this.put<WordPressComment>(`comments/${id}`, {
871
+ status: "unapproved",
872
+ });
873
+ }
874
+
875
+ async spamComment(id: number): Promise<WordPressComment> {
876
+ return this.put<WordPressComment>(`comments/${id}`, { status: "spam" });
877
+ }
878
+
879
+ // Taxonomies
880
+ async getCategories(params?: any): Promise<WordPressCategory[]> {
881
+ const queryString = params
882
+ ? "?" + new URLSearchParams(params).toString()
883
+ : "";
884
+ return this.get<WordPressCategory[]>(`categories${queryString}`);
885
+ }
886
+
887
+ async getCategory(id: number): Promise<WordPressCategory> {
888
+ return this.get<WordPressCategory>(`categories/${id}`);
889
+ }
890
+
891
+ async createCategory(
892
+ data: CreateCategoryRequest,
893
+ ): Promise<WordPressCategory> {
894
+ return this.post<WordPressCategory>("categories", data);
895
+ }
896
+
897
+ async updateCategory(
898
+ data: UpdateCategoryRequest,
899
+ ): Promise<WordPressCategory> {
900
+ const { id, ...updateData } = data;
901
+ return this.put<WordPressCategory>(`categories/${id}`, updateData);
902
+ }
903
+
904
+ async deleteCategory(
905
+ id: number,
906
+ force = false,
907
+ ): Promise<{ deleted: boolean; previous?: WordPressCategory }> {
908
+ return this.delete(`categories/${id}?force=${force}`);
909
+ }
910
+
911
+ async getTags(params?: any): Promise<WordPressTag[]> {
912
+ const queryString = params
913
+ ? "?" + new URLSearchParams(params).toString()
914
+ : "";
915
+ return this.get<WordPressTag[]>(`tags${queryString}`);
916
+ }
917
+
918
+ async getTag(id: number): Promise<WordPressTag> {
919
+ return this.get<WordPressTag>(`tags/${id}`);
920
+ }
921
+
922
+ async createTag(data: CreateTagRequest): Promise<WordPressTag> {
923
+ return this.post<WordPressTag>("tags", data);
924
+ }
925
+
926
+ async updateTag(data: UpdateTagRequest): Promise<WordPressTag> {
927
+ const { id, ...updateData } = data;
928
+ return this.put<WordPressTag>(`tags/${id}`, updateData);
929
+ }
930
+
931
+ async deleteTag(
932
+ id: number,
933
+ force = false,
934
+ ): Promise<{ deleted: boolean; previous?: WordPressTag }> {
935
+ return this.delete(`tags/${id}?force=${force}`);
936
+ }
937
+
938
+ // Site Management
939
+ async getSiteSettings(): Promise<WordPressSiteSettings> {
940
+ return this.get<WordPressSiteSettings>("settings");
941
+ }
942
+
943
+ async updateSiteSettings(
944
+ settings: Partial<WordPressSiteSettings>,
945
+ ): Promise<WordPressSiteSettings> {
946
+ return this.post<WordPressSiteSettings>("settings", settings);
947
+ }
948
+
949
+ async getSiteInfo(): Promise<any> {
950
+ return this.get("");
951
+ }
952
+
953
+ // Application Passwords
954
+ async getApplicationPasswords(
955
+ userId: number | "me" = "me",
956
+ ): Promise<WordPressApplicationPassword[]> {
957
+ return this.get<WordPressApplicationPassword[]>(
958
+ `users/${userId}/application-passwords`,
959
+ );
960
+ }
961
+
962
+ async createApplicationPassword(
963
+ userId: number | "me",
964
+ name: string,
965
+ appId?: string,
966
+ ): Promise<WordPressApplicationPassword> {
967
+ const data: any = { name };
968
+ if (appId) data.app_id = appId;
969
+ return this.post<WordPressApplicationPassword>(
970
+ `users/${userId}/application-passwords`,
971
+ data,
972
+ );
973
+ }
974
+
975
+ async deleteApplicationPassword(
976
+ userId: number | "me",
977
+ uuid: string,
978
+ ): Promise<{ deleted: boolean }> {
979
+ return this.delete(`users/${userId}/application-passwords/${uuid}`);
980
+ }
981
+
982
+ // Search
983
+ async search(
984
+ query: string,
985
+ types?: string[],
986
+ subtype?: string,
987
+ ): Promise<any[]> {
988
+ const params = new URLSearchParams({ search: query });
989
+ if (types) params.append("type", types.join(","));
990
+ if (subtype) params.append("subtype", subtype);
991
+
992
+ return this.get<any[]>(`search?${params.toString()}`);
993
+ }
994
+
995
+ // Utility Methods
996
+ async ping(): Promise<boolean> {
997
+ try {
998
+ await this.get("");
999
+ return true;
1000
+ } catch {
1001
+ return false;
1002
+ }
1003
+ }
1004
+
1005
+ async getServerInfo(): Promise<Record<string, any>> {
1006
+ return this.get("");
1007
+ }
1008
+
1009
+ validateEndpoint(endpoint: string): boolean {
1010
+ return /^[a-zA-Z0-9\/\-_]+$/.test(endpoint);
1011
+ }
1012
+
1013
+ buildUrl(endpoint: string, params?: Record<string, any>): string {
1014
+ const url = `${this.apiUrl}/${endpoint.replace(/^\/+/, "")}`;
1015
+ if (params) {
1016
+ const searchParams = new URLSearchParams(params);
1017
+ return `${url}?${searchParams.toString()}`;
1018
+ }
1019
+ return url;
1020
+ }
1021
+
1022
+ private getMimeType(filePath: string): string {
1023
+ const ext = path.extname(filePath).toLowerCase();
1024
+ const mimeTypes: Record<string, string> = {
1025
+ ".jpg": "image/jpeg",
1026
+ ".jpeg": "image/jpeg",
1027
+ ".png": "image/png",
1028
+ ".gif": "image/gif",
1029
+ ".webp": "image/webp",
1030
+ ".svg": "image/svg+xml",
1031
+ ".pdf": "application/pdf",
1032
+ ".doc": "application/msword",
1033
+ ".docx":
1034
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1035
+ ".txt": "text/plain",
1036
+ ".mp4": "video/mp4",
1037
+ ".mp3": "audio/mpeg",
1038
+ ".wav": "audio/wav",
1039
+ };
1040
+
1041
+ return mimeTypes[ext] || "application/octet-stream";
1042
+ }
1043
+ }