@respira/wordpress-mcp-server 7.2.10 → 7.2.12

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.d.ts CHANGED
@@ -15,6 +15,9 @@ 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 account site-list refresh (throttle network calls). */
19
+ private lastSiteRefreshAt;
20
+ private static readonly SITE_REFRESH_MIN_INTERVAL_MS;
18
21
  private static readonly MCP_SERVER_VERSION;
19
22
  /**
20
23
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -28,6 +31,26 @@ export declare class RespiraWordPressServer {
28
31
  private generateDualTools;
29
32
  constructor(siteConfigs: WordPressSiteConfig[], enabledTools?: string[]);
30
33
  private getSiteSummary;
34
+ /**
35
+ * Re-pull the account's CURRENT site list from respira.press and merge in any
36
+ * sites that aren't in the running server yet.
37
+ *
38
+ * Why this exists: the server boots from whatever site list was in its config
39
+ * at startup. When the user adds a site on the dashboard afterwards, a
40
+ * long-lived server (Cowork especially) never learns about it and silently
41
+ * serves the stale list — Emil had 4 sites but Cowork only ever saw 3
42
+ * (2026-06-30). Rather than make the user re-paste config and restart, this
43
+ * lets list_sites self-heal: authenticate with a `respira_site_` token the
44
+ * server already holds, fetch the account's canonical config, and register
45
+ * any new sites in-memory (and persist to ~/.respira/config.json when that is
46
+ * the active source).
47
+ *
48
+ * Best-effort and throttled: a network/auth failure is swallowed and the
49
+ * existing list is returned unchanged. Servers with no site token (OAuth-only)
50
+ * or pinned to RESPIRA_CONFIG_B64 still get the in-session merge; only the
51
+ * on-disk persistence is skipped when the file is not the active source.
52
+ */
53
+ private refreshSitesFromAccount;
31
54
  /**
32
55
  * N6 fix (v6.19.0): the response envelope `site` field must reflect the
33
56
  * client that ACTUALLY serviced the call, not the global default. Pre-fix,
@@ -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;AAqTzE,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;IAsVvE,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;IAgDxB;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAiJzB;;;;;;;;;OASG;YACW,kBAAkB;IAyIhC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,6BAA6B;IA0BrC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,yBAAyB;IA+BjC,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,aAAa;YA2QP,kBAAkB;YA6BlB,yBAAyB;IASvC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;YAyBd,QAAQ;IAuwFtB;;;;;;OAMG;IACH,yEAAyE;IACzE,OAAO,CAAC,mBAAmB,CAAoD;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAElD;;;;;;;;;OASG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,uBAAuB,CAQ7C;IAEF;;;;;;;OAOG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAQ3C;IAEF,4EAA4E;IAC5E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,+BAA+B,CAAY;IAEnE,uEAAuE;IACvE,OAAO,CAAC,MAAM,CAAC,SAAS;YAWV,oBAAoB;YAqFpB,2BAA2B;IAazC;;;;OAIG;YACW,cAAc;IAY5B,OAAO,CAAC,mBAAmB;IAwT3B;;;;;;;;;;;;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;IA22B9B;;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;AAsTzE,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,+EAA+E;IAC/E,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,4BAA4B,CAAU;IAE9D,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;IAsVvE,OAAO,CAAC,cAAc;IAItB;;;;;;;;;;;;;;;;;;OAkBG;YACW,uBAAuB;IA0GrC;;;;;;;;;OASG;IACH,OAAO,CAAC,oBAAoB;IAkB5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IA4BrB,gEAAgE;IAChE,OAAO,CAAC,aAAa;IAUrB;;;;;;OAMG;YACW,UAAU;IAgDxB;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAiJzB;;;;;;;;;OASG;YACW,kBAAkB;IAyIhC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,6BAA6B;IA0BrC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,yBAAyB;IA+BjC,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,aAAa;YA2QP,kBAAkB;YA6BlB,yBAAyB;IASvC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;YAyBd,QAAQ;IA+wFtB;;;;;;OAMG;IACH,yEAAyE;IACzE,OAAO,CAAC,mBAAmB,CAAoD;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAElD;;;;;;;;;OASG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,uBAAuB,CAQ7C;IAEF;;;;;;;OAOG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAQ3C;IAEF,4EAA4E;IAC5E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,+BAA+B,CAAY;IAEnE,uEAAuE;IACvE,OAAO,CAAC,MAAM,CAAC,SAAS;YAWV,oBAAoB;YAqFpB,2BAA2B;IAazC;;;;OAIG;YACW,cAAc;IAY5B,OAAO,CAAC,mBAAmB;IAwT3B;;;;;;;;;;;;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;IAw3B9B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAwUzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA6UxB,GAAG;CAyCV"}
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ import { getBricksTools, dispatchBricksTool } from './bricks-tools.js';
17
17
  import { getElementorTools, dispatchElementorTool } from './elementor-tools.js';
18
18
  import { getAcfTools, ACF_TOOL_NAMES } from './acf-tools.js';
19
19
  import { getUsageEmitter, deriveToolKind } from './usage-emitter.js';
20
+ import { loadConfig, saveConfig } from './config.js';
20
21
  /**
21
22
  * Per-tool argument aliases. AI clients (especially Cowork / Claude desktop)
22
23
  * frequently pass a natural-but-wrong key for a value: `pageId` / `page_id`
@@ -315,6 +316,9 @@ export class RespiraWordPressServer {
315
316
  allowedSites = null;
316
317
  /** Whether the plugin version warning has already been shown this session. */
317
318
  versionWarningShown = false;
319
+ /** Epoch ms of the last account site-list refresh (throttle network calls). */
320
+ lastSiteRefreshAt = 0;
321
+ static SITE_REFRESH_MIN_INTERVAL_MS = 60_000;
318
322
  static MCP_SERVER_VERSION = MCP_SERVER_VERSION;
319
323
  /**
320
324
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -693,6 +697,124 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
693
697
  getSiteSummary(site) {
694
698
  return site.getSiteSummary(site.getSiteId() === this.defaultSiteId);
695
699
  }
700
+ /**
701
+ * Re-pull the account's CURRENT site list from respira.press and merge in any
702
+ * sites that aren't in the running server yet.
703
+ *
704
+ * Why this exists: the server boots from whatever site list was in its config
705
+ * at startup. When the user adds a site on the dashboard afterwards, a
706
+ * long-lived server (Cowork especially) never learns about it and silently
707
+ * serves the stale list — Emil had 4 sites but Cowork only ever saw 3
708
+ * (2026-06-30). Rather than make the user re-paste config and restart, this
709
+ * lets list_sites self-heal: authenticate with a `respira_site_` token the
710
+ * server already holds, fetch the account's canonical config, and register
711
+ * any new sites in-memory (and persist to ~/.respira/config.json when that is
712
+ * the active source).
713
+ *
714
+ * Best-effort and throttled: a network/auth failure is swallowed and the
715
+ * existing list is returned unchanged. Servers with no site token (OAuth-only)
716
+ * or pinned to RESPIRA_CONFIG_B64 still get the in-session merge; only the
717
+ * on-disk persistence is skipped when the file is not the active source.
718
+ */
719
+ async refreshSitesFromAccount(force = false) {
720
+ const result = { added: [] };
721
+ const now = Date.now();
722
+ if (!force && now - this.lastSiteRefreshAt < RespiraWordPressServer.SITE_REFRESH_MIN_INTERVAL_MS) {
723
+ return result;
724
+ }
725
+ // Pick a live dashboard site token to authenticate the refresh.
726
+ let authToken = '';
727
+ for (const client of this.sites.values()) {
728
+ const key = client.getApiKey();
729
+ if (typeof key === 'string' && key.startsWith('respira_site_')) {
730
+ authToken = key;
731
+ break;
732
+ }
733
+ }
734
+ if (!authToken) {
735
+ // OAuth-only or token-less server: nothing to authenticate with.
736
+ return result;
737
+ }
738
+ // Stamp before the network call so a hang/failure still respects the
739
+ // throttle and we don't retry on every single list_sites.
740
+ this.lastSiteRefreshAt = now;
741
+ const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
742
+ const url = `${apiBase.replace(/\/+$/, '')}/api/mcp/config/refresh`;
743
+ let payload = null;
744
+ try {
745
+ const response = await fetch(url, {
746
+ method: 'POST',
747
+ headers: { 'Content-Type': 'application/json' },
748
+ body: JSON.stringify({ token: authToken }),
749
+ signal: AbortSignal.timeout(8000),
750
+ });
751
+ if (!response.ok) {
752
+ return result;
753
+ }
754
+ payload = await response.json().catch(() => null);
755
+ }
756
+ catch {
757
+ return result;
758
+ }
759
+ const accountSites = payload?.config?.sites;
760
+ if (!Array.isArray(accountSites)) {
761
+ return result;
762
+ }
763
+ for (const site of accountSites) {
764
+ if (!site?.id || !site?.url || !site?.apiKey)
765
+ continue;
766
+ if (this.sites.has(site.id))
767
+ continue;
768
+ const cfg = {
769
+ id: site.id,
770
+ name: site.name || site.id,
771
+ url: site.url,
772
+ apiKey: site.apiKey,
773
+ default: false,
774
+ };
775
+ const client = new WordPressClient(cfg);
776
+ this.sites.set(cfg.id, client);
777
+ if (this.currentSite === null) {
778
+ this.currentSite = client;
779
+ this.defaultSiteId = cfg.id;
780
+ }
781
+ try {
782
+ getUsageEmitter().registerSiteToken(cfg.url, cfg.apiKey);
783
+ }
784
+ catch {
785
+ // Best-effort; usage telemetry never blocks.
786
+ }
787
+ result.added.push(cfg.name);
788
+ }
789
+ // Persist new sites to disk so a restart keeps them — only when the file is
790
+ // the active config source. A RESPIRA_CONFIG_B64- or RESPIRA_CONFIG_FILE-
791
+ // pinned server still has the correct in-memory list this session; writing
792
+ // the default file would not be read on its next boot and could orphan a
793
+ // stray file, so skip it there.
794
+ const fileIsActiveSource = !process.env.RESPIRA_CONFIG_B64?.trim() && !process.env.RESPIRA_CONFIG_FILE?.trim();
795
+ if (result.added.length > 0 && fileIsActiveSource) {
796
+ try {
797
+ const onDisk = loadConfig();
798
+ const known = new Set(onDisk.sites.map((s) => s.id));
799
+ for (const site of accountSites) {
800
+ if (site?.id && site?.url && site?.apiKey && !known.has(site.id)) {
801
+ onDisk.sites.push({
802
+ id: site.id,
803
+ name: site.name || site.id,
804
+ url: site.url,
805
+ apiKey: site.apiKey,
806
+ default: false,
807
+ });
808
+ }
809
+ }
810
+ saveConfig(onDisk);
811
+ }
812
+ catch {
813
+ // Best-effort; the in-memory merge above is what unblocks this session.
814
+ }
815
+ }
816
+ return result;
817
+ }
696
818
  /**
697
819
  * N6 fix (v6.19.0): the response envelope `site` field must reflect the
698
820
  * client that ACTUALLY serviced the call, not the global default. Pre-fix,
@@ -2141,7 +2263,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2141
2263
  },
2142
2264
  {
2143
2265
  name: 'wordpress_inject_builder_content',
2144
- description: 'REPLACE (or append to) the entire page builder layout. WARNING: By default this REPLACES all existing content (use mode:"append" to add content without destroying existing elements). For editing a single module, use wordpress_update_module instead. Use exactly: gutenberg, divi, elementor, bricks, beaver, oxygen, breakdance, brizy, thrive, visual-composer, wpbakery. For Divi, divi_version is required ("4" or "5"). Replacing a page that already has content with mode="replace" requires confirm_replace=true, else returns 409 respira_replace_confirmation_required. Alternatives: mode="append" to add to existing content, or edit_target="live" to overwrite the live page directly. The gate prevents silent data loss.\n\nCanonical Divi 5 nested shape: `content: [{ type: "section", children: [{ type: "row", children: [{ type: "column", children: [{ type: "heading", settings: {...} }] }] }] }]` (rows/cols/columns/modules/elements/innerBlocks also accepted as `children` aliases). Each Divi module needs its render-critical attr in `settings` (e.g. heading→title, text→content, button→button_text); the plugin names any missing one via `respira_divi_required_attrs_missing`.',
2266
+ description: 'REPLACE (or append to) the entire page builder layout. WARNING: By default this REPLACES all existing content (use mode:"append" to add content without destroying existing elements). For editing a single module, use wordpress_update_module instead. Use exactly: gutenberg, divi, elementor, bricks, beaver, oxygen, breakdance, brizy, thrive, visual-composer, wpbakery. For Divi, divi_version is required ("4" or "5"). Replacing a page that already has content with mode="replace" requires confirm_replace=true, else returns 409 respira_replace_confirmation_required. Alternatives: mode="append" to add to existing content, or edit_target="live" to overwrite the live page directly. The gate prevents silent data loss.\n\nCanonical Divi 5 nested shape: `content: [{ type: "section", children: [{ type: "row", children: [{ type: "column", children: [{ type: "heading", settings: {...} }] }] }] }]` (rows/cols/columns/modules/elements/innerBlocks also accepted as `children` aliases). Each Divi module needs its render-critical attr in `settings` (e.g. heading→title, text→content, button→button_text); the plugin names any missing one via `respira_divi_required_attrs_missing`.\n\nLARGE SECTIONS (payload cap): if a fully styled section is too big to send in one call (some AI clients cap tool-call payload size), build it in pieces with `target_path`. First create the section frame (the section + rows + empty columns) in one small call, then append each module into its column with separate small calls: `target_path: "sections[0].rows[0].columns[1]"`, `position: -1` (append to end). Get exact bracket paths from `extract_builder_content`. Do NOT split one section across multiple top-level `mode:"append"` calls — append adds each call as a NEW section, so you would get several sections instead of one. `target_path` insert is supported on Divi 5 today; on other builders it returns `respira_target_path_unsupported` (use the lean-skeleton + `update_module` approach there).',
2145
2267
  inputSchema: {
2146
2268
  type: 'object',
2147
2269
  properties: {
@@ -2176,6 +2298,14 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2176
2298
  description: 'Divi generation hint ("4" for shortcode format, "5" for block format). Required for all Divi inject calls.',
2177
2299
  enum: ['4', '5'],
2178
2300
  },
2301
+ target_path: {
2302
+ type: 'string',
2303
+ description: 'Insert the content as children of the container at this bracket path (from extract_builder_content), e.g. "sections[0].rows[1].columns[0]", instead of at page top level. Use this to build a large section in small calls when the whole tree exceeds the AI client payload cap: create the section frame first, then append each module by target_path. Omit for normal whole-page inject. Supported on Divi 5 today; other builders return respira_target_path_unsupported.',
2304
+ },
2305
+ position: {
2306
+ type: 'number',
2307
+ description: 'Where to insert within the target_path container: -1 (default) appends to the end, 0 inserts first, N inserts at index N. Only used when target_path is set.',
2308
+ },
2179
2309
  },
2180
2310
  required: ['builder', 'page_id', 'content'],
2181
2311
  },
@@ -5041,6 +5171,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5041
5171
  ? await client.getSiteContext()
5042
5172
  : await client.getCompactSiteContext();
5043
5173
  case 'wordpress_list_sites': {
5174
+ // Self-heal a stale list: re-pull the account's current sites so a site
5175
+ // added on the dashboard after this server booted shows up without a
5176
+ // re-paste or restart. Best-effort and throttled; never blocks the list.
5177
+ const refresh = await this.refreshSitesFromAccount().catch(() => ({ added: [] }));
5044
5178
  const allSites = Array.from(this.sites.values());
5045
5179
  const visibleSites = allSites.filter((site) => this.isSiteAllowed(site));
5046
5180
  // v6.17.2: surface hidden sites so the AI can explain
@@ -5053,6 +5187,13 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5053
5187
  return {
5054
5188
  sites: visibleSites.map((site) => this.getSiteSummary(site)),
5055
5189
  active_site: this.getActiveSiteSummary(),
5190
+ ...(refresh.added.length > 0
5191
+ ? {
5192
+ refreshed_note: `Picked up ${refresh.added.length} newly added site${refresh.added.length === 1 ? '' : 's'} from your respira.press account: ${refresh.added.join(', ')}.${process.env.RESPIRA_CONFIG_B64?.trim()
5193
+ ? ' This server is pinned to the RESPIRA_CONFIG_B64 env var, so update that env to keep these sites after a restart.'
5194
+ : ''}`,
5195
+ }
5196
+ : {}),
5056
5197
  ...(hiddenSites.length > 0
5057
5198
  ? {
5058
5199
  hidden_by_filter: hiddenSites,
@@ -5194,7 +5335,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5194
5335
  return await client.getBuilderInlineSchemas(args.builder, args.types);
5195
5336
  case 'wordpress_inject_builder_content':
5196
5337
  return hoistDroppedStylingWarnings({
5197
- ...(await client.injectBuilderContent(args.builder, args.page_id, args.content, args.divi_version, args.edit_target, args.mode, args.confirm_replace)),
5338
+ ...(await client.injectBuilderContent(args.builder, args.page_id, args.content, args.divi_version, args.edit_target, args.mode, args.confirm_replace, args.target_path, args.position)),
5198
5339
  respira_approvals_url: client.getApprovalsUrl(),
5199
5340
  });
5200
5341
  case 'wordpress_make_responsive':