@respira/wordpress-mcp-server 8.3.3 → 8.3.5

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
@@ -4,6 +4,38 @@
4
4
  * Provides tools for AI coding assistants to interact with WordPress sites
5
5
  */
6
6
  import type { WordPressSiteConfig } from './types/index.js';
7
+ export type RespiraMcpState = {
8
+ /** Legacy / cross-version compatibility. Prefer active_site_by_config. */
9
+ last_active_site_id?: string;
10
+ /** configOriginKey() of the connection that wrote last_active_site_id. */
11
+ last_active_config?: string;
12
+ /** configOriginKey() -> site id. The authoritative store since 5004a473. */
13
+ active_site_by_config?: Record<string, string>;
14
+ schema_version?: number;
15
+ };
16
+ /**
17
+ * Which site should this connection restore, given the state file contents?
18
+ *
19
+ * Pure so it can be tested without touching the real ~/.respira/state.json.
20
+ *
21
+ * 1. This connection's own namespaced entry always wins.
22
+ * 2. Otherwise fall back to the legacy top-level key ONLY when no other
23
+ * connection has claimed it. A file written before this change has no
24
+ * `last_active_config`, so a single-connection customer upgrading keeps
25
+ * the site they had picked. A file written by another connection is
26
+ * ignored, which is the whole point of the fix.
27
+ * 3. Returning an id is not the same as using it: the caller still requires
28
+ * that the id exists in ITS OWN site map and passes the RESPIRA_SITES
29
+ * filter before restoring, so a stale or foreign id degrades to the
30
+ * configured default rather than routing anywhere unexpected.
31
+ */
32
+ export declare function pickActiveSiteIdForConfig(state: RespiraMcpState, namespace: string): string | null;
33
+ /**
34
+ * The write half of the namespacing. Returns the patch to persist: the
35
+ * connection's own entry, plus the legacy pair kept in sync so older server
36
+ * versions and the migration path above both stay correct.
37
+ */
38
+ export declare function applyActiveSiteIdForConfig(state: RespiraMcpState, namespace: string, siteId: string): RespiraMcpState;
7
39
  /**
8
40
  * v7.2.1: hoist plugin-side dropped-styling warnings to the TOP of a build
9
41
  * write result so the agent sees them before it inspects anything else.
@@ -51,6 +83,12 @@ export declare class RespiraWordPressServer {
51
83
  private lastSiteRefreshAt;
52
84
  /** Dedupe concurrent self-heals so parallel list_sites calls share one fetch. */
53
85
  private siteRefreshInFlight;
86
+ /**
87
+ * 5004a473: key under which this connection's active site is persisted in
88
+ * the machine-global ~/.respira/state.json. Derived from the config origin,
89
+ * so a switch_site in another connection can never re-point this one.
90
+ */
91
+ private readonly stateNamespace;
54
92
  private static readonly MCP_SERVER_VERSION;
55
93
  /**
56
94
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -177,8 +215,11 @@ export declare class RespiraWordPressServer {
177
215
  * after the user notices the wrong page changed. C.J. 2026-05-29 reported
178
216
  * exactly this ("the token for the new site is resolving to the first one").
179
217
  *
180
- * Read tools and explicit-site_id calls get no notice only the silent
181
- * default-routing case on a multi-site account is worth flagging.
218
+ * Explicit-site_id calls get no notice. Writes always get one. Reads get one
219
+ * only for the small DEFAULT_SITE_NOTICE_READ_TOOLS allowlist the calls that
220
+ * report site identity or list a site's content, where answering from the
221
+ * wrong site is invisible (ticket 5004a473). Every other read stays silent so
222
+ * the notice keeps its signal.
182
223
  */
183
224
  private buildDefaultSiteRoutingNotice;
184
225
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAkBH,OAAO,KAAK,EAAE,mBAAmB,EAAe,MAAM,kBAAkB,CAAC;AAsHzE;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAoBhF;AAiID,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;IA6VvE,OAAO,CAAC,cAAc;IAItB;;;;;;;;;OASG;IACH,OAAO,CAAC,oBAAoB;IAkB5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IAiCrB,gEAAgE;IAChE,OAAO,CAAC,aAAa;IAUrB;;;;;;OAMG;YACW,UAAU;IA2CxB;;;;;;;;;;;;;;OAcG;YACW,WAAW;IAiJzB;;;;;;;;;OASG;YACW,kBAAkB;IA4OhC;;;;;;;;OAQG;IACH,OAAO,CAAC,qBAAqB;IAgE7B;;;;;;;;;;;;;;;;;;;;;;;;;;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;IAwjHtB;;;;;;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;IAw9C3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAe3C;IAEF;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;YAmCpB,cAAc;IAuH5B,oEAAoE;IACpE,OAAO,CAAC,iBAAiB;IAoBzB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,eAAe;YAuCT,gBAAgB;IAy6C9B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA0ZzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA6UxB,GAAG;CA+CV"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -18,7 +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, describeConfigOrigin } from './config.js';
21
+ import { collapseLauncherProcesses, validateApiKeyShape, describeConfigOrigin, configOriginKey } from './config.js';
22
22
  // Process-local secret keeps target hashes useful for same-session retry
23
23
  // detection without making low-entropy WordPress ids reversible centrally.
24
24
  const TELEMETRY_HASH_SECRET = randomUUID();
@@ -39,12 +39,70 @@ const TELEMETRY_HASH_SECRET = randomUUID();
39
39
  * single biggest day-to-day friction across sessions on 2026-05-23.
40
40
  *
41
41
  * State lives in `~/.respira/state.json`. Single small object so we can
42
- * extend it without a schema migration; only `last_active_site_id` is
43
- * read today. Best-effort: any IO failure logs to stderr and falls back
44
- * to the configured default site (the pre-v6.18.7 behaviour).
42
+ * extend it without a schema migration. Best-effort: any IO failure logs
43
+ * to stderr and falls back to the configured default site (the
44
+ * pre-v6.18.7 behaviour).
45
+ *
46
+ * Ticket 5004a473: that file is machine-global, and `last_active_site_id`
47
+ * was a single top-level key. So a switch_site made in one Respira MCP
48
+ * connection re-pointed a DIFFERENT connection on the same machine at its
49
+ * next start, silently, across accounts and clients. The active site is now
50
+ * stored per connection in `active_site_by_config`, keyed by
51
+ * configOriginKey(). `last_active_site_id` is still written, both so an
52
+ * older server version on the same machine keeps working and so the
53
+ * migration below can tell a pre-upgrade file (no owner recorded) from one
54
+ * written by another connection (owner recorded, and not us).
45
55
  */
46
56
  const STATE_DIR_PATH = join(homedir(), '.respira');
47
57
  const STATE_FILE_PATH = join(STATE_DIR_PATH, 'state.json');
58
+ /**
59
+ * Which site should this connection restore, given the state file contents?
60
+ *
61
+ * Pure so it can be tested without touching the real ~/.respira/state.json.
62
+ *
63
+ * 1. This connection's own namespaced entry always wins.
64
+ * 2. Otherwise fall back to the legacy top-level key ONLY when no other
65
+ * connection has claimed it. A file written before this change has no
66
+ * `last_active_config`, so a single-connection customer upgrading keeps
67
+ * the site they had picked. A file written by another connection is
68
+ * ignored, which is the whole point of the fix.
69
+ * 3. Returning an id is not the same as using it: the caller still requires
70
+ * that the id exists in ITS OWN site map and passes the RESPIRA_SITES
71
+ * filter before restoring, so a stale or foreign id degrades to the
72
+ * configured default rather than routing anywhere unexpected.
73
+ */
74
+ export function pickActiveSiteIdForConfig(state, namespace) {
75
+ const scoped = state?.active_site_by_config;
76
+ if (scoped && typeof scoped === 'object' && !Array.isArray(scoped)) {
77
+ const owned = scoped[namespace];
78
+ if (typeof owned === 'string' && owned.length > 0) {
79
+ return owned;
80
+ }
81
+ }
82
+ const legacy = state?.last_active_site_id;
83
+ if (typeof legacy === 'string' && legacy.length > 0) {
84
+ const owner = state?.last_active_config;
85
+ if (typeof owner !== 'string' || owner.length === 0 || owner === namespace) {
86
+ return legacy;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /**
92
+ * The write half of the namespacing. Returns the patch to persist: the
93
+ * connection's own entry, plus the legacy pair kept in sync so older server
94
+ * versions and the migration path above both stay correct.
95
+ */
96
+ export function applyActiveSiteIdForConfig(state, namespace, siteId) {
97
+ const existing = state?.active_site_by_config;
98
+ const scoped = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {};
99
+ scoped[namespace] = siteId;
100
+ return {
101
+ active_site_by_config: scoped,
102
+ last_active_site_id: siteId,
103
+ last_active_config: namespace,
104
+ };
105
+ }
48
106
  function loadRespiraMcpState() {
49
107
  try {
50
108
  if (!existsSync(STATE_FILE_PATH)) {
@@ -254,6 +312,29 @@ const SITE_AGNOSTIC_TOOLS = new Set([
254
312
  'respira_switch_site',
255
313
  'respira_redeem_token',
256
314
  ]);
315
+ /**
316
+ * Read tools that report site IDENTITY or list a site's own content, and are
317
+ * therefore the calls where routing to the wrong site is invisible but total.
318
+ *
319
+ * Ticket 5004a473 (D.B., agency, 2026-07): a connection the customer had named
320
+ * "DB-Services staging" answered get_site_context and list_pages for a
321
+ * completely different site. The connection NAME is cosmetic — nothing in this
322
+ * server reads it — so a connection carrying the whole account silently answers
323
+ * as the account's default site, and the default is just whichever site was
324
+ * activated first. Both of his calls were reads, and the routing notice below
325
+ * only fired for writes, so he got zero signal and reasonably concluded Respira
326
+ * had mixed two customers' sites together.
327
+ *
328
+ * Deliberately short. This is not "every read tool": a notice on all 300+ reads
329
+ * would be noise that teaches agents to ignore it. These four are the ones whose
330
+ * whole purpose is to tell you what you are looking at.
331
+ */
332
+ const DEFAULT_SITE_NOTICE_READ_TOOLS = new Set([
333
+ 'wordpress_get_site_context',
334
+ 'wordpress_get_active_site',
335
+ 'wordpress_list_pages',
336
+ 'wordpress_list_posts',
337
+ ]);
257
338
  /**
258
339
  * v6.12.0: Schema fragment auto-injected into every non-agnostic tool's
259
340
  * inputSchema.properties. Lets callers override the active site on a single
@@ -301,6 +382,12 @@ export class RespiraWordPressServer {
301
382
  lastSiteRefreshAt = 0;
302
383
  /** Dedupe concurrent self-heals so parallel list_sites calls share one fetch. */
303
384
  siteRefreshInFlight = null;
385
+ /**
386
+ * 5004a473: key under which this connection's active site is persisted in
387
+ * the machine-global ~/.respira/state.json. Derived from the config origin,
388
+ * so a switch_site in another connection can never re-point this one.
389
+ */
390
+ stateNamespace = configOriginKey();
304
391
  static MCP_SERVER_VERSION = MCP_SERVER_VERSION;
305
392
  /**
306
393
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -671,13 +758,16 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
671
758
  }
672
759
  }
673
760
  });
674
- // B-19 (v6.18.7): restore the persisted last_active_site_id if present
675
- // and the site is still known + allowed. Falls back silently to the
676
- // default site picked above when the persisted id is stale (the
677
- // customer renamed sites between client versions, hit by A.D.).
678
- const persisted = loadRespiraMcpState();
679
- if (persisted.last_active_site_id) {
680
- const restored = this.sites.get(persisted.last_active_site_id);
761
+ // B-19 (v6.18.7): restore the persisted active site if present and the
762
+ // site is still known + allowed. Falls back silently to the default site
763
+ // picked above when the persisted id is stale (the customer renamed sites
764
+ // between client versions, hit by A.D.).
765
+ //
766
+ // 5004a473: the lookup is now scoped to THIS connection, so another
767
+ // connection's switch_site on the same machine cannot re-point this one.
768
+ const persistedSiteId = pickActiveSiteIdForConfig(loadRespiraMcpState(), this.stateNamespace);
769
+ if (persistedSiteId) {
770
+ const restored = this.sites.get(persistedSiteId);
681
771
  if (restored && this.isSiteAllowed(restored)) {
682
772
  this.currentSite = restored;
683
773
  }
@@ -1434,8 +1524,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1434
1524
  * after the user notices the wrong page changed. C.J. 2026-05-29 reported
1435
1525
  * exactly this ("the token for the new site is resolving to the first one").
1436
1526
  *
1437
- * Read tools and explicit-site_id calls get no notice only the silent
1438
- * default-routing case on a multi-site account is worth flagging.
1527
+ * Explicit-site_id calls get no notice. Writes always get one. Reads get one
1528
+ * only for the small DEFAULT_SITE_NOTICE_READ_TOOLS allowlist the calls that
1529
+ * report site identity or list a site's content, where answering from the
1530
+ * wrong site is invisible (ticket 5004a473). Every other read stays silent so
1531
+ * the notice keeps its signal.
1439
1532
  */
1440
1533
  buildDefaultSiteRoutingNotice(name, args) {
1441
1534
  if (this.sites.size <= 1 || !this.currentSite) {
@@ -1445,7 +1538,9 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1445
1538
  if (hasExplicitSiteId) {
1446
1539
  return null;
1447
1540
  }
1448
- if (deriveToolKind(this.normalizeToolName(name).canonical) !== 'write') {
1541
+ const canonical = this.normalizeToolName(name).canonical;
1542
+ const isWrite = deriveToolKind(canonical) === 'write';
1543
+ if (!isWrite && !DEFAULT_SITE_NOTICE_READ_TOOLS.has(canonical)) {
1449
1544
  return null;
1450
1545
  }
1451
1546
  const site = this.currentSite;
@@ -1456,9 +1551,24 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1456
1551
  catch {
1457
1552
  // fall back to the site id
1458
1553
  }
1459
- return (`Wrote to ${site.getSiteName()} (${host}) — your default site, because no site_id was given. ` +
1460
- `${this.sites.size} sites are connected. To target a different site, pass site_id on the tool call ` +
1461
- `or call respira_switch_site first.`);
1554
+ if (isWrite) {
1555
+ return (`Wrote to ${site.getSiteName()} (${host}) your default site, because no site_id was given. ` +
1556
+ `${this.sites.size} sites are connected. To target a different site, pass site_id on the tool call ` +
1557
+ `or call respira_switch_site first.`);
1558
+ }
1559
+ // Read wording is deliberately different: nothing was changed, but what you
1560
+ // are reading may not be the site you think you asked about. Says the site
1561
+ // that actually answered, and why it was picked.
1562
+ //
1563
+ // get_active_site is site-agnostic and has no site_id parameter, so don't
1564
+ // send an agent off to retry it with an argument the tool doesn't take.
1565
+ const howToTarget = SITE_AGNOSTIC_TOOLS.has(canonical)
1566
+ ? `If you meant a different site, call respira_switch_site first.`
1567
+ : `If you meant a different site, pass site_id on the tool call or call respira_switch_site first.`;
1568
+ return (`Answered from ${site.getSiteName()} (${host}), the active site for this connection, ` +
1569
+ `because this call passed no site_id. ${this.sites.size} sites are connected here, and the name ` +
1570
+ `of your MCP connection does not select one of them. ${howToTarget} ` +
1571
+ `respira_list_sites shows every site_id.`);
1462
1572
  }
1463
1573
  /**
1464
1574
  * Opt-in strict scoping for write tools. When RESPIRA_REQUIRE_SITE_ID is
@@ -3107,7 +3217,15 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3107
3217
  type: { type: 'string', enum: ['color', 'typography', 'spacing', 'breakpoints'] },
3108
3218
  id: { type: 'string' },
3109
3219
  name: { type: 'string' },
3110
- value: {},
3220
+ value: {
3221
+ // Typed on purpose. An empty schema is valid JSON Schema and a
3222
+ // strict MCP validator rejects it: ChatGPT refused the whole
3223
+ // catalogue over this, so one untyped property took every tool
3224
+ // down. The plugin's own ability schema was fixed in 8.6.6; this
3225
+ // is the same fix in the npm lane, which still shipped it.
3226
+ type: ['string', 'number', 'object'],
3227
+ description: 'Token value: a CSS string for color and spacing ("#0f7b5f", "1.5rem"), a number, or an object for typography (fontFamily, fontSize, fontWeight, lineHeight).',
3228
+ },
3111
3229
  expected_fingerprint: { type: 'string', description: 'Optional document revision returned by wordpress_list_design_tokens.' },
3112
3230
  approval_token: { type: 'string' },
3113
3231
  dry_run: { type: 'boolean' },
@@ -3125,7 +3243,15 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3125
3243
  type: { type: 'string', enum: ['color', 'typography', 'spacing', 'breakpoints'] },
3126
3244
  id: { type: 'string' },
3127
3245
  name: { type: 'string' },
3128
- value: {},
3246
+ value: {
3247
+ // Typed on purpose. An empty schema is valid JSON Schema and a
3248
+ // strict MCP validator rejects it: ChatGPT refused the whole
3249
+ // catalogue over this, so one untyped property took every tool
3250
+ // down. The plugin's own ability schema was fixed in 8.6.6; this
3251
+ // is the same fix in the npm lane, which still shipped it.
3252
+ type: ['string', 'number', 'object'],
3253
+ description: 'Token value: a CSS string for color and spacing ("#0f7b5f", "1.5rem"), a number, or an object for typography (fontFamily, fontSize, fontWeight, lineHeight).',
3254
+ },
3129
3255
  expected_fingerprint: { type: 'string' },
3130
3256
  approval_token: { type: 'string' },
3131
3257
  dry_run: { type: 'boolean' },
@@ -3205,6 +3331,27 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3205
3331
  },
3206
3332
  idempotentHint: true,
3207
3333
  },
3334
+ {
3335
+ name: 'wordpress_deactivate_design_direction',
3336
+ description: 'Stop resolving builds against the ACTIVE design direction. Clears the site pointer and nothing else: the document keeps every token, its guidance and its whole history, so reactivating later is a single call with nothing to restore. Until this existed the only ways out of active were activating a different direction or trashing this one, so the only offered path to "stop using this" was destructive. Returns 409 respira_no_active_direction when nothing is active. Pass `id` to assert WHICH direction you meant: it must be the active one, so a stale view cannot clear a pointer that has since moved.\n\nApproval flow: the first call may return `code: respira_approval_required` with `data.approval_request.approval_token`. Pass that token back via the `approval_token` param on the next call to confirm. The pointer is snapshotted first, so the flip is one wordpress_restore_snapshot away.',
3337
+ inputSchema: {
3338
+ type: 'object',
3339
+ properties: {
3340
+ id: {
3341
+ type: 'number',
3342
+ description: 'Optional. When given it must be the currently active direction; mismatch returns 409 respira_direction_not_active.',
3343
+ },
3344
+ approval_token: {
3345
+ type: 'string',
3346
+ description: 'One-time token from a prior respira_approval_required response. Omit on the first call.',
3347
+ },
3348
+ dry_run: { type: 'boolean' },
3349
+ edit_target: { type: 'string', enum: ['approval', 'live'] },
3350
+ },
3351
+ required: [],
3352
+ },
3353
+ idempotentHint: false,
3354
+ },
3208
3355
  {
3209
3356
  name: 'wordpress_delete_design_direction',
3210
3357
  description: 'Trash a saved design direction through approval. The document stays recoverable in the WordPress trash; deleting the ACTIVE direction also clears the active pointer (snapshotted first).\n\nApproval flow: destructive — the first call may return `code: respira_approval_required` with `data.approval_request.approval_token`. Pass that token back via the `approval_token` param on the next call to complete the delete.',
@@ -5028,6 +5175,26 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5028
5175
  },
5029
5176
  destructiveHint: true,
5030
5177
  },
5178
+ {
5179
+ name: 'wordpress_purge_cache',
5180
+ description: 'Purge page and object caches, and report exactly which caching layers were touched.\n\nEvery Respira write already purges what it changed, so you rarely need this. Reach for it when something changed outside Respira (a plugin update, a theme edit, a CDN holding a copy) or when a page still looks stale after an edit.\n\nPass post_id to purge one page or post. Omit it for a site-wide purge. The response lists every caching plugin Respira knows, split into `purged` (with the exact function or hook called) and `not_found` (with what was looked for), so a quiet result is never ambiguous.',
5181
+ inputSchema: {
5182
+ type: 'object',
5183
+ properties: {
5184
+ post_id: {
5185
+ type: 'number',
5186
+ description: 'Purge just this page or post. Omit for a site-wide purge.',
5187
+ },
5188
+ scope: {
5189
+ type: 'string',
5190
+ enum: ['post', 'site'],
5191
+ description: 'Force a scope. Defaults to post when post_id is given, site otherwise.',
5192
+ },
5193
+ },
5194
+ required: [],
5195
+ },
5196
+ idempotentHint: true,
5197
+ },
5031
5198
  // Media enhancements
5032
5199
  {
5033
5200
  name: 'wordpress_get_media',
@@ -5782,7 +5949,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5782
5949
  },
5783
5950
  {
5784
5951
  name: 'woocommerce_list_orders',
5785
- description: 'List WooCommerce orders.',
5952
+ description: 'List WooCommerce orders. Every order carries customer_ip_address and customer_user_agent (the request fingerprint WooCommerce recorded, null when it recorded nothing) plus meta_data as a key/value list, so a card-testing run can be triaged from one call. Meta keys whose name matches a credential pattern (token, secret, key, password, hash, nonce, auth) are withheld; meta_data_withheld_count and meta_data_withheld_keys say which. Meta values over 500 characters are cut and the entry is marked truncated. The fingerprint fields are personal data and stay in this response: Respira telemetry never records tool results.',
5786
5953
  inputSchema: {
5787
5954
  type: 'object',
5788
5955
  properties: {
@@ -5795,7 +5962,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5795
5962
  },
5796
5963
  {
5797
5964
  name: 'woocommerce_get_order',
5798
- description: 'Get detailed information for a single WooCommerce order.',
5965
+ description: 'Get detailed information for a single WooCommerce order, including customer_ip_address, customer_user_agent and meta_data as a key/value list. Meta keys whose name matches a credential pattern (token, secret, key, password, hash, nonce, auth) are withheld and reported in meta_data_withheld_count and meta_data_withheld_keys, so filtered data never looks like missing data.',
5799
5966
  inputSchema: {
5800
5967
  type: 'object',
5801
5968
  properties: {
@@ -7539,7 +7706,9 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
7539
7706
  this.cachedFilterContext = null; // Invalidate tool filter cache on site switch.
7540
7707
  // B-19 (v6.18.7): persist so the next process restart restores
7541
7708
  // this site instead of falling back to the configured default.
7542
- saveRespiraMcpState({ last_active_site_id: String(args.site_id) });
7709
+ // 5004a473: scoped to this connection, so the choice does not leak
7710
+ // into every other Respira connection on the machine.
7711
+ saveRespiraMcpState(applyActiveSiteIdForConfig(loadRespiraMcpState(), this.stateNamespace, String(args.site_id)));
7543
7712
  return {
7544
7713
  success: true,
7545
7714
  message: `Switched to site: ${newSite.getSiteName()}`,
@@ -7817,6 +7986,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
7817
7986
  return await client.updateOption(args.option, args.value);
7818
7987
  case 'wordpress_delete_option':
7819
7988
  return await client.deleteOption(args.option, args.approval_token);
7989
+ case 'wordpress_purge_cache':
7990
+ return await client.purgeCache(args.post_id !== undefined ? Number(args.post_id) : undefined, args.scope);
7820
7991
  // Media enhancements
7821
7992
  case 'wordpress_get_media':
7822
7993
  return await client.getMedia(args.id);
@@ -7921,6 +8092,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
7921
8092
  return await client.callRestV2('POST', '/design-direction', args);
7922
8093
  case 'wordpress_activate_design_direction':
7923
8094
  return await client.callRestV2('POST', '/design-direction/activate', args);
8095
+ case 'wordpress_deactivate_design_direction':
8096
+ return await client.callRestV2('POST', '/design-direction/deactivate', args);
7924
8097
  case 'wordpress_delete_design_direction': {
7925
8098
  const { id, ...rest } = args;
7926
8099
  return await client.callRestV2('DELETE', `/design-direction/${encodeURIComponent(id)}`, rest);
@@ -8457,6 +8630,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
8457
8630
  type: 'boolean',
8458
8631
  description: 'Required when responding to a `confirmation_required` reply on the live path. Pass `true` alongside `edit_target: "live"` to acknowledge the published-original write. Has no effect when `edit_target` is `duplicate` or omitted.',
8459
8632
  },
8633
+ on_ambiguous_match: {
8634
+ type: 'string',
8635
+ enum: ['reject', 'first'],
8636
+ description: 'What to do when identifier_type/identifier_value match more than one element and match_content does not narrow it to exactly one. Default "reject" refuses the write with a 422 respira_ambiguous_target error and a candidate list, because guessing has silently edited the wrong element before. Pass "first" only when that is genuinely what you want: it means accepting whichever matching element comes first in document order, with no further confirmation.',
8637
+ },
8460
8638
  },
8461
8639
  required: ['post_id', 'identifier_type', 'identifier_value', 'updates'],
8462
8640
  },
@@ -8571,6 +8749,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
8571
8749
  type: 'boolean',
8572
8750
  description: 'Pass `true` alongside `edit_target: "live"` to acknowledge a published-original write when the first call returned `confirmation_required`.',
8573
8751
  },
8752
+ on_ambiguous_match: {
8753
+ type: 'string',
8754
+ enum: ['reject', 'first'],
8755
+ description: 'What to do when identifier_type/identifier_value match more than one element and match_content does not narrow it to exactly one. Default "reject" refuses the removal with a 422 respira_ambiguous_target error and a candidate list, because guessing has silently deleted the wrong element before. Pass "first" only when that is genuinely what you want: it means removing whichever matching element comes first in document order, with no further confirmation.',
8756
+ },
8574
8757
  },
8575
8758
  required: ['post_id', 'identifier_type', 'identifier_value'],
8576
8759
  },
@@ -8823,7 +9006,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
8823
9006
  widget: 'image',
8824
9007
  description: 'Add an image widget to a page.',
8825
9008
  properties: {
8826
- src: { type: 'string', description: 'Image URL or media library attachment URL' },
9009
+ src: { type: 'string', description: 'Image URL (remote or local). Either src or image_id is required.' },
9010
+ image_id: { type: 'integer', description: 'WordPress attachment ID. Resolves to image URL if src is not provided.' },
8827
9011
  alt: { type: 'string', description: 'Alt text' },
8828
9012
  width: { type: 'string', description: 'Image width' },
8829
9013
  height: { type: 'string', description: 'Image height' },
@@ -8961,7 +9145,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
8961
9145
  content: { type: 'string', description: 'Testimonial text' },
8962
9146
  name: { type: 'string', description: 'Author name' },
8963
9147
  title: { type: 'string', description: 'Author title/role' },
8964
- image: { type: 'string', description: 'Author photo URL' },
9148
+ image: { type: 'string', description: 'Author photo URL. Either image or image_id can be provided.' },
9149
+ image_id: { type: 'integer', description: 'WordPress attachment ID for author photo. Resolves to image URL if image is not provided.' },
8965
9150
  },
8966
9151
  required: ['content', 'name'],
8967
9152
  },