@respira/wordpress-mcp-server 7.4.0 → 7.4.1

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,18 @@
1
+ /**
2
+ * Tests for the site-list self-heal (maybeSelfHealSiteList / performSiteRefresh).
3
+ *
4
+ * Background: the running MCP server boots from a frozen site list
5
+ * (RESPIRA_CONFIG_B64 env, which outranks the file, or ~/.respira/config.json,
6
+ * which Cowork's sandbox can't write). When a user adds a site on the
7
+ * dashboard, that frozen list never learns about it, so the site is invisible
8
+ * until the connector is deleted and reinstalled. Mario (3 sites) and Emil
9
+ * (4 sites, Cowork saw 3) both hit this. The fix re-pulls the account's current
10
+ * inventory from /api/mcp/config/refresh using a `respira_site_` token the
11
+ * server already holds and merges missing sites into the live map in-session.
12
+ *
13
+ * Private methods are reached through `as any` (compile-time-only constraint),
14
+ * matching the house convention in default-site-routing-notice.test.ts. `fetch`
15
+ * is stubbed on globalThis per test and restored after.
16
+ */
17
+ export {};
18
+ //# sourceMappingURL=site-list-self-heal.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-list-self-heal.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/site-list-self-heal.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Tests for the site-list self-heal (maybeSelfHealSiteList / performSiteRefresh).
3
+ *
4
+ * Background: the running MCP server boots from a frozen site list
5
+ * (RESPIRA_CONFIG_B64 env, which outranks the file, or ~/.respira/config.json,
6
+ * which Cowork's sandbox can't write). When a user adds a site on the
7
+ * dashboard, that frozen list never learns about it, so the site is invisible
8
+ * until the connector is deleted and reinstalled. Mario (3 sites) and Emil
9
+ * (4 sites, Cowork saw 3) both hit this. The fix re-pulls the account's current
10
+ * inventory from /api/mcp/config/refresh using a `respira_site_` token the
11
+ * server already holds and merges missing sites into the live map in-session.
12
+ *
13
+ * Private methods are reached through `as any` (compile-time-only constraint),
14
+ * matching the house convention in default-site-routing-notice.test.ts. `fetch`
15
+ * is stubbed on globalThis per test and restored after.
16
+ */
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { RespiraWordPressServer } from '../server.js';
20
+ const SITE_A = {
21
+ id: 'simply1888-com',
22
+ name: 'Simply 1888',
23
+ url: 'https://simply1888.com',
24
+ apiKey: 'respira_site_aaa',
25
+ default: true,
26
+ };
27
+ function refreshPayload(sites) {
28
+ return {
29
+ ok: true,
30
+ json: async () => ({ success: true, config: { sites }, sites_count: sites.length }),
31
+ };
32
+ }
33
+ function stubFetch(impl) {
34
+ const prev = globalThis.fetch;
35
+ let seen = 0;
36
+ globalThis.fetch = async (url, init) => {
37
+ seen += 1;
38
+ return impl(url, init);
39
+ };
40
+ stubFetch.lastCount = () => seen;
41
+ return () => {
42
+ globalThis.fetch = prev;
43
+ };
44
+ }
45
+ test('self-heal adds a site that appeared on the dashboard after startup', async () => {
46
+ const server = new RespiraWordPressServer([SITE_A]);
47
+ const restore = stubFetch(async (url, init) => {
48
+ assert.match(url, /\/api\/mcp\/config\/refresh$/);
49
+ // it must authenticate with the respira_site_ token the server holds
50
+ assert.match(String(init.body), /respira_site_aaa/);
51
+ return refreshPayload([
52
+ { id: 'simply1888-com', url: 'https://simply1888.com', apiKey: 'respira_site_aaa', name: 'Simply 1888' },
53
+ { id: 'thebranchespsychology-com', url: 'https://thebranchespsychology.com', apiKey: 'respira_site_bbb', name: 'Branches' },
54
+ ]);
55
+ });
56
+ try {
57
+ const added = await server.maybeSelfHealSiteList(true);
58
+ assert.equal(added, 1, 'one new site should be merged');
59
+ assert.ok(server.sites.has('thebranchespsychology-com'), 'new site now in the live map');
60
+ assert.ok(server.sites.has('simply1888-com'), 'existing site untouched');
61
+ }
62
+ finally {
63
+ restore();
64
+ }
65
+ });
66
+ test('dedupes by normalized URL even when the frozen id differs from canonical', async () => {
67
+ const server = new RespiraWordPressServer([SITE_A]);
68
+ const restore = stubFetch(async () =>
69
+ // same site, different id + www + trailing slash: must NOT double-add
70
+ refreshPayload([
71
+ { id: 'simply1888-DIFFERENT-id', url: 'https://www.simply1888.com/', apiKey: 'respira_site_aaa', name: 'Simply 1888' },
72
+ ]));
73
+ try {
74
+ const added = await server.maybeSelfHealSiteList(true);
75
+ assert.equal(added, 0, 'same URL under a different id should not be re-added');
76
+ assert.equal(server.sites.size, 1);
77
+ }
78
+ finally {
79
+ restore();
80
+ }
81
+ });
82
+ test('throttles: a second unforced call within the window does not re-fetch', async () => {
83
+ const server = new RespiraWordPressServer([SITE_A]);
84
+ let calls = 0;
85
+ const restore = stubFetch(async () => {
86
+ calls += 1;
87
+ return refreshPayload([{ id: 'simply1888-com', url: 'https://simply1888.com', apiKey: 'respira_site_aaa' }]);
88
+ });
89
+ try {
90
+ await server.maybeSelfHealSiteList(true); // primes lastSiteRefreshAt
91
+ const added = await server.maybeSelfHealSiteList(false); // within 60s window
92
+ assert.equal(added, 0);
93
+ assert.equal(calls, 1, 'the throttled call must not hit the network');
94
+ }
95
+ finally {
96
+ restore();
97
+ }
98
+ });
99
+ test('no respira_site_ token in config → graceful no-op, no fetch', async () => {
100
+ const oauthOnly = {
101
+ id: 'oauth-site',
102
+ name: 'OAuth Site',
103
+ url: 'https://oauth.example',
104
+ apiKey: 'rsp_at_perSiteOAuthToken',
105
+ default: true,
106
+ };
107
+ const server = new RespiraWordPressServer([oauthOnly]);
108
+ let calls = 0;
109
+ const restore = stubFetch(async () => {
110
+ calls += 1;
111
+ return refreshPayload([]);
112
+ });
113
+ try {
114
+ const added = await server.maybeSelfHealSiteList(true);
115
+ assert.equal(added, 0);
116
+ assert.equal(calls, 0, 'must not call the backend without an account-wide token');
117
+ }
118
+ finally {
119
+ restore();
120
+ }
121
+ });
122
+ test('backend failure never throws and adds nothing', async () => {
123
+ const server = new RespiraWordPressServer([SITE_A]);
124
+ const restore = stubFetch(async () => ({ ok: false, status: 500, json: async () => ({}) }));
125
+ try {
126
+ const added = await server.maybeSelfHealSiteList(true);
127
+ assert.equal(added, 0, 'non-ok response yields 0 added');
128
+ assert.equal(server.sites.size, 1, 'existing sites remain');
129
+ }
130
+ finally {
131
+ restore();
132
+ }
133
+ });
134
+ test('network throw is swallowed (offline / firewall)', async () => {
135
+ const server = new RespiraWordPressServer([SITE_A]);
136
+ const restore = stubFetch(async () => {
137
+ throw new Error('ENOTFOUND');
138
+ });
139
+ try {
140
+ const added = await server.maybeSelfHealSiteList(true);
141
+ assert.equal(added, 0);
142
+ }
143
+ finally {
144
+ restore();
145
+ }
146
+ });
147
+ test('disabled via RESPIRA_DISABLE_SITE_REFRESH', async () => {
148
+ const prev = process.env.RESPIRA_DISABLE_SITE_REFRESH;
149
+ process.env.RESPIRA_DISABLE_SITE_REFRESH = '1';
150
+ const server = new RespiraWordPressServer([SITE_A]);
151
+ let calls = 0;
152
+ const restore = stubFetch(async () => {
153
+ calls += 1;
154
+ return refreshPayload([]);
155
+ });
156
+ try {
157
+ const added = await server.maybeSelfHealSiteList(true);
158
+ assert.equal(added, 0);
159
+ assert.equal(calls, 0, 'kill switch must short-circuit before any fetch');
160
+ }
161
+ finally {
162
+ restore();
163
+ if (prev === undefined)
164
+ delete process.env.RESPIRA_DISABLE_SITE_REFRESH;
165
+ else
166
+ process.env.RESPIRA_DISABLE_SITE_REFRESH = prev;
167
+ }
168
+ });
169
+ //# sourceMappingURL=site-list-self-heal.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-list-self-heal.test.js","sourceRoot":"","sources":["../../src/__tests__/site-list-self-heal.test.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -15,6 +15,10 @@ export declare class RespiraWordPressServer {
15
15
  private allowedSites;
16
16
  /** Whether the plugin version warning has already been shown this session. */
17
17
  private versionWarningShown;
18
+ /** Epoch ms of the last site-list self-heal against respira.press (throttle). */
19
+ private lastSiteRefreshAt;
20
+ /** Dedupe concurrent self-heals so parallel list_sites calls share one fetch. */
21
+ private siteRefreshInFlight;
18
22
  private static readonly MCP_SERVER_VERSION;
19
23
  /**
20
24
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -89,6 +93,37 @@ export declare class RespiraWordPressServer {
89
93
  * a structured error so the AI can explain the failure plainly.
90
94
  */
91
95
  private redeemInstallToken;
96
+ /**
97
+ * Self-heal the in-memory site list from respira.press.
98
+ *
99
+ * The running server boots from whatever site list was frozen into its
100
+ * config at startup (RESPIRA_CONFIG_B64 env, or ~/.respira/config.json).
101
+ * When the user later adds a site on the dashboard, that frozen list never
102
+ * learns about it: RESPIRA_CONFIG_B64 outranks the file (see loadConfig), and
103
+ * Cowork's sandbox can't even write the file, so `redeem` can't persist a new
104
+ * site there. The only workaround users found was deleting and reinstalling
105
+ * the connector for every new site (Mario, 3 sites; Emil, 4 sites Cowork saw
106
+ * as 3). That is the bug this fixes.
107
+ *
108
+ * Here we re-pull the account's CURRENT canonical inventory using a
109
+ * credential the server already holds (any one live `respira_site_` token)
110
+ * via /api/mcp/config/refresh, and merge any missing sites straight into the
111
+ * live `this.sites` map. Because the merge is in-memory it takes effect in
112
+ * the SAME session with no restart, no re-paste, and no file write, so it
113
+ * works identically under RESPIRA_CONFIG_B64 and inside Cowork's sandbox.
114
+ *
115
+ * Best-effort and non-blocking: any failure (offline, OAuth-only config with
116
+ * no site token, backend error, timeout) resolves to 0 added and never
117
+ * throws, so list_sites still returns the sites already known. Throttled to
118
+ * at most once per RESPIRA_SITE_REFRESH_THROTTLE_MS (default 60s) and
119
+ * deduped so parallel calls share one fetch.
120
+ *
121
+ * @returns number of newly added sites.
122
+ */
123
+ private maybeSelfHealSiteList;
124
+ private performSiteRefresh;
125
+ /** Normalize a site URL for cross-config dedupe: scheme/host only, no www, lowercase. */
126
+ private normalizeSiteUrl;
92
127
  /**
93
128
  * When a WRITE tool runs against the default site because the caller
94
129
  * omitted site_id and the account has more than one site connected,
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAoBH,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,kBAAkB,CAAC;AAkQzE,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;IAEpC,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;;;;;;;;;;;;;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;IAkpC3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAe3C;IAEF;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;YAmCpB,cAAc;IAqG5B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,eAAe;YAuCT,gBAAgB;IA8iC9B;;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;AAoBH,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,kBAAkB,CAAC;AAkQzE,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;IAkpC3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAe3C;IAEF;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;YAmCpB,cAAc;IAqG5B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,eAAe;YAuCT,gBAAgB;IAyjC9B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAwUzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA6UxB,GAAG;CAyCV"}
package/dist/server.js CHANGED
@@ -263,6 +263,10 @@ export class RespiraWordPressServer {
263
263
  allowedSites = null;
264
264
  /** Whether the plugin version warning has already been shown this session. */
265
265
  versionWarningShown = false;
266
+ /** Epoch ms of the last site-list self-heal against respira.press (throttle). */
267
+ lastSiteRefreshAt = 0;
268
+ /** Dedupe concurrent self-heals so parallel list_sites calls share one fetch. */
269
+ siteRefreshInFlight = null;
266
270
  static MCP_SERVER_VERSION = MCP_SERVER_VERSION;
267
271
  /**
268
272
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -1044,6 +1048,146 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1044
1048
  message,
1045
1049
  };
1046
1050
  }
1051
+ /**
1052
+ * Self-heal the in-memory site list from respira.press.
1053
+ *
1054
+ * The running server boots from whatever site list was frozen into its
1055
+ * config at startup (RESPIRA_CONFIG_B64 env, or ~/.respira/config.json).
1056
+ * When the user later adds a site on the dashboard, that frozen list never
1057
+ * learns about it: RESPIRA_CONFIG_B64 outranks the file (see loadConfig), and
1058
+ * Cowork's sandbox can't even write the file, so `redeem` can't persist a new
1059
+ * site there. The only workaround users found was deleting and reinstalling
1060
+ * the connector for every new site (Mario, 3 sites; Emil, 4 sites Cowork saw
1061
+ * as 3). That is the bug this fixes.
1062
+ *
1063
+ * Here we re-pull the account's CURRENT canonical inventory using a
1064
+ * credential the server already holds (any one live `respira_site_` token)
1065
+ * via /api/mcp/config/refresh, and merge any missing sites straight into the
1066
+ * live `this.sites` map. Because the merge is in-memory it takes effect in
1067
+ * the SAME session with no restart, no re-paste, and no file write, so it
1068
+ * works identically under RESPIRA_CONFIG_B64 and inside Cowork's sandbox.
1069
+ *
1070
+ * Best-effort and non-blocking: any failure (offline, OAuth-only config with
1071
+ * no site token, backend error, timeout) resolves to 0 added and never
1072
+ * throws, so list_sites still returns the sites already known. Throttled to
1073
+ * at most once per RESPIRA_SITE_REFRESH_THROTTLE_MS (default 60s) and
1074
+ * deduped so parallel calls share one fetch.
1075
+ *
1076
+ * @returns number of newly added sites.
1077
+ */
1078
+ async maybeSelfHealSiteList(force = false) {
1079
+ if (process.env.RESPIRA_DISABLE_SITE_REFRESH === '1') {
1080
+ return 0;
1081
+ }
1082
+ const throttleMs = Number(process.env.RESPIRA_SITE_REFRESH_THROTTLE_MS) || 60_000;
1083
+ if (!force && Date.now() - this.lastSiteRefreshAt < throttleMs) {
1084
+ return 0;
1085
+ }
1086
+ if (this.siteRefreshInFlight) {
1087
+ return this.siteRefreshInFlight;
1088
+ }
1089
+ this.siteRefreshInFlight = this.performSiteRefresh().finally(() => {
1090
+ this.siteRefreshInFlight = null;
1091
+ });
1092
+ return this.siteRefreshInFlight;
1093
+ }
1094
+ async performSiteRefresh() {
1095
+ // Any one live dashboard site token identifies the account. OAuth
1096
+ // per-site tokens (rsp_at_) are not account-wide and are skipped.
1097
+ let token;
1098
+ for (const client of this.sites.values()) {
1099
+ const key = client.getApiKey?.();
1100
+ if (key && key.startsWith('respira_site_')) {
1101
+ token = key;
1102
+ break;
1103
+ }
1104
+ }
1105
+ // Stamp the attempt time even when we bail, so a token-less config doesn't
1106
+ // retry the loop on every single list_sites call.
1107
+ this.lastSiteRefreshAt = Date.now();
1108
+ if (!token) {
1109
+ return 0;
1110
+ }
1111
+ const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
1112
+ const url = `${apiBase.replace(/\/+$/, '')}/api/mcp/config/refresh`;
1113
+ const timeoutMs = Number(process.env.RESPIRA_SITE_REFRESH_TIMEOUT_MS) || 8000;
1114
+ let payload;
1115
+ try {
1116
+ const response = await fetch(url, {
1117
+ method: 'POST',
1118
+ headers: { 'Content-Type': 'application/json' },
1119
+ body: JSON.stringify({ token }),
1120
+ signal: AbortSignal.timeout(timeoutMs),
1121
+ });
1122
+ if (!response.ok) {
1123
+ return 0;
1124
+ }
1125
+ payload = await response.json();
1126
+ }
1127
+ catch {
1128
+ // Offline / proxy / firewall / timeout: never block list_sites on this.
1129
+ return 0;
1130
+ }
1131
+ const sites = payload?.config?.sites;
1132
+ if (!Array.isArray(sites)) {
1133
+ return 0;
1134
+ }
1135
+ const knownUrls = new Set();
1136
+ for (const client of this.sites.values()) {
1137
+ knownUrls.add(this.normalizeSiteUrl(client.getSiteUrl()));
1138
+ }
1139
+ let added = 0;
1140
+ for (const s of sites) {
1141
+ if (!s || typeof s !== 'object' || !s.id || !s.url || !s.apiKey) {
1142
+ continue;
1143
+ }
1144
+ // Dedupe by id AND by normalized URL (a stale frozen config may hold the
1145
+ // same site under a different id than the canonical inventory returns).
1146
+ if (this.sites.has(s.id) || knownUrls.has(this.normalizeSiteUrl(s.url))) {
1147
+ continue;
1148
+ }
1149
+ try {
1150
+ const client = new WordPressClient({
1151
+ id: s.id,
1152
+ url: s.url,
1153
+ apiKey: s.apiKey,
1154
+ name: s.name || s.id,
1155
+ default: false,
1156
+ });
1157
+ this.sites.set(s.id, client);
1158
+ knownUrls.add(this.normalizeSiteUrl(s.url));
1159
+ if (!this.currentSite) {
1160
+ this.currentSite = client;
1161
+ this.defaultSiteId = s.id;
1162
+ }
1163
+ try {
1164
+ getUsageEmitter().registerSiteToken(s.url, s.apiKey);
1165
+ }
1166
+ catch {
1167
+ // usage telemetry never blocks
1168
+ }
1169
+ added += 1;
1170
+ }
1171
+ catch {
1172
+ // A single malformed site entry never aborts the merge.
1173
+ }
1174
+ }
1175
+ if (added > 0) {
1176
+ console.error(`respira-mcp: self-heal added ${added} site${added === 1 ? '' : 's'} from respira.press ` +
1177
+ `(now ${this.sites.size} connected).`);
1178
+ }
1179
+ return added;
1180
+ }
1181
+ /** Normalize a site URL for cross-config dedupe: scheme/host only, no www, lowercase. */
1182
+ normalizeSiteUrl(raw) {
1183
+ try {
1184
+ const u = new URL(raw);
1185
+ return u.host.replace(/^www\./i, '').toLowerCase() + u.pathname.replace(/\/+$/, '');
1186
+ }
1187
+ catch {
1188
+ return String(raw || '').trim().toLowerCase().replace(/\/+$/, '');
1189
+ }
1190
+ }
1047
1191
  /**
1048
1192
  * When a WRITE tool runs against the default site because the caller
1049
1193
  * omitted site_id and the account has more than one site connected,
@@ -5705,6 +5849,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5705
5849
  ? await client.getSiteContext()
5706
5850
  : await client.getCompactSiteContext();
5707
5851
  case 'wordpress_list_sites': {
5852
+ // Self-heal first so a site added on the dashboard after this server
5853
+ // started shows up here without a reinstall or re-paste (throttled,
5854
+ // best-effort, never throws).
5855
+ await this.maybeSelfHealSiteList();
5708
5856
  const allSites = Array.from(this.sites.values());
5709
5857
  const visibleSites = allSites.filter((site) => this.isSiteAllowed(site));
5710
5858
  // v6.17.2: surface hidden sites so the AI can explain
@@ -5883,9 +6031,16 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5883
6031
  respira_approvals_url: client.getApprovalsUrl(),
5884
6032
  };
5885
6033
  case 'wordpress_switch_site': {
5886
- const newSite = this.sites.get(args.site_id);
6034
+ let newSite = this.sites.get(args.site_id);
6035
+ if (!newSite) {
6036
+ // The target may be a site added on the dashboard after this server
6037
+ // started. Self-heal once, then retry before giving up.
6038
+ await this.maybeSelfHealSiteList();
6039
+ newSite = this.sites.get(args.site_id);
6040
+ }
5887
6041
  if (!newSite) {
5888
- throw new Error(`Site with ID "${args.site_id}" not found in configuration`);
6042
+ const available = Array.from(this.sites.keys()).join(', ') || '(none configured)';
6043
+ throw new Error(`Site with ID "${args.site_id}" not found in configuration. Available: ${available}`);
5889
6044
  }
5890
6045
  if (!this.isSiteAllowed(newSite)) {
5891
6046
  throw new Error(`Site "${args.site_id}" is not in this MCP configuration group.`);