@respira/wordpress-mcp-server 8.2.2 → 8.2.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/__tests__/invoke-ability-required-args.test.d.ts +2 -0
  3. package/dist/__tests__/invoke-ability-required-args.test.d.ts.map +1 -0
  4. package/dist/__tests__/invoke-ability-required-args.test.js +53 -0
  5. package/dist/__tests__/invoke-ability-required-args.test.js.map +1 -0
  6. package/dist/__tests__/oq-put-fallback-callRestV2-integration.test.d.ts +24 -0
  7. package/dist/__tests__/oq-put-fallback-callRestV2-integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/oq-put-fallback-callRestV2-integration.test.js +135 -0
  9. package/dist/__tests__/oq-put-fallback-callRestV2-integration.test.js.map +1 -0
  10. package/dist/__tests__/transport-mode-observability.test.d.ts +2 -0
  11. package/dist/__tests__/transport-mode-observability.test.d.ts.map +1 -0
  12. package/dist/__tests__/transport-mode-observability.test.js +201 -0
  13. package/dist/__tests__/transport-mode-observability.test.js.map +1 -0
  14. package/dist/__tests__/woo-card-tools-forward-fse-params.test.js +2 -2
  15. package/dist/__tests__/woo-card-tools-forward-fse-params.test.js.map +1 -1
  16. package/dist/config.d.ts +102 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +229 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/server.d.ts +42 -0
  21. package/dist/server.d.ts.map +1 -1
  22. package/dist/server.js +218 -24
  23. package/dist/server.js.map +1 -1
  24. package/dist/wordpress-client.d.ts +17 -0
  25. package/dist/wordpress-client.d.ts.map +1 -1
  26. package/dist/wordpress-client.js +43 -0
  27. package/dist/wordpress-client.js.map +1 -1
  28. package/package.json +1 -1
  29. package/skills/design-system-synthesizer/SKILL.md +30 -9
  30. package/skills/design-system-synthesizer/metadata.json +6 -3
  31. package/skills/figma-to-elementor/SKILL.md +14 -2
  32. package/skills/html-to-bricks/SKILL.md +16 -2
  33. package/skills/html-to-bricks/metadata.json +4 -3
  34. package/skills/migrate-beaver-builder-to-bricks/SKILL.md +20 -3
  35. package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +20 -3
  36. package/skills/migrate-brizy-to-gutenberg/SKILL.md +20 -3
  37. package/skills/migrate-divi-to-breakdance/SKILL.md +20 -3
  38. package/skills/migrate-divi-to-bricks/SKILL.md +20 -3
  39. package/skills/migrate-divi-to-gutenberg/SKILL.md +20 -3
  40. package/skills/migrate-elementor-to-breakdance/SKILL.md +21 -4
  41. package/skills/migrate-elementor-to-bricks/SKILL.md +21 -4
  42. package/skills/migrate-elementor-to-gutenberg/SKILL.md +21 -4
  43. package/skills/migrate-elementor-to-oxygen/SKILL.md +21 -4
  44. package/skills/migrate-oxygen-to-breakdance/SKILL.md +20 -3
  45. package/skills/migrate-oxygen-to-bricks/SKILL.md +20 -3
  46. package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +20 -3
  47. package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +20 -3
  48. package/skills/migrate-wpbakery-to-bricks/SKILL.md +20 -3
  49. package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +20 -3
  50. package/skills/prime-the-agent/SKILL.md +35 -13
  51. package/skills/prime-the-agent/metadata.json +6 -5
  52. package/skills/respira-setup/SKILL.md +0 -72
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ import { getBricksTools, dispatchBricksTool } from './bricks-tools.js';
18
18
  import { getElementorTools, dispatchElementorTool } from './elementor-tools.js';
19
19
  import { getAcfTools, resolveAcfToolName } from './acf-tools.js';
20
20
  import { getUsageEmitter, deriveToolKind } from './usage-emitter.js';
21
+ import { collapseLauncherProcesses, validateApiKeyShape } from './config.js';
21
22
  // Process-local secret keeps target hashes useful for same-session retry
22
23
  // detection without making low-entropy WordPress ids reversible centrally.
23
24
  const TELEMETRY_HASH_SECRET = randomUUID();
@@ -144,6 +145,35 @@ function getMaxToolTimeoutMs() {
144
145
  * possible top-level `dropped_settings` array, and a pre-summarised
145
146
  * `styling_dropped` count/string if the plugin sends one.
146
147
  */
148
+ /**
149
+ * The `ability` argument for wordpress_invoke_ability, or a refusal that says
150
+ * how to fix the call.
151
+ *
152
+ * Validated here rather than letting `undefined` ride to the site. The POST
153
+ * body drops the key, WordPress answers `rest_missing_callback_param`, and the
154
+ * agent is told a REST parameter is missing without being told which one or
155
+ * what this tool calls it. Seven of those across three sites, and the recovery
156
+ * path from that message is guesswork.
157
+ *
158
+ * Aliases are named in the error rather than silently accepted. Quietly
159
+ * honouring `ability_name` would make the tool's own schema a lie, and the next
160
+ * agent would learn the wrong contract from a call that happened to work.
161
+ */
162
+ export function requireAbilityName(args) {
163
+ const ability = args?.ability;
164
+ if (typeof ability === 'string' && ability.trim() !== '') {
165
+ return ability.trim();
166
+ }
167
+ const aliases = ['name', 'ability_name', 'abilityName', 'tool', 'slug'].filter((key) => typeof args?.[key] === 'string' && String(args[key]).trim() !== '');
168
+ const sawInstead = aliases.length
169
+ ? ` You sent ${aliases.map((k) => '`' + k + '`').join(' and ')} instead; rename it to \`ability\`.`
170
+ : '';
171
+ throw new Error('wordpress_invoke_ability requires `ability`, the full registered name in the form ' +
172
+ '"vendor/ability-name" (for example "rankmath/get-meta").' +
173
+ sawInstead +
174
+ ' Arguments for the ability go in `args`, not at the top level.' +
175
+ ' Call wordpress_search_abilities first if you do not know the exact name.');
176
+ }
147
177
  function hoistDroppedStylingWarnings(result) {
148
178
  if (!result || typeof result !== 'object') {
149
179
  return result;
@@ -1019,6 +1049,18 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1019
1049
  message: 'The token redeemed but the account has no connected WordPress sites yet. Add a site at respira.press/dashboard/sites and try again.',
1020
1050
  };
1021
1051
  }
1052
+ // Make the redeemed sites live in THIS session before touching the disk.
1053
+ //
1054
+ // This is the fix for the shape M.S. reported twice (ce28ca7e, 135558a9):
1055
+ // redeem returns success, writes a file, and the running server carries on
1056
+ // with exactly the site list it booted with. Under RESPIRA_CONFIG_B64 the
1057
+ // next start ignores that file too, so the redeem is a no-op that reports
1058
+ // success — the worst possible combination. Merging into the live map is
1059
+ // the same in-memory path maybeSelfHealSiteList already uses, and it works
1060
+ // identically under B64 and inside a sandbox that cannot write at all.
1061
+ // Deliberately before the write, so a write failure still leaves a working
1062
+ // session instead of nothing.
1063
+ const activation = this.activateRedeemedSites(config.sites);
1022
1064
  // Write ~/.respira/config.json with the canonical shape. Make the
1023
1065
  // directory if absent. If a file already exists, back it up first
1024
1066
  // so the user can recover the previous config if they want.
@@ -1088,12 +1130,18 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1088
1130
  }
1089
1131
  catch (err) {
1090
1132
  const attempted = process.env.RESPIRA_CONFIG_FILE?.trim() || '~/.respira/config.json';
1133
+ const liveNow = activation.activated.length + activation.already_active.length;
1091
1134
  return {
1092
1135
  success: false,
1093
1136
  error: 'write_failed',
1094
1137
  // Name the ABSOLUTE path that failed. "could not write
1095
1138
  // ~/.respira/config.json" sends someone looking in the wrong home.
1096
- message: `Token redeemed but could not write the config to ${attempted} (${err?.message || 'unknown error'}). Download it from respira.press/dashboard/mcp and place it at that exact path.`,
1139
+ message: `Token redeemed but could not write the config to ${attempted} (${err?.message || 'unknown error'}). ` +
1140
+ (liveNow > 0
1141
+ ? `${liveNow} site${liveNow === 1 ? ' is' : 's are'} usable in THIS session anyway, but the redeem will not survive a restart until the file exists. `
1142
+ : '') +
1143
+ 'Download it from respira.press/dashboard/mcp and place it at that exact path.',
1144
+ activated_in_session: activation.activated,
1097
1145
  };
1098
1146
  }
1099
1147
  // This token's own sites (what was just redeemed) vs. the merged total
@@ -1135,9 +1183,15 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1135
1183
  };
1136
1184
  }
1137
1185
  const hasConfigB64 = Boolean(process.env.RESPIRA_CONFIG_B64);
1138
- const baseMessage = `Connected ${tokenSites.length} site${tokenSites.length === 1 ? '' : 's'} via this Respira Cowork token. ${allSites.length} site${allSites.length === 1 ? '' : 's'} total now configured at ${configPath}. Restart this Cowork chat (or open a new one) so the MCP server picks up the new sites.`;
1186
+ const liveCount = this.sites.size;
1187
+ const activationSentence = activation.activated.length > 0
1188
+ ? `${activation.activated.length} of them ${activation.activated.length === 1 ? 'is' : 'are'} live in this session already, no restart needed (${liveCount} site${liveCount === 1 ? '' : 's'} usable right now).`
1189
+ : activation.failed.length > 0
1190
+ ? `None of them could be activated in this session (${activation.failed.map((f) => `${f.url}: ${f.reason}`).join('; ')}). Restart the client after fixing that.`
1191
+ : 'They were already connected in this session, so nothing changed here.';
1192
+ const baseMessage = `Connected ${tokenSites.length} site${tokenSites.length === 1 ? '' : 's'} via this Respira Cowork token. ${allSites.length} site${allSites.length === 1 ? '' : 's'} total now written to ${configPath}. ${activationSentence}`;
1139
1193
  const message = hasConfigB64
1140
- ? `${baseMessage} IMPORTANT: your MCP server is currently configured via the RESPIRA_CONFIG_B64 environment variable, which takes precedence over the file on disk. To activate the new site, update RESPIRA_CONFIG_B64 in your agent's MCP server config with the base64-encoded contents of the new ~/.respira/config.json file, then restart the agent.`
1194
+ ? `${baseMessage} Note for next time: your MCP server is configured via the RESPIRA_CONFIG_B64 environment variable, which takes precedence over the file on disk. The sites above work now, but to keep them after a restart, update RESPIRA_CONFIG_B64 in your agent's MCP server config with the base64-encoded contents of ${configPath}.`
1141
1195
  : baseMessage;
1142
1196
  return {
1143
1197
  success: true,
@@ -1149,9 +1203,81 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1149
1203
  // Proof rather than assumption: what landed on disk, and whether the
1150
1204
  // next start will actually read it.
1151
1205
  write_verification: writeVerification,
1206
+ // Proof for the OTHER half: what this running server can use right now.
1207
+ // A redeem that changes neither of these two is a no-op, and now says so.
1208
+ session_activation: {
1209
+ activated_now: activation.activated,
1210
+ already_active: activation.already_active,
1211
+ failed: activation.failed,
1212
+ sites_usable_this_session: liveCount,
1213
+ },
1152
1214
  message,
1153
1215
  };
1154
1216
  }
1217
+ /**
1218
+ * Merge freshly redeemed sites into the LIVE site map.
1219
+ *
1220
+ * Same mechanism as maybeSelfHealSiteList, applied at the moment of redeem
1221
+ * instead of on the next list_sites call. Deduped by id and by normalised
1222
+ * URL, because a stale frozen config can hold the same site under a
1223
+ * different id than the token returns. Never throws: one malformed entry
1224
+ * must not fail a redeem that otherwise worked.
1225
+ */
1226
+ activateRedeemedSites(sites) {
1227
+ const activated = [];
1228
+ const alreadyActive = [];
1229
+ const failed = [];
1230
+ const knownUrls = new Set();
1231
+ for (const client of this.sites.values()) {
1232
+ knownUrls.add(this.normalizeSiteUrl(client.getSiteUrl()));
1233
+ }
1234
+ for (const s of Array.isArray(sites) ? sites : []) {
1235
+ if (!s || typeof s !== 'object' || !s.id || !s.url || !s.apiKey) {
1236
+ failed.push({ url: String(s?.url || '(no url)'), reason: 'incomplete site entry (id, url or key missing)' });
1237
+ continue;
1238
+ }
1239
+ const normalized = this.normalizeSiteUrl(s.url);
1240
+ if (this.sites.has(s.id) || knownUrls.has(normalized)) {
1241
+ alreadyActive.push({ id: String(s.id), url: String(s.url) });
1242
+ continue;
1243
+ }
1244
+ try {
1245
+ // Same gate loadConfig applies (8.1.4): a masked key must fail here,
1246
+ // loudly, not become a live site that 401s on its first tool call.
1247
+ validateApiKeyShape(String(s.apiKey), `redeemed site "${s.id}"`);
1248
+ const client = new WordPressClient({
1249
+ id: s.id,
1250
+ url: s.url,
1251
+ apiKey: s.apiKey,
1252
+ name: s.name || s.id,
1253
+ default: false,
1254
+ });
1255
+ this.sites.set(s.id, client);
1256
+ knownUrls.add(normalized);
1257
+ if (!this.currentSite) {
1258
+ this.currentSite = client;
1259
+ this.defaultSiteId = s.id;
1260
+ }
1261
+ try {
1262
+ getUsageEmitter().registerSiteToken(s.url, s.apiKey, () => client.getTelemetryToken());
1263
+ }
1264
+ catch {
1265
+ // usage telemetry never blocks a redeem
1266
+ }
1267
+ activated.push({ id: String(s.id), url: String(s.url) });
1268
+ }
1269
+ catch (err) {
1270
+ failed.push({ url: String(s.url), reason: String(err?.message || err) });
1271
+ }
1272
+ }
1273
+ if (activated.length > 0) {
1274
+ // The tool catalog is filtered per site; a new site can change it.
1275
+ this.cachedFilterContext = null;
1276
+ console.error(`respira-mcp: redeem activated ${activated.length} site${activated.length === 1 ? '' : 's'} in the running session ` +
1277
+ `(now ${this.sites.size} connected).`);
1278
+ }
1279
+ return { activated, already_active: alreadyActive, failed };
1280
+ }
1155
1281
  /**
1156
1282
  * Self-heal the in-memory site list from respira.press.
1157
1283
  *
@@ -3155,7 +3281,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3155
3281
  },
3156
3282
  {
3157
3283
  name: 'wordpress_diagnose_connection',
3158
- description: 'Run a connection-fingerprint diagnostic for the active site. Combines the plugin\'s server-side report (route registration, php/wp/plugin versions, edge plugin presence) with outside-in probes from the MCP server (REST root reachability, content-type sanity check on Respira routes, edge-layer headers). Use when a tool returns "html instead of json", an opaque 5xx, or when a connection that worked yesterday silently breaks. Returns a structured object including detected edge layers (Cloudflare, Wordfence, Sucuri) and concrete remediation recommendations.',
3284
+ description: 'Run a connection-fingerprint diagnostic for the active site. Combines the plugin\'s server-side report (route registration, php/wp/plugin versions, edge plugin presence) with outside-in probes from the MCP server (REST root reachability, content-type sanity check on Respira routes, edge-layer headers). Use when a tool returns "html instead of json", an opaque 5xx, or when a connection that worked yesterday silently breaks. ALSO use it when the user suspects the wrong site, a stale connection, or a setup step that "did nothing": the response carries transport_mode (which install is answering: mcpb_bundle, npx_cache, global_npm_install, project_local_install, source_checkout), transport.duplicate_entries (whether a second Respira server is running, which usually means the client config lists Respira twice), and client_config (which config source won and how many sites came out of it). Returns a structured object including detected edge layers (Cloudflare, Wordfence, Sucuri) and concrete remediation recommendations.',
3159
3285
  inputSchema: {
3160
3286
  type: 'object',
3161
3287
  properties: {
@@ -5934,7 +6060,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5934
6060
  dry_run: { type: 'boolean' },
5935
6061
  edit_target: {
5936
6062
  type: 'string',
5937
- enum: ['live', 'duplicate'],
6063
+ enum: ['approval', 'live'],
5938
6064
  description: 'Set "live" to write the template directly instead of creating an approval proposal. Requires respira_allow_direct_edit on the site; without that option the call is refused rather than silently downgraded. Omit to keep the default proposal flow.',
5939
6065
  },
5940
6066
  verification_marker: {
@@ -5958,7 +6084,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5958
6084
  dry_run: { type: 'boolean' },
5959
6085
  edit_target: {
5960
6086
  type: 'string',
5961
- enum: ['live', 'duplicate'],
6087
+ enum: ['approval', 'live'],
5962
6088
  description: 'Set "live" to write the template directly instead of creating an approval proposal. Requires respira_allow_direct_edit on the site; without that option the call is refused rather than silently downgraded. Omit to keep the default proposal flow.',
5963
6089
  },
5964
6090
  verification_marker: {
@@ -5978,7 +6104,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5978
6104
  dry_run: { type: 'boolean' },
5979
6105
  edit_target: {
5980
6106
  type: 'string',
5981
- enum: ['live', 'duplicate'],
6107
+ enum: ['approval', 'live'],
5982
6108
  description: 'Set "live" to write the template directly instead of creating an approval proposal. Requires respira_allow_direct_edit on the site; without that option the call is refused rather than silently downgraded. Omit to keep the default proposal flow.',
5983
6109
  },
5984
6110
  verification_marker: {
@@ -6008,7 +6134,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6008
6134
  dry_run: { type: 'boolean' },
6009
6135
  edit_target: {
6010
6136
  type: 'string',
6011
- enum: ['live', 'duplicate'],
6137
+ enum: ['approval', 'live'],
6012
6138
  description: 'Set "live" to write the template directly instead of creating an approval proposal. Requires respira_allow_direct_edit on the site; without that option the call is refused rather than silently downgraded. Omit to keep the default proposal flow.',
6013
6139
  },
6014
6140
  verification_marker: {
@@ -6998,7 +7124,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6998
7124
  case 'wordpress_search_abilities':
6999
7125
  return await client.searchAbilities(args);
7000
7126
  case 'wordpress_invoke_ability':
7001
- return await client.invokeInhaledAbility(args?.ability, args?.args || {});
7127
+ return await client.invokeInhaledAbility(requireAbilityName(args), args?.args || {});
7002
7128
  case 'wordpress_list_pages':
7003
7129
  return await client.listPages(args);
7004
7130
  case 'wordpress_read_page':
@@ -7212,7 +7338,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
7212
7338
  }
7213
7339
  case 'wordpress_diagnose_connection': {
7214
7340
  const siteDiag = await client.diagnoseConnection({ post_id: args.post_id, probe_timeout_ms: args.probe_timeout_ms });
7215
- const { describeEffectiveConfig } = await import('./config.js');
7341
+ const { describeEffectiveConfig, describeTransportMode } = await import('./config.js');
7216
7342
  // v6.17.2: append process-level runtime info so version drift,
7217
7343
  // orphan processes, and a stale RESPIRA_SITES filter become
7218
7344
  // visible from a single tool call rather than requiring shell
@@ -7220,14 +7346,34 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
7220
7346
  // processes flapping the tool catalog because npm exec ignored
7221
7347
  // the @latest pin and bound to a homebrew v6.14.2 install).
7222
7348
  const binaryPath = process.argv[1] || '<unknown>';
7349
+ const siblingScan = findSiblingServerProcesses();
7350
+ // Which INSTALL is answering, and whether a second one is running.
7351
+ //
7352
+ // client_config below says which configuration won. This says which
7353
+ // binary won. Desmond installed the .mcpb bundle while an older npx
7354
+ // entry stayed in his client config; both were valid Respira servers,
7355
+ // the client picked one, and nothing said which. See
7356
+ // describeTransportMode and describeDuplicateEntries in config.ts for
7357
+ // exactly what is and is not knowable from inside the server.
7358
+ const transport = describeTransportMode({
7359
+ binaryPath,
7360
+ siblings: siblingScan.siblings,
7361
+ processScanWorked: siblingScan.scanned,
7362
+ });
7223
7363
  return {
7224
7364
  ...siteDiag,
7365
+ // Promoted to the top level because it is the field support asks for
7366
+ // first: 'mcpb_bundle' | 'npx_cache' | 'global_npm_install' |
7367
+ // 'project_local_install' | 'source_checkout' | 'unknown'.
7368
+ transport_mode: transport.transport_mode,
7369
+ transport,
7225
7370
  runtime: {
7226
7371
  version: MCP_SERVER_VERSION,
7227
7372
  pid: process.pid,
7228
7373
  binary_path: binaryPath,
7229
- is_global_install: /\/opt\/homebrew\/|\/usr\/local\//.test(binaryPath),
7230
- sibling_pids: findSiblingProcesses(),
7374
+ launch_mode: transport.transport_mode,
7375
+ is_global_install: transport.is_global_install,
7376
+ sibling_pids: collapseLauncherProcesses(process.pid, process.ppid, siblingScan.siblings).map((s) => s.pid),
7231
7377
  uptime_seconds: Math.round(process.uptime()),
7232
7378
  node_version: process.version,
7233
7379
  },
@@ -8771,24 +8917,72 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
8771
8917
  }
8772
8918
  }
8773
8919
  /**
8774
- * v6.17.2: detect orphan wordpress-mcp-server processes from prior
8775
- * supervisor reconnect cycles. Pure function — does not log. Used by
8776
- * the boot warning and by respira_diagnose_connection so diagnose
8777
- * doesn't re-emit a stderr warning on every call.
8920
+ * Command lines that belong to a Respira MCP server.
8921
+ *
8922
+ * The first two alternatives are the npm binaries. The third is the desktop
8923
+ * bundle, which the old filter missed: the .mcpb wrapper launches
8924
+ * `node .../Claude Extensions/<id>/server/index.js` and imports the package
8925
+ * in-process, so the string 'wordpress-mcp-server' never appears on its
8926
+ * command line. That blind spot is why a bundle running alongside an npx
8927
+ * entry — Desmond's case — showed up as zero siblings.
8778
8928
  */
8779
- function findSiblingProcesses() {
8929
+ function isRespiraMcpProcess(command) {
8930
+ if (/wordpress-mcp-server|respira-wordpress-mcp/i.test(command))
8931
+ return true;
8932
+ // The bundle: a Respira-named extension directory running server/index.js.
8933
+ // Both halves are required so another vendor's desktop extension, which also
8934
+ // ends in server/index.js, is never counted as a second Respira server.
8935
+ return /respira/i.test(command) && /[/\\]server[/\\]index\.js/.test(command);
8936
+ }
8937
+ /**
8938
+ * v6.17.2: detect other Respira MCP server processes — orphans from prior
8939
+ * supervisor reconnect cycles, or a second install the client also started.
8940
+ * Pure function — does not log. Used by the boot warning and by
8941
+ * respira_diagnose_connection so diagnose doesn't re-emit a stderr warning
8942
+ * on every call.
8943
+ *
8944
+ * Filtering happens in JS rather than in awk so the caller can also see each
8945
+ * sibling's command line and classify how it was launched.
8946
+ */
8947
+ function findSiblingServerProcesses() {
8780
8948
  try {
8781
- // ps -eo lists every process; awk filters to ones that contain
8782
- // 'wordpress-mcp-server' in their command and aren't our own PID.
8783
- const psOutput = execSync(`ps -eo pid,command 2>/dev/null | awk -v me=${process.pid} '$0 ~ /wordpress-mcp-server/ && $0 !~ /awk/ && $1 != me { print $1 }'`, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
8784
- return psOutput
8785
- ? psOutput.split('\n').map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n))
8786
- : [];
8949
+ // ppid is what lets collapseLauncherProcesses tell one npx entry's
8950
+ // three-process launcher chain apart from three separate servers.
8951
+ const psOutput = execSync('ps -eo pid=,ppid=,command= 2>/dev/null', {
8952
+ encoding: 'utf8',
8953
+ timeout: 2000,
8954
+ maxBuffer: 8 * 1024 * 1024,
8955
+ stdio: ['ignore', 'pipe', 'ignore'],
8956
+ });
8957
+ const siblings = [];
8958
+ for (const line of psOutput.split('\n')) {
8959
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
8960
+ if (!match)
8961
+ continue;
8962
+ const pid = Number.parseInt(match[1], 10);
8963
+ const ppid = Number.parseInt(match[2], 10);
8964
+ const command = match[3];
8965
+ if (Number.isNaN(pid) || pid === process.pid)
8966
+ continue;
8967
+ if (!isRespiraMcpProcess(command))
8968
+ continue;
8969
+ siblings.push({ pid, ppid, command });
8970
+ }
8971
+ return { scanned: true, siblings };
8787
8972
  }
8788
8973
  catch {
8789
- return [];
8974
+ return { scanned: false, siblings: [] };
8790
8975
  }
8791
8976
  }
8977
+ /**
8978
+ * Leaf server processes only. The launcher chain above an npx run belongs to
8979
+ * the same logical server, so counting it would make the boot warning fire on
8980
+ * every healthy npx install.
8981
+ */
8982
+ function findSiblingProcesses() {
8983
+ const { siblings } = findSiblingServerProcesses();
8984
+ return collapseLauncherProcesses(process.pid, process.ppid, siblings).map((s) => s.pid);
8985
+ }
8792
8986
  /**
8793
8987
  * v6.17.2: emit a one-shot stderr warning at boot if any sibling
8794
8988
  * wordpress-mcp-server processes are running. We can't kill them