@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.
- package/README.md +18 -2
- package/dist/__tests__/delete-media-approval-token.test.d.ts +21 -0
- package/dist/__tests__/delete-media-approval-token.test.d.ts.map +1 -0
- package/dist/__tests__/delete-media-approval-token.test.js +126 -0
- package/dist/__tests__/delete-media-approval-token.test.js.map +1 -0
- package/dist/__tests__/upload-media-path-detection.test.d.ts +2 -0
- package/dist/__tests__/upload-media-path-detection.test.d.ts.map +1 -0
- package/dist/__tests__/upload-media-path-detection.test.js +70 -0
- package/dist/__tests__/upload-media-path-detection.test.js.map +1 -0
- package/dist/__tests__/usage-emitter-privacy.test.d.ts +2 -0
- package/dist/__tests__/usage-emitter-privacy.test.d.ts.map +1 -0
- package/dist/__tests__/usage-emitter-privacy.test.js +16 -0
- package/dist/__tests__/usage-emitter-privacy.test.js.map +1 -0
- package/dist/__tests__/write-retry-safety.test.d.ts +2 -0
- package/dist/__tests__/write-retry-safety.test.d.ts.map +1 -0
- package/dist/__tests__/write-retry-safety.test.js +36 -0
- package/dist/__tests__/write-retry-safety.test.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +36 -3
- package/dist/server.js.map +1 -1
- package/dist/usage-emitter.d.ts +12 -6
- package/dist/usage-emitter.d.ts.map +1 -1
- package/dist/usage-emitter.js +89 -31
- package/dist/usage-emitter.js.map +1 -1
- package/dist/wordpress-client.d.ts +64 -1
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +153 -13
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +1 -1
package/dist/wordpress-client.js
CHANGED
|
@@ -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
|
|
171
|
-
const
|
|
172
|
-
|
|
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 (
|
|
1657
|
+
else if (isLocalFilePath(file)) {
|
|
1547
1658
|
// Local file path — preflight size before reading the whole file into memory
|
|
1548
|
-
const resolvedPath = file
|
|
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
|
-
|
|
2891
|
-
|
|
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
|