@respira/wordpress-mcp-server 7.5.1 → 7.5.3

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 (30) hide show
  1. package/README.md +18 -2
  2. package/dist/__tests__/delete-media-approval-token.test.d.ts +21 -0
  3. package/dist/__tests__/delete-media-approval-token.test.d.ts.map +1 -0
  4. package/dist/__tests__/delete-media-approval-token.test.js +126 -0
  5. package/dist/__tests__/delete-media-approval-token.test.js.map +1 -0
  6. package/dist/__tests__/upload-media-path-detection.test.d.ts +2 -0
  7. package/dist/__tests__/upload-media-path-detection.test.d.ts.map +1 -0
  8. package/dist/__tests__/upload-media-path-detection.test.js +70 -0
  9. package/dist/__tests__/upload-media-path-detection.test.js.map +1 -0
  10. package/dist/__tests__/usage-emitter-privacy.test.d.ts +2 -0
  11. package/dist/__tests__/usage-emitter-privacy.test.d.ts.map +1 -0
  12. package/dist/__tests__/usage-emitter-privacy.test.js +16 -0
  13. package/dist/__tests__/usage-emitter-privacy.test.js.map +1 -0
  14. package/dist/__tests__/write-retry-safety.test.d.ts +2 -0
  15. package/dist/__tests__/write-retry-safety.test.d.ts.map +1 -0
  16. package/dist/__tests__/write-retry-safety.test.js +36 -0
  17. package/dist/__tests__/write-retry-safety.test.js.map +1 -0
  18. package/dist/server.d.ts +2 -0
  19. package/dist/server.d.ts.map +1 -1
  20. package/dist/server.js +36 -3
  21. package/dist/server.js.map +1 -1
  22. package/dist/usage-emitter.d.ts +12 -6
  23. package/dist/usage-emitter.d.ts.map +1 -1
  24. package/dist/usage-emitter.js +89 -31
  25. package/dist/usage-emitter.js.map +1 -1
  26. package/dist/wordpress-client.d.ts +64 -1
  27. package/dist/wordpress-client.d.ts.map +1 -1
  28. package/dist/wordpress-client.js +153 -13
  29. package/dist/wordpress-client.js.map +1 -1
  30. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
9
9
  import { dirname, resolve, extname } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { Agent as HttpAgent } from 'node:http';
12
+ import { randomUUID } from 'node:crypto';
12
13
  import { getRespiraAgentHeaders } from './agent-signature.js';
13
14
  import { CONFIG_FILE, isTlsInsecureEnv } from './config.js';
14
15
  import { makeHttpsAgent } from './system-ca.js';
@@ -26,6 +27,100 @@ const MCP_CLIENT_VERSION = (() => {
26
27
  }
27
28
  return 'unknown';
28
29
  })();
30
+ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
31
+ const RETRYABLE_CONNECTION_CODES = new Set([
32
+ 'ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'ENETUNREACH',
33
+ 'EHOSTUNREACH', 'EAI_AGAIN', 'EPIPE', 'ECONNREFUSED',
34
+ ]);
35
+ /** Attach one stable idempotency key to a mutation config. Retries reuse it. */
36
+ export function ensureWriteIdempotencyKey(config, makeId = randomUUID) {
37
+ const method = String(config?.method || 'get').toUpperCase();
38
+ if (SAFE_HTTP_METHODS.has(method))
39
+ return;
40
+ config.headers = config.headers || {};
41
+ const headers = config.headers;
42
+ const existing = typeof headers.get === 'function'
43
+ ? headers.get('Idempotency-Key')
44
+ : headers['Idempotency-Key'] || headers['idempotency-key'];
45
+ if (existing)
46
+ return;
47
+ if (typeof headers.set === 'function')
48
+ headers.set('Idempotency-Key', makeId());
49
+ else
50
+ headers['Idempotency-Key'] = makeId();
51
+ }
52
+ /** Whether a failed request is safe for the connector to repeat automatically. */
53
+ export function isReplaySafeRetry(args) {
54
+ const method = String(args.method || 'GET').toUpperCase();
55
+ const safe = SAFE_HTTP_METHODS.has(method);
56
+ if (typeof args.status === 'number') {
57
+ if (safe)
58
+ return args.status >= 500 && args.status <= 599;
59
+ return args.idempotencySupported === true && [502, 503, 504].includes(args.status);
60
+ }
61
+ return safe && !!args.connectionCode && RETRYABLE_CONNECTION_CODES.has(args.connectionCode);
62
+ }
63
+ // Windows drive-letter absolute path, e.g. `C:\Users\bob\photo.jpg` or `C:/Users/bob/photo.jpg`.
64
+ const WINDOWS_DRIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
65
+ /**
66
+ * True when `file` (the `file` argument to `uploadMedia`) looks like a local
67
+ * filesystem path rather than a base64 blob or a `data:`/`http(s):` URL.
68
+ *
69
+ * Historically this only matched POSIX-style paths (`/`, `./`, `../`, `~`),
70
+ * so a Windows absolute path like `C:/Users/bob/photo.jpg` — which starts
71
+ * with none of those — fell through to the base64 branch. `Buffer.from(file,
72
+ * 'base64')` doesn't throw on non-base64 input; it silently decodes whatever
73
+ * valid base64 characters happen to appear (mostly none, for a path like
74
+ * that) and produces a few garbage bytes instead of an error, so the upload
75
+ * "succeeds" with a corrupt file. See ticket 5a19d816.
76
+ *
77
+ * Also matches `file://` URIs (`file:///C:/Users/...` on Windows,
78
+ * `file:///home/...` on Unix); the caller converts these to a real path with
79
+ * `fileURLToPath` before reuse of the existing local-file-read logic below.
80
+ *
81
+ * Not handled: Windows UNC paths (`\\server\share\file.jpg`). They don't
82
+ * reproduce this bug — backslashes aren't valid base64 characters, so they'd
83
+ * still fall into the base64 branch but wouldn't "succeed" quietly the same
84
+ * way — but they also aren't read as a local file today. Out of scope for
85
+ * this fix; revisit if a UNC-specific report comes in.
86
+ */
87
+ export function isLocalFilePath(file) {
88
+ return (file.startsWith('/') ||
89
+ file.startsWith('./') ||
90
+ file.startsWith('../') ||
91
+ file.startsWith('~') ||
92
+ WINDOWS_DRIVE_PATH_RE.test(file) ||
93
+ file.startsWith('file://'));
94
+ }
95
+ /**
96
+ * Turns an `isLocalFilePath()`-matched string into a real filesystem path.
97
+ * Handles `file://` URIs (unwrapping via `fileURLToPath`, forcing Windows
98
+ * semantics when the URI's own path looks like a drive letter so this
99
+ * converts correctly even when the MCP server process itself is running on
100
+ * a non-Windows host), `~` home-dir expansion, and relative-path resolution
101
+ * against cwd.
102
+ *
103
+ * Windows drive-letter absolute paths (`C:\...`, `C:/...`) are returned
104
+ * as-is rather than run through `resolve()`: they're already absolute, and
105
+ * POSIX `resolve()` does not recognize a drive letter as an absolute-path
106
+ * marker, so calling it on a non-Windows host (e.g. this test suite) would
107
+ * wrongly prefix the string with `cwd`. On the Windows machine where such a
108
+ * path is actually real, `fs` calls accept it verbatim.
109
+ */
110
+ export function resolveLocalFilePath(file) {
111
+ let input = file;
112
+ if (file.startsWith('file://')) {
113
+ const looksWindows = WINDOWS_DRIVE_PATH_RE.test(new URL(file).pathname.replace(/^\//, ''));
114
+ input = looksWindows ? fileURLToPath(file, { windows: true }) : fileURLToPath(file);
115
+ }
116
+ if (input.startsWith('~')) {
117
+ return input.replace(/^~/, process.env.HOME || '');
118
+ }
119
+ if (WINDOWS_DRIVE_PATH_RE.test(input)) {
120
+ return input;
121
+ }
122
+ return resolve(input);
123
+ }
29
124
  export class WordPressClient {
30
125
  client;
31
126
  rootClient;
@@ -133,6 +228,7 @@ export class WordPressClient {
133
228
  // (`_restRouteFallback: true` on the request config).
134
229
  const requestInterceptor = (config) => {
135
230
  try {
231
+ ensureWriteIdempotencyKey(config);
136
232
  if (this.useRestRouteFallback &&
137
233
  config &&
138
234
  !config._restRouteFallback &&
@@ -159,17 +255,22 @@ export class WordPressClient {
159
255
  const RETRY_MAX_ATTEMPTS = 3;
160
256
  const RETRY_BASE_MS = 200;
161
257
  const RETRY_FACTOR = 3;
162
- const RETRYABLE_CONN_CODES = new Set([
163
- 'ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'ENETUNREACH',
164
- 'EHOSTUNREACH', 'EAI_AGAIN', 'EPIPE', 'ECONNREFUSED',
165
- ]);
166
258
  const errorInterceptor = async (error) => {
167
259
  const config = error.config || {};
168
260
  const attempt = typeof config._respiraRetryAttempt === 'number' ? config._respiraRetryAttempt : 0;
169
261
  const status = error.response?.status;
170
- const isRetryableStatus = status !== undefined && status >= 500 && status <= 599;
171
- const isRetryableConnError = error.response === undefined && error.code !== undefined && RETRYABLE_CONN_CODES.has(error.code);
172
- if (attempt < RETRY_MAX_ATTEMPTS && (isRetryableStatus || isRetryableConnError) && config.url !== undefined) {
262
+ const method = String(config.method || 'get').toUpperCase();
263
+ const idempotencySupported = String(error.response?.headers?.['x-respira-idempotency-supported'] || '') === '1';
264
+ // Connection failures have an ambiguous commit state for writes. Only
265
+ // repeat safe reads, or gateway failures that explicitly advertise the
266
+ // plugin's response-replay contract.
267
+ const retryable = isReplaySafeRetry({
268
+ method,
269
+ status,
270
+ idempotencySupported,
271
+ connectionCode: error.response === undefined ? error.code : undefined,
272
+ });
273
+ if (attempt < RETRY_MAX_ATTEMPTS && retryable && config.url !== undefined) {
173
274
  const backoffMs = RETRY_BASE_MS * Math.pow(RETRY_FACTOR, attempt);
174
275
  await new Promise((resolve) => setTimeout(resolve, backoffMs));
175
276
  config._respiraRetryAttempt = attempt + 1;
@@ -934,6 +1035,11 @@ export class WordPressClient {
934
1035
  // hint AND set a dedicated error.name so the admin/mcp-quality dashboard
935
1036
  // surfaces this distinct from generic network errors.
936
1037
  let errorName = code ? `network_${String(code).toLowerCase()}` : 'network_error';
1038
+ const requestMethod = String(error.config?.method || 'get').toUpperCase();
1039
+ const isAmbiguousWrite = !SAFE_HTTP_METHODS.has(requestMethod);
1040
+ const requestHeaders = error.config?.headers || {};
1041
+ const callId = String((typeof requestHeaders.get === 'function' ? requestHeaders.get('Idempotency-Key') : null) ||
1042
+ requestHeaders['Idempotency-Key'] || requestHeaders['idempotency-key'] || '');
937
1043
  const siteUrl = String(this.siteConfig?.url || '').toLowerCase();
938
1044
  const isLocalDevUrl = siteUrl.includes('.local/') || siteUrl.endsWith('.local') ||
939
1045
  siteUrl.includes('localhost') ||
@@ -944,10 +1050,15 @@ export class WordPressClient {
944
1050
  errorName = 'respira_local_url_unreachable';
945
1051
  }
946
1052
  const netError = new Error(`Could not connect to ${this.siteConfig.url} — no response received.${hint}\n\n` +
1053
+ (isAmbiguousWrite
1054
+ ? `The write may have completed after the connection dropped. Do not issue a new write blindly. Verify the target first; if retrying, preserve call id ${callId || '(missing)'}.\n\n`
1055
+ : '') +
947
1056
  'Before checking the API key, verify that the site is accessible: open ' + this.siteConfig.url + ' in a browser. ' +
948
1057
  'If the site requires a login or password prompt to access, add "httpAuth" to your site config:\n' +
949
1058
  ' "httpAuth": { "username": "your-user", "password": "your-pass" }');
950
1059
  netError.name = errorName;
1060
+ if (isAmbiguousWrite)
1061
+ netError.name = 'respira_write_outcome_unknown';
951
1062
  return netError;
952
1063
  }
953
1064
  return new Error(`Unknown error: ${error.message}`);
@@ -1543,11 +1654,9 @@ export class WordPressClient {
1543
1654
  clearTimeout(killer);
1544
1655
  }
1545
1656
  }
1546
- else if (file.startsWith('/') || file.startsWith('./') || file.startsWith('../') || file.startsWith('~')) {
1657
+ else if (isLocalFilePath(file)) {
1547
1658
  // Local file path — preflight size before reading the whole file into memory
1548
- const resolvedPath = file.startsWith('~')
1549
- ? file.replace(/^~/, process.env.HOME || '')
1550
- : resolve(file);
1659
+ const resolvedPath = resolveLocalFilePath(file);
1551
1660
  if (!existsSync(resolvedPath)) {
1552
1661
  throw new Error(`File not found: ${resolvedPath}`);
1553
1662
  }
@@ -2275,6 +2384,21 @@ export class WordPressClient {
2275
2384
  getSiteUrl() {
2276
2385
  return this.siteConfig.url;
2277
2386
  }
2387
+ /**
2388
+ * Fetch a site-scoped telemetry bearer from the plugin. The request is
2389
+ * authenticated locally; the local respira_* API key is never sent to the
2390
+ * central ingest.
2391
+ */
2392
+ async getTelemetryToken() {
2393
+ try {
2394
+ const response = await this.client.post('/mcp/telemetry-token', {}, { timeout: 15000 });
2395
+ const token = typeof response.data?.token === 'string' ? response.data.token.trim() : '';
2396
+ return token.startsWith('rp_mcp_') ? token : null;
2397
+ }
2398
+ catch {
2399
+ return null;
2400
+ }
2401
+ }
2278
2402
  /**
2279
2403
  * Get approvals URL in WordPress admin.
2280
2404
  *
@@ -2887,8 +3011,24 @@ export class WordPressClient {
2887
3011
  const response = await this.client.put('/media/batch', { items });
2888
3012
  return response.data;
2889
3013
  }
2890
- async deleteMedia(id) {
2891
- const response = await this.client.delete(`/media/${id}`);
3014
+ /**
3015
+ * Delete a media attachment. Approval-gated since v7.1.0-beta.1: the
3016
+ * first call comes back with `code: respira_approval_required` and a
3017
+ * fresh `approval_token`; the caller re-sends the same id with that
3018
+ * token to confirm. Ticket 927aa7ed — this method used to only forward
3019
+ * `id`, silently dropping `force` and `approvalToken`, so the WP side
3020
+ * never saw a token on the "confirm" call and looped forever minting
3021
+ * new ones. Mirrors `deletePage`'s params shape (see comment above it).
3022
+ */
3023
+ async deleteMedia(id, force, approvalToken) {
3024
+ const params = {};
3025
+ if (force) {
3026
+ params.force = true;
3027
+ }
3028
+ if (approvalToken) {
3029
+ params.approval_token = approvalToken;
3030
+ }
3031
+ const response = await this.client.delete(`/media/${id}`, { params });
2892
3032
  return response.data;
2893
3033
  }
2894
3034
  // Menu Management