@respira/wordpress-mcp-server 7.5.2 → 7.5.4

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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Regression test for ticket 927aa7ed: "delete_media approval flow never
3
+ * completes: token rejected in a loop".
4
+ *
5
+ * Root cause: `WordPressClient.deleteMedia()` only ever forwarded `id` to the
6
+ * WP REST endpoint (`DELETE /media/{id}`). The MCP tool schema for
7
+ * `wordpress_delete_media` advertises `approval_token` and `force` params and
8
+ * the server.ts dispatcher received them from the caller, but the client
9
+ * method silently dropped both on the floor before the request ever went
10
+ * out. The WP-side approval gate (Respira_Tool_Governance) never saw a token
11
+ * on the "confirm" call, so it always treated it as a fresh request and
12
+ * minted a brand-new token — an infinite loop, indistinguishable from the
13
+ * outside from a broken approval mechanism, even though the mechanism itself
14
+ * (proven by delete_page working) was fine.
15
+ *
16
+ * Uses the same axios mock-adapter pattern as rest-route-fallback.test.ts.
17
+ *
18
+ * @since 7.5.x (ticket 927aa7ed)
19
+ */
20
+ export {};
21
+ //# sourceMappingURL=delete-media-approval-token.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete-media-approval-token.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/delete-media-approval-token.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Regression test for ticket 927aa7ed: "delete_media approval flow never
3
+ * completes: token rejected in a loop".
4
+ *
5
+ * Root cause: `WordPressClient.deleteMedia()` only ever forwarded `id` to the
6
+ * WP REST endpoint (`DELETE /media/{id}`). The MCP tool schema for
7
+ * `wordpress_delete_media` advertises `approval_token` and `force` params and
8
+ * the server.ts dispatcher received them from the caller, but the client
9
+ * method silently dropped both on the floor before the request ever went
10
+ * out. The WP-side approval gate (Respira_Tool_Governance) never saw a token
11
+ * on the "confirm" call, so it always treated it as a fresh request and
12
+ * minted a brand-new token — an infinite loop, indistinguishable from the
13
+ * outside from a broken approval mechanism, even though the mechanism itself
14
+ * (proven by delete_page working) was fine.
15
+ *
16
+ * Uses the same axios mock-adapter pattern as rest-route-fallback.test.ts.
17
+ *
18
+ * @since 7.5.x (ticket 927aa7ed)
19
+ */
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import axios from 'axios';
23
+ import { WordPressClient } from '../wordpress-client.js';
24
+ const callLog = [];
25
+ let currentHandler = null;
26
+ const originalAdapter = axios.defaults.adapter;
27
+ function installMockAdapter() {
28
+ callLog.length = 0;
29
+ axios.defaults.adapter = ((config) => {
30
+ callLog.push({
31
+ method: (config.method || 'get').toLowerCase(),
32
+ url: config.url,
33
+ params: config.params,
34
+ });
35
+ if (!currentHandler) {
36
+ return Promise.reject(new Error('no mock handler installed'));
37
+ }
38
+ const result = currentHandler(config);
39
+ return Promise.resolve(result).then((r) => ({
40
+ status: r.status,
41
+ statusText: 'OK',
42
+ headers: {},
43
+ config,
44
+ data: r.data,
45
+ request: {},
46
+ }));
47
+ });
48
+ }
49
+ function restoreAdapter() {
50
+ axios.defaults.adapter = originalAdapter;
51
+ currentHandler = null;
52
+ }
53
+ function setHandler(h) {
54
+ currentHandler = h;
55
+ }
56
+ function buildConfig() {
57
+ return {
58
+ id: 'test-site',
59
+ name: 'Test Site',
60
+ url: 'https://example.com',
61
+ apiKey: 'respira_test_key',
62
+ };
63
+ }
64
+ // -----------------------------------------------------------------------------
65
+ // Tests
66
+ // -----------------------------------------------------------------------------
67
+ test('deleteMedia forwards approval_token on the confirm call (927aa7ed regression)', async () => {
68
+ installMockAdapter();
69
+ try {
70
+ setHandler(() => ({ status: 200, data: { success: true } }));
71
+ const client = new WordPressClient(buildConfig());
72
+ // First call: no token yet, simulates the initial delete attempt.
73
+ await client.deleteMedia(42);
74
+ assert.equal(callLog.length, 1);
75
+ assert.equal(callLog[0].method, 'delete');
76
+ assert.equal(callLog[0].url, '/media/42');
77
+ assert.ok(!callLog[0].params || callLog[0].params.approval_token === undefined, 'first call must not send an approval_token');
78
+ // Second call: the agent echoes back the exact token it was handed.
79
+ // Pre-fix this token was silently dropped and the WP side looped forever.
80
+ await client.deleteMedia(42, undefined, 'approve-tok-abc123');
81
+ assert.equal(callLog.length, 2);
82
+ assert.equal(callLog[1].method, 'delete');
83
+ assert.equal(callLog[1].url, '/media/42');
84
+ assert.equal(callLog[1].params?.approval_token, 'approve-tok-abc123', 'confirm call must forward the approval_token verbatim');
85
+ }
86
+ finally {
87
+ restoreAdapter();
88
+ }
89
+ });
90
+ test('deleteMedia forwards force alongside approval_token', async () => {
91
+ installMockAdapter();
92
+ try {
93
+ setHandler(() => ({ status: 200, data: { success: true } }));
94
+ const client = new WordPressClient(buildConfig());
95
+ await client.deleteMedia(7, true, 'tok-xyz');
96
+ assert.equal(callLog.length, 1);
97
+ assert.equal(callLog[0].params?.force, true);
98
+ assert.equal(callLog[0].params?.approval_token, 'tok-xyz');
99
+ }
100
+ finally {
101
+ restoreAdapter();
102
+ }
103
+ });
104
+ test('deletePage approval flow is unaffected (no regression)', async () => {
105
+ installMockAdapter();
106
+ try {
107
+ setHandler(() => ({ status: 200, data: { success: true } }));
108
+ const client = new WordPressClient(buildConfig());
109
+ // No force -> no params sent at all (matches pre-existing behavior).
110
+ await client.deletePage(99);
111
+ assert.equal(callLog[0].url, '/pages/99');
112
+ assert.ok(!callLog[0].params || Object.keys(callLog[0].params).length === 0);
113
+ // force + approval_token -> both forwarded, plus confirm_live_edit which
114
+ // deletePage has always sent alongside force.
115
+ await client.deletePage(99, true, 'page-tok-1');
116
+ const call = callLog[1];
117
+ assert.equal(call.url, '/pages/99');
118
+ assert.equal(call.params.force, true);
119
+ assert.equal(call.params.confirm_live_edit, true);
120
+ assert.equal(call.params.approval_token, 'page-tok-1');
121
+ }
122
+ finally {
123
+ restoreAdapter();
124
+ }
125
+ });
126
+ //# sourceMappingURL=delete-media-approval-token.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete-media-approval-token.test.js","sourceRoot":"","sources":["../../src/__tests__/delete-media-approval-token.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tool-filter-context-cache.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-filter-context-cache.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tool-filter-context-cache.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { RespiraWordPressServer } from '../server.js';
4
+ /**
5
+ * Regression test for the getTools() blocking-network-calls bug
6
+ * (nathankohut.co.uk, Windows, 2026-07-19): isWooCommerceAddonAvailable(),
7
+ * isAcfAvailable(), and filterToolsByContext() each independently called
8
+ * getSiteContext()/getCompactSiteContext() — three uncached, sequential live
9
+ * HTTP calls to the customer's own site before tools/list could return,
10
+ * each carrying the wordpress-client retry backoff. A slow/failing site
11
+ * could push a single tools/list response well past an MCP client's own
12
+ * handshake patience, killing the process with zero stderr — indistinguishable
13
+ * from a crash.
14
+ *
15
+ * Fix: a single cached, timeout-bounded getContextForToolFiltering() shared
16
+ * by all three call sites.
17
+ */
18
+ function makeServer() {
19
+ const server = new RespiraWordPressServer([
20
+ { id: 'test-site', name: 'Test Site', url: 'https://example.test', apiKey: 'test-key', default: true },
21
+ ]);
22
+ return server;
23
+ }
24
+ test('WooCommerce and ACF availability checks share ONE getSiteContext() call, not two', async () => {
25
+ const server = makeServer();
26
+ let callCount = 0;
27
+ server.currentSite.getSiteContext = async () => {
28
+ callCount++;
29
+ return { addons: { woocommerce: { installed: true, licensed: true }, acf: { installed: true } } };
30
+ };
31
+ const [woo, acf] = await Promise.all([
32
+ server.isWooCommerceAddonAvailable(),
33
+ server.isAcfAvailable(),
34
+ ]);
35
+ assert.equal(woo, true);
36
+ assert.equal(acf, true);
37
+ assert.equal(callCount, 1, 'expected exactly one underlying getSiteContext() call, got ' + callCount);
38
+ });
39
+ test('a slow getSiteContext() never blocks the caller past the timeout bound', async () => {
40
+ const server = makeServer();
41
+ server.currentSite.getSiteContext = () => new Promise((resolve) => setTimeout(() => resolve({ addons: {} }), 10_000)); // deliberately far past any reasonable bound
42
+ const start = Date.now();
43
+ const result = await server.getContextForToolFiltering();
44
+ const elapsed = Date.now() - start;
45
+ assert.equal(result, null, 'a timed-out fetch should resolve to null (fail-open), not hang or throw');
46
+ assert.ok(elapsed < 3000, `expected the timeout bound to cap the wait well under 3s, took ${elapsed}ms`);
47
+ });
48
+ test('filterToolsByContext shows all tools when the site context fetch fails', async () => {
49
+ const server = makeServer();
50
+ server.currentSite.getSiteContext = async () => {
51
+ throw new Error('ECONNRESET');
52
+ };
53
+ const tools = [{ name: 'wordpress_list_pages' }, { name: 'woocommerce_list_products' }];
54
+ const filtered = await server.filterToolsByContext(tools);
55
+ assert.equal(filtered.length, 2, 'a failed context fetch should fail open and show everything, not drop tools');
56
+ });
57
+ test('a successful fetch is cached and reused across repeated tools/list-style calls', async () => {
58
+ const server = makeServer();
59
+ let callCount = 0;
60
+ server.currentSite.getSiteContext = async () => {
61
+ callCount++;
62
+ return { addons: { woocommerce: { installed: true, licensed: true } } };
63
+ };
64
+ await server.isWooCommerceAddonAvailable();
65
+ await server.isWooCommerceAddonAvailable();
66
+ await server.filterToolsByContext([]);
67
+ assert.equal(callCount, 1, 'a fresh cache hit should never re-fetch within the TTL window');
68
+ });
69
+ //# sourceMappingURL=tool-filter-context-cache.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-filter-context-cache.test.js","sourceRoot":"","sources":["../../src/__tests__/tool-filter-context-cache.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAEtD;;;;;;;;;;;;;GAaG;AAEH,SAAS,UAAU;IACjB,MAAM,MAAM,GAAG,IAAI,sBAAsB,CAAC;QACxC,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,sBAAsB,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE;KACvG,CAAQ,CAAC;IACV,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,IAAI,CAAC,kFAAkF,EAAE,KAAK,IAAI,EAAE;IAClG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,WAAW,CAAC,cAAc,GAAG,KAAK,IAAI,EAAE;QAC7C,SAAS,EAAE,CAAC;QACZ,OAAO,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IACpG,CAAC,CAAC;IAEF,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QACnC,MAAM,CAAC,2BAA2B,EAAE;QACpC,MAAM,CAAC,cAAc,EAAE;KACxB,CAAC,CAAC;IAEH,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACxB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACxB,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC,EAAE,6DAA6D,GAAG,SAAS,CAAC,CAAC;AACxG,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,wEAAwE,EAAE,KAAK,IAAI,EAAE;IACxF,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,MAAM,CAAC,WAAW,CAAC,cAAc,GAAG,GAAG,EAAE,CACvC,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,6CAA6C;IAE5H,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACzB,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,0BAA0B,EAAE,CAAC;IACzD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC;IAEnC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,yEAAyE,CAAC,CAAC;IACtG,MAAM,CAAC,EAAE,CAAC,OAAO,GAAG,IAAI,EAAE,kEAAkE,OAAO,IAAI,CAAC,CAAC;AAC3G,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,wEAAwE,EAAE,KAAK,IAAI,EAAE;IACxF,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,MAAM,CAAC,WAAW,CAAC,cAAc,GAAG,KAAK,IAAI,EAAE;QAC7C,MAAM,IAAI,KAAK,CAAC,YAAY,CAAC,CAAC;IAChC,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,CAAC,EAAE,IAAI,EAAE,sBAAsB,EAAE,EAAE,EAAE,IAAI,EAAE,2BAA2B,EAAE,CAAQ,CAAC;IAC/F,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC;IAE1D,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,6EAA6E,CAAC,CAAC;AAClH,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,gFAAgF,EAAE,KAAK,IAAI,EAAE;IAChG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,WAAW,CAAC,cAAc,GAAG,KAAK,IAAI,EAAE;QAC7C,SAAS,EAAE,CAAC;QACZ,OAAO,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAC1E,CAAC,CAAC;IAEF,MAAM,MAAM,CAAC,2BAA2B,EAAE,CAAC;IAC3C,MAAM,MAAM,CAAC,2BAA2B,EAAE,CAAC;IAC3C,MAAM,MAAM,CAAC,oBAAoB,CAAC,EAAE,CAAC,CAAC;IAEtC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC,EAAE,+DAA+D,CAAC,CAAC;AAC9F,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=upload-media-path-detection.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-media-path-detection.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/upload-media-path-detection.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,70 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { isLocalFilePath, resolveLocalFilePath } from '../wordpress-client.js';
8
+ // Ticket 5a19d816: on Windows, respira_upload_media(file="C:/Users/.../photo.jpg")
9
+ // fell through the local-file detection (which only matched '/', './', '../',
10
+ // '~') into the base64 branch. Buffer.from(nonBase64, 'base64') doesn't throw,
11
+ // it just silently decodes garbage bytes, so the upload "succeeded" with a
12
+ // corrupt ~80-byte file instead of reading the real one from disk.
13
+ test('isLocalFilePath: Windows drive-letter paths are detected as local files, not base64', () => {
14
+ assert.equal(isLocalFilePath('C:/Users/bob/photo.jpg'), true);
15
+ assert.equal(isLocalFilePath('C:\\Users\\bob\\photo.jpg'), true);
16
+ assert.equal(isLocalFilePath('D:\\Media\\file.png'), true);
17
+ assert.equal(isLocalFilePath('z:/lower/drive/letter.gif'), true);
18
+ });
19
+ test('isLocalFilePath: file:// URIs are detected as local files (Windows and Unix forms)', () => {
20
+ assert.equal(isLocalFilePath('file:///C:/Users/bob/photo.jpg'), true);
21
+ assert.equal(isLocalFilePath('file:///home/bob/photo.jpg'), true);
22
+ });
23
+ test('isLocalFilePath: existing POSIX-style detection is unchanged (no regression)', () => {
24
+ assert.equal(isLocalFilePath('/absolute/unix/path.jpg'), true);
25
+ assert.equal(isLocalFilePath('./relative/path.jpg'), true);
26
+ assert.equal(isLocalFilePath('../parent/path.jpg'), true);
27
+ assert.equal(isLocalFilePath('~/home/path.jpg'), true);
28
+ });
29
+ test('isLocalFilePath: base64 payloads and data:/http(s): URLs are still NOT treated as local files', () => {
30
+ // A plausible base64 blob with no path-like prefix.
31
+ assert.equal(isLocalFilePath('aGVsbG8gd29ybGQgdGhpcyBpcyBhIHRlc3Q='), false);
32
+ assert.equal(isLocalFilePath('data:image/png;base64,iVBORw0KGgo='), false);
33
+ assert.equal(isLocalFilePath('http://example.com/photo.jpg'), false);
34
+ assert.equal(isLocalFilePath('https://example.com/photo.jpg'), false);
35
+ });
36
+ test('resolveLocalFilePath: Windows drive-letter paths pass through unchanged (no cwd corruption)', () => {
37
+ // Regression guard: node:path's resolve() is POSIX on non-Windows hosts and
38
+ // does not recognize "C:\..." as absolute, so running it through resolve()
39
+ // would wrongly prefix the string with cwd. The fix must return the drive
40
+ // path verbatim so fs calls on an actual Windows host see the real path
41
+ // (and, off-Windows, this test suite sees a clean "File not found: C:\..."
42
+ // instead of a mangled "<cwd>/C:\...").
43
+ assert.equal(resolveLocalFilePath('C:\\Users\\bob\\photo.jpg'), 'C:\\Users\\bob\\photo.jpg');
44
+ assert.equal(resolveLocalFilePath('C:/Users/bob/photo.jpg'), 'C:/Users/bob/photo.jpg');
45
+ });
46
+ test('resolveLocalFilePath: file:///C:/... extracts a Windows-style path', () => {
47
+ const resolved = resolveLocalFilePath('file:///C:/Users/bob/photo.jpg');
48
+ assert.equal(resolved, 'C:\\Users\\bob\\photo.jpg');
49
+ });
50
+ test('resolveLocalFilePath: file:///... on Unix round-trips to the real file, byte for byte', () => {
51
+ const dir = mkdtempSync(join(tmpdir(), 'respira-upload-media-test-'));
52
+ const filePath = join(dir, 'photo.jpg');
53
+ writeFileSync(filePath, 'not-actually-a-jpeg-but-that-is-fine');
54
+ try {
55
+ const fileUrl = pathToFileURL(filePath).href;
56
+ assert.equal(isLocalFilePath(fileUrl), true);
57
+ const resolved = resolveLocalFilePath(fileUrl);
58
+ assert.equal(resolved, filePath);
59
+ }
60
+ finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+ test('resolveLocalFilePath: existing "~" and relative-path resolution behavior is unchanged', () => {
65
+ const home = process.env.HOME || '';
66
+ assert.equal(resolveLocalFilePath('~/photo.jpg'), `${home}/photo.jpg`);
67
+ // A relative path resolves against cwd, same as plain node:path resolve().
68
+ assert.equal(resolveLocalFilePath('./photo.jpg'), join(process.cwd(), 'photo.jpg'));
69
+ });
70
+ //# sourceMappingURL=upload-media-path-detection.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-media-path-detection.test.js","sourceRoot":"","sources":["../../src/__tests__/upload-media-path-detection.test.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -170,9 +170,38 @@ export declare class RespiraWordPressServer {
170
170
  * Widget shortcuts are filtered to only the detected builder's element types.
171
171
  * Core tools (site context, pages, posts, media, menus, etc.) always remain.
172
172
  */
173
- /** Cached site context for tool filtering (refreshed on site switch). */
173
+ /** Cached site context, shared by tool filtering and the WooCommerce/ACF
174
+ * availability checks below — all three used to independently call
175
+ * getSiteContext() (getCompactSiteContext() itself just wraps it), meaning
176
+ * every getTools() invocation made up to three uncached, sequential live
177
+ * HTTP calls to the customer's own WordPress site before it could return
178
+ * the tool catalog. Each call carries the wordpress-client retry backoff
179
+ * (200/600/1800ms across 3 attempts), so a slow or failing site could push
180
+ * a single tools/list response past 7s — long enough for an MCP client's
181
+ * own handshake patience to give up and tear down the process, which
182
+ * looks identical to a crash with zero stderr output (nathankohut.co.uk,
183
+ * Windows, 2026-07-19). getContextForToolFiltering() below is the only
184
+ * caller of currentSite.getSiteContext() in this class; everything that
185
+ * used to call it directly or via getCompactSiteContext() now shares this
186
+ * one cached, timeout-bounded fetch instead. */
174
187
  private cachedFilterContext;
175
188
  private static readonly FILTER_CACHE_TTL;
189
+ private static readonly FILTER_CACHE_FAILURE_TTL;
190
+ private static readonly FILTER_CONTEXT_TIMEOUT_MS;
191
+ /** Dedupes concurrent callers (e.g. isWooCommerceAddonAvailable() and
192
+ * isAcfAvailable() both missing a cold cache at once) onto one in-flight
193
+ * fetch instead of each starting their own. */
194
+ private filterContextFetchInFlight;
195
+ /**
196
+ * Fetch (or reuse a cached / in-flight) site context for tool-catalog
197
+ * decisions. Bounded to FILTER_CONTEXT_TIMEOUT_MS regardless of how long
198
+ * the underlying client's own retry backoff would otherwise take, so a
199
+ * slow or unreachable site can never block tools/list indefinitely.
200
+ * Resolves to null (and caches the miss briefly) on timeout or any fetch
201
+ * error — callers treat null as "show everything / skip context-dependent
202
+ * gating" rather than failing the whole tool list.
203
+ */
204
+ private getContextForToolFiltering;
176
205
  private filterToolsByContext;
177
206
  private isWooCommerceAddonAvailable;
178
207
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAqBH,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,kBAAkB,CAAC;AAsQzE,qBAAa,sBAAsB;IACjC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,KAAK,CAA2C;IACxD,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,YAAY,CAA4B;IAChD,8EAA8E;IAC9E,OAAO,CAAC,YAAY,CAA4B;IAChD,8EAA8E;IAC9E,OAAO,CAAC,mBAAmB,CAAS;IACpC,iFAAiF;IACjF,OAAO,CAAC,iBAAiB,CAAK;IAC9B,iFAAiF;IACjF,OAAO,CAAC,mBAAmB,CAAgC;IAE3D,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAsB;IAEhE;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAWzB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;gBA4Bb,WAAW,EAAE,mBAAmB,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE;IAiVvE,OAAO,CAAC,cAAc;IAItB;;;;;;;;;OASG;IACH,OAAO,CAAC,oBAAoB;IAkB5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IA4BrB,gEAAgE;IAChE,OAAO,CAAC,aAAa;IAUrB;;;;;;OAMG;YACW,UAAU;IA2CxB;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAiJzB;;;;;;;;;OASG;YACW,kBAAkB;IAiHhC;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;YACW,qBAAqB;YAiBrB,kBAAkB;IA6FhC,yFAAyF;IACzF,OAAO,CAAC,gBAAgB;IASxB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,6BAA6B;IA0BrC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,yBAAyB;IA+BjC,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,aAAa;YAmQP,kBAAkB;YA6BlB,yBAAyB;IASvC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;YAyBd,QAAQ;IA+vFtB;;;;;;OAMG;IACH,yEAAyE;IACzE,OAAO,CAAC,mBAAmB,CAAoD;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAU;YAEpC,oBAAoB;YAqDpB,2BAA2B;IAazC;;;;OAIG;YACW,cAAc;IAY5B,OAAO,CAAC,mBAAmB;IA2uC3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAe3C;IAEF;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;YAmCpB,cAAc;IA4G5B,oEAAoE;IACpE,OAAO,CAAC,iBAAiB;IAoBzB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,eAAe;YAuCT,gBAAgB;IAimC9B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAwUzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA6UxB,GAAG;CAyCV"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAqBH,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,kBAAkB,CAAC;AAsQzE,qBAAa,sBAAsB;IACjC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,KAAK,CAA2C;IACxD,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,YAAY,CAA4B;IAChD,8EAA8E;IAC9E,OAAO,CAAC,YAAY,CAA4B;IAChD,8EAA8E;IAC9E,OAAO,CAAC,mBAAmB,CAAS;IACpC,iFAAiF;IACjF,OAAO,CAAC,iBAAiB,CAAK;IAC9B,iFAAiF;IACjF,OAAO,CAAC,mBAAmB,CAAgC;IAE3D,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAsB;IAEhE;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAWzB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;gBA4Bb,WAAW,EAAE,mBAAmB,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE;IAiVvE,OAAO,CAAC,cAAc;IAItB;;;;;;;;;OASG;IACH,OAAO,CAAC,oBAAoB;IAkB5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IA4BrB,gEAAgE;IAChE,OAAO,CAAC,aAAa;IAUrB;;;;;;OAMG;YACW,UAAU;IA2CxB;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAiJzB;;;;;;;;;OASG;YACW,kBAAkB;IAiHhC;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;YACW,qBAAqB;YAiBrB,kBAAkB;IA6FhC,yFAAyF;IACzF,OAAO,CAAC,gBAAgB;IASxB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,6BAA6B;IA0BrC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,yBAAyB;IA+BjC,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,aAAa;YAmQP,kBAAkB;YA6BlB,yBAAyB;IASvC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;YAyBd,QAAQ;IA+vFtB;;;;;;OAMG;IACH;;;;;;;;;;;;;oDAagD;IAChD,OAAO,CAAC,mBAAmB,CAAoD;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAClD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAU;IAC1D,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,yBAAyB,CAAS;IAC1D;;mDAE+C;IAC/C,OAAO,CAAC,0BAA0B,CAAoC;IAEtE;;;;;;;;OAQG;YACW,0BAA0B;YAyC1B,oBAAoB;YA6CpB,2BAA2B;IAQzC;;;;OAIG;YACW,cAAc;IAQ5B,OAAO,CAAC,mBAAmB;IA2uC3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAe3C;IAEF;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;YAmCpB,cAAc;IA4G5B,oEAAoE;IACpE,OAAO,CAAC,iBAAiB;IAoBzB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,eAAe;YAuCT,gBAAgB;IAimC9B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAwUzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA6UxB,GAAG;CAyCV"}
package/dist/server.js CHANGED
@@ -4380,26 +4380,80 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4380
4380
  * Widget shortcuts are filtered to only the detected builder's element types.
4381
4381
  * Core tools (site context, pages, posts, media, menus, etc.) always remain.
4382
4382
  */
4383
- /** Cached site context for tool filtering (refreshed on site switch). */
4383
+ /** Cached site context, shared by tool filtering and the WooCommerce/ACF
4384
+ * availability checks below — all three used to independently call
4385
+ * getSiteContext() (getCompactSiteContext() itself just wraps it), meaning
4386
+ * every getTools() invocation made up to three uncached, sequential live
4387
+ * HTTP calls to the customer's own WordPress site before it could return
4388
+ * the tool catalog. Each call carries the wordpress-client retry backoff
4389
+ * (200/600/1800ms across 3 attempts), so a slow or failing site could push
4390
+ * a single tools/list response past 7s — long enough for an MCP client's
4391
+ * own handshake patience to give up and tear down the process, which
4392
+ * looks identical to a crash with zero stderr output (nathankohut.co.uk,
4393
+ * Windows, 2026-07-19). getContextForToolFiltering() below is the only
4394
+ * caller of currentSite.getSiteContext() in this class; everything that
4395
+ * used to call it directly or via getCompactSiteContext() now shares this
4396
+ * one cached, timeout-bounded fetch instead. */
4384
4397
  cachedFilterContext = null;
4385
- static FILTER_CACHE_TTL = 60_000; // 1 minute
4386
- async filterToolsByContext(tools) {
4398
+ static FILTER_CACHE_TTL = 60_000; // successful fetch: 1 minute
4399
+ static FILTER_CACHE_FAILURE_TTL = 15_000; // failed fetch: retry sooner
4400
+ static FILTER_CONTEXT_TIMEOUT_MS = 2_000;
4401
+ /** Dedupes concurrent callers (e.g. isWooCommerceAddonAvailable() and
4402
+ * isAcfAvailable() both missing a cold cache at once) onto one in-flight
4403
+ * fetch instead of each starting their own. */
4404
+ filterContextFetchInFlight = null;
4405
+ /**
4406
+ * Fetch (or reuse a cached / in-flight) site context for tool-catalog
4407
+ * decisions. Bounded to FILTER_CONTEXT_TIMEOUT_MS regardless of how long
4408
+ * the underlying client's own retry backoff would otherwise take, so a
4409
+ * slow or unreachable site can never block tools/list indefinitely.
4410
+ * Resolves to null (and caches the miss briefly) on timeout or any fetch
4411
+ * error — callers treat null as "show everything / skip context-dependent
4412
+ * gating" rather than failing the whole tool list.
4413
+ */
4414
+ async getContextForToolFiltering() {
4387
4415
  if (!this.currentSite) {
4388
- return tools; // No site connected — show everything.
4416
+ return null;
4389
4417
  }
4390
- let context;
4391
4418
  const now = Date.now();
4392
- if (this.cachedFilterContext && (now - this.cachedFilterContext.timestamp) < RespiraWordPressServer.FILTER_CACHE_TTL) {
4393
- context = this.cachedFilterContext.context;
4419
+ if (this.cachedFilterContext) {
4420
+ const ttl = this.cachedFilterContext.context
4421
+ ? RespiraWordPressServer.FILTER_CACHE_TTL
4422
+ : RespiraWordPressServer.FILTER_CACHE_FAILURE_TTL;
4423
+ if (now - this.cachedFilterContext.timestamp < ttl) {
4424
+ return this.cachedFilterContext.context;
4425
+ }
4394
4426
  }
4395
- else {
4427
+ if (this.filterContextFetchInFlight) {
4428
+ return this.filterContextFetchInFlight;
4429
+ }
4430
+ const fetchPromise = (async () => {
4431
+ let context = null;
4396
4432
  try {
4397
- context = await this.currentSite.getCompactSiteContext();
4398
- this.cachedFilterContext = { context, timestamp: now };
4433
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('site context fetch timed out')), RespiraWordPressServer.FILTER_CONTEXT_TIMEOUT_MS));
4434
+ context = await Promise.race([this.currentSite.getSiteContext(), timeout]);
4399
4435
  }
4400
4436
  catch {
4401
- return tools; // Can't fetch context show everything.
4437
+ context = null;
4402
4438
  }
4439
+ this.cachedFilterContext = { context, timestamp: now };
4440
+ return context;
4441
+ })();
4442
+ this.filterContextFetchInFlight = fetchPromise;
4443
+ try {
4444
+ return await fetchPromise;
4445
+ }
4446
+ finally {
4447
+ this.filterContextFetchInFlight = null;
4448
+ }
4449
+ }
4450
+ async filterToolsByContext(tools) {
4451
+ if (!this.currentSite) {
4452
+ return tools; // No site connected — show everything.
4453
+ }
4454
+ const context = await this.getContextForToolFiltering();
4455
+ if (!context) {
4456
+ return tools; // Can't fetch context — show everything.
4403
4457
  }
4404
4458
  const detectedBuilder = (context?.page_builder?.name || '').toLowerCase();
4405
4459
  const hasWooCommerce = Boolean(context?.woocommerce?.active || context?.addons?.woocommerce?.installed);
@@ -4430,16 +4484,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4430
4484
  });
4431
4485
  }
4432
4486
  async isWooCommerceAddonAvailable() {
4433
- if (!this.currentSite) {
4434
- return false;
4435
- }
4436
- try {
4437
- const context = await this.currentSite.getSiteContext();
4438
- return Boolean(context.addons?.woocommerce?.installed && context.addons?.woocommerce?.licensed);
4439
- }
4440
- catch {
4487
+ const context = await this.getContextForToolFiltering();
4488
+ if (!context) {
4441
4489
  return false;
4442
4490
  }
4491
+ return Boolean(context.addons?.woocommerce?.installed && context.addons?.woocommerce?.licensed);
4443
4492
  }
4444
4493
  /**
4445
4494
  * Detect whether ACF is active on the currently selected site.
@@ -4447,16 +4496,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4447
4496
  * (a Pro tool on a non-Pro site returns PRO_FEATURE_REQUIRED).
4448
4497
  */
4449
4498
  async isAcfAvailable() {
4450
- if (!this.currentSite) {
4451
- return false;
4452
- }
4453
- try {
4454
- const context = await this.currentSite.getSiteContext();
4455
- return Boolean(context.addons?.acf?.installed);
4456
- }
4457
- catch {
4499
+ const context = await this.getContextForToolFiltering();
4500
+ if (!context) {
4458
4501
  return false;
4459
4502
  }
4503
+ return Boolean(context.addons?.acf?.installed);
4460
4504
  }
4461
4505
  getWooCommerceTools() {
4462
4506
  return [
@@ -6401,7 +6445,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6401
6445
  case 'wordpress_update_media_batch':
6402
6446
  return await client.updateMediaBatch(args.items);
6403
6447
  case 'wordpress_delete_media':
6404
- return await client.deleteMedia(args.id);
6448
+ return await client.deleteMedia(args.id, args.force, args.approval_token);
6405
6449
  // Menu Management
6406
6450
  case 'wordpress_list_menus':
6407
6451
  return await client.listMenus();