@respira/wordpress-mcp-server 7.4.0 → 7.5.0

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/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,
@@ -5346,6 +5490,94 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5346
5490
  },
5347
5491
  readOnlyHint: true,
5348
5492
  },
5493
+ // --- Product configurator, STAGGS (addon v3.2) ---
5494
+ {
5495
+ name: 'woocommerce_configurator_status',
5496
+ description: 'Which product configurator is active (STAGGS) plus how many products and attribute groups use it. Start here before any configurator work.',
5497
+ inputSchema: { type: 'object', properties: {} },
5498
+ readOnlyHint: true,
5499
+ },
5500
+ {
5501
+ name: 'woocommerce_get_product_configurator',
5502
+ description: "Read a product's STAGGS configurator state: the enabled flag and all sgg_ meta (steps, attributes, themes).",
5503
+ inputSchema: {
5504
+ type: 'object',
5505
+ properties: { id: { type: 'number', description: 'Product ID' } },
5506
+ required: ['id'],
5507
+ },
5508
+ readOnlyHint: true,
5509
+ },
5510
+ {
5511
+ name: 'woocommerce_set_product_configurator',
5512
+ description: 'Enable or disable the STAGGS configurator on a product. Supports dry_run. Snapshots the product before and after.',
5513
+ inputSchema: {
5514
+ type: 'object',
5515
+ properties: {
5516
+ id: { type: 'number', description: 'Product ID' },
5517
+ enabled: { type: 'boolean' },
5518
+ dry_run: { type: 'boolean', description: 'Preview without writing (default false)' },
5519
+ },
5520
+ required: ['id', 'enabled'],
5521
+ },
5522
+ },
5523
+ {
5524
+ name: 'woocommerce_list_configurator_attributes',
5525
+ description: 'List STAGGS attribute groups (the reusable option sets a configurator is built from): id, title, type.',
5526
+ inputSchema: { type: 'object', properties: {} },
5527
+ readOnlyHint: true,
5528
+ },
5529
+ {
5530
+ name: 'woocommerce_get_configurator_attribute_items',
5531
+ description: "Read a STAGGS attribute's option items. The workflow is read, edit the returned array, then write it back with set_configurator_attribute_items, so STAGGS' internal sub-field names never have to be guessed.",
5532
+ inputSchema: {
5533
+ type: 'object',
5534
+ properties: { id: { type: 'number', description: 'STAGGS attribute (sgg_attribute) post ID' } },
5535
+ required: ['id'],
5536
+ },
5537
+ readOnlyHint: true,
5538
+ },
5539
+ {
5540
+ name: 'woocommerce_set_configurator_attribute_items',
5541
+ description: "Replace a STAGGS attribute's option items, written through the STAGGS/Carbon Fields helpers so storage stays consistent. Supports dry_run. Snapshots the attribute.",
5542
+ inputSchema: {
5543
+ type: 'object',
5544
+ properties: {
5545
+ id: { type: 'number', description: 'STAGGS attribute (sgg_attribute) post ID' },
5546
+ items: { type: 'array', items: { type: 'object' }, description: 'The full items array (read first, edit, write back)' },
5547
+ dry_run: { type: 'boolean', description: 'Preview without writing (default false)' },
5548
+ },
5549
+ required: ['id', 'items'],
5550
+ },
5551
+ },
5552
+ {
5553
+ name: 'woocommerce_get_plugin_state',
5554
+ description: 'Read allow-listed options and post meta for a known third-party plugin (currently: staggs). Keys outside the allow-list are ignored on read.',
5555
+ inputSchema: {
5556
+ type: 'object',
5557
+ properties: {
5558
+ plugin: { type: 'string', description: 'Plugin slug with a registered allow-list (staggs)' },
5559
+ option_keys: { type: 'array', items: { type: 'string' }, description: 'Option names to read (must match the allow-list prefixes)' },
5560
+ post_id: { type: 'number', description: 'Optional post whose allow-listed meta to read' },
5561
+ },
5562
+ required: ['plugin'],
5563
+ },
5564
+ readOnlyHint: true,
5565
+ },
5566
+ {
5567
+ name: 'woocommerce_set_plugin_state',
5568
+ description: 'Write allow-listed options and post meta for a known third-party plugin (currently: staggs). Any key outside the allow-list refuses the whole request. Supports dry_run. Snapshots the post.',
5569
+ inputSchema: {
5570
+ type: 'object',
5571
+ properties: {
5572
+ plugin: { type: 'string', description: 'Plugin slug with a registered allow-list (staggs)' },
5573
+ options: { type: 'object', description: 'Option name to value map' },
5574
+ meta: { type: 'object', description: 'Meta key to value map (requires post_id)' },
5575
+ post_id: { type: 'number' },
5576
+ dry_run: { type: 'boolean', description: 'Preview without writing (default false)' },
5577
+ },
5578
+ required: ['plugin'],
5579
+ },
5580
+ },
5349
5581
  // --- Readiness autofix (addon v3.1) ---
5350
5582
  {
5351
5583
  name: 'woocommerce_readiness_fixlist',
@@ -5705,6 +5937,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5705
5937
  ? await client.getSiteContext()
5706
5938
  : await client.getCompactSiteContext();
5707
5939
  case 'wordpress_list_sites': {
5940
+ // Self-heal first so a site added on the dashboard after this server
5941
+ // started shows up here without a reinstall or re-paste (throttled,
5942
+ // best-effort, never throws).
5943
+ await this.maybeSelfHealSiteList();
5708
5944
  const allSites = Array.from(this.sites.values());
5709
5945
  const visibleSites = allSites.filter((site) => this.isSiteAllowed(site));
5710
5946
  // v6.17.2: surface hidden sites so the AI can explain
@@ -5883,9 +6119,16 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5883
6119
  respira_approvals_url: client.getApprovalsUrl(),
5884
6120
  };
5885
6121
  case 'wordpress_switch_site': {
5886
- const newSite = this.sites.get(args.site_id);
6122
+ let newSite = this.sites.get(args.site_id);
6123
+ if (!newSite) {
6124
+ // The target may be a site added on the dashboard after this server
6125
+ // started. Self-heal once, then retry before giving up.
6126
+ await this.maybeSelfHealSiteList();
6127
+ newSite = this.sites.get(args.site_id);
6128
+ }
5887
6129
  if (!newSite) {
5888
- throw new Error(`Site with ID "${args.site_id}" not found in configuration`);
6130
+ const available = Array.from(this.sites.keys()).join(', ') || '(none configured)';
6131
+ throw new Error(`Site with ID "${args.site_id}" not found in configuration. Available: ${available}`);
5889
6132
  }
5890
6133
  if (!this.isSiteAllowed(newSite)) {
5891
6134
  throw new Error(`Site "${args.site_id}" is not in this MCP configuration group.`);
@@ -6350,6 +6593,30 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6350
6593
  return await client.woocommerceCreateCartLink(args);
6351
6594
  case 'woocommerce_agent_orders_report':
6352
6595
  return await client.woocommerceAgentOrdersReport(args);
6596
+ case 'woocommerce_configurator_status':
6597
+ return await client.woocommerceConfiguratorStatus();
6598
+ case 'woocommerce_get_product_configurator':
6599
+ return await client.woocommerceGetProductConfigurator(args.id);
6600
+ case 'woocommerce_set_product_configurator': {
6601
+ const { id, ...payload } = args;
6602
+ return await client.woocommerceSetProductConfigurator(id, payload);
6603
+ }
6604
+ case 'woocommerce_list_configurator_attributes':
6605
+ return await client.woocommerceListConfiguratorAttributes();
6606
+ case 'woocommerce_get_configurator_attribute_items':
6607
+ return await client.woocommerceGetConfiguratorAttributeItems(args.id);
6608
+ case 'woocommerce_set_configurator_attribute_items': {
6609
+ const { id, ...payload } = args;
6610
+ return await client.woocommerceSetConfiguratorAttributeItems(id, payload);
6611
+ }
6612
+ case 'woocommerce_get_plugin_state': {
6613
+ const { plugin, ...params } = args;
6614
+ return await client.woocommerceGetPluginState(plugin, params);
6615
+ }
6616
+ case 'woocommerce_set_plugin_state': {
6617
+ const { plugin, ...payload } = args;
6618
+ return await client.woocommerceSetPluginState(plugin, payload);
6619
+ }
6353
6620
  case 'woocommerce_readiness_fixlist':
6354
6621
  return await client.woocommerceReadinessFixlist(args);
6355
6622
  case 'woocommerce_set_image_alt': {