@wootsup/mcp 0.1.0 → 0.4.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.
Files changed (208) hide show
  1. package/CHANGELOG.md +157 -83
  2. package/README.md +31 -27
  3. package/SECURITY.md +15 -6
  4. package/dist/auth/keychain.d.ts +27 -1
  5. package/dist/auth/keychain.js +48 -2
  6. package/dist/auth/keychain.js.map +1 -1
  7. package/dist/catalog/build-catalog.d.ts +31 -0
  8. package/dist/catalog/build-catalog.js +68 -0
  9. package/dist/catalog/build-catalog.js.map +1 -0
  10. package/dist/cli-hint.d.ts +22 -0
  11. package/dist/cli-hint.js +55 -0
  12. package/dist/cli-hint.js.map +1 -0
  13. package/dist/index.js +129 -22
  14. package/dist/index.js.map +1 -1
  15. package/dist/install-skill.js +1 -1
  16. package/dist/modules/apimapper/auto-layout.d.ts +21 -0
  17. package/dist/modules/apimapper/auto-layout.js +54 -0
  18. package/dist/modules/apimapper/auto-layout.js.map +1 -0
  19. package/dist/modules/apimapper/cache.js +25 -17
  20. package/dist/modules/apimapper/cache.js.map +1 -1
  21. package/dist/modules/apimapper/client.d.ts +115 -4
  22. package/dist/modules/apimapper/client.js +699 -304
  23. package/dist/modules/apimapper/client.js.map +1 -1
  24. package/dist/modules/apimapper/connections-format.d.ts +31 -1
  25. package/dist/modules/apimapper/connections-format.js +97 -5
  26. package/dist/modules/apimapper/connections-format.js.map +1 -1
  27. package/dist/modules/apimapper/connections.d.ts +9 -7
  28. package/dist/modules/apimapper/connections.js +449 -127
  29. package/dist/modules/apimapper/connections.js.map +1 -1
  30. package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
  31. package/dist/modules/apimapper/credential-sanitizer.js +60 -1
  32. package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
  33. package/dist/modules/apimapper/credentials.js +105 -61
  34. package/dist/modules/apimapper/credentials.js.map +1 -1
  35. package/dist/modules/apimapper/diagnose.js +21 -2
  36. package/dist/modules/apimapper/diagnose.js.map +1 -1
  37. package/dist/modules/apimapper/elicitation.d.ts +29 -0
  38. package/dist/modules/apimapper/elicitation.js +62 -0
  39. package/dist/modules/apimapper/elicitation.js.map +1 -1
  40. package/dist/modules/apimapper/example-extract.d.ts +13 -0
  41. package/dist/modules/apimapper/example-extract.js +111 -0
  42. package/dist/modules/apimapper/example-extract.js.map +1 -0
  43. package/dist/modules/apimapper/filter-operators.d.ts +24 -0
  44. package/dist/modules/apimapper/filter-operators.js +103 -0
  45. package/dist/modules/apimapper/filter-operators.js.map +1 -0
  46. package/dist/modules/apimapper/flows-format.js +92 -22
  47. package/dist/modules/apimapper/flows-format.js.map +1 -1
  48. package/dist/modules/apimapper/flows.d.ts +8 -7
  49. package/dist/modules/apimapper/flows.js +275 -120
  50. package/dist/modules/apimapper/flows.js.map +1 -1
  51. package/dist/modules/apimapper/gateway/advanced-read-tool.d.ts +9 -0
  52. package/dist/modules/apimapper/gateway/advanced-read-tool.js +172 -0
  53. package/dist/modules/apimapper/gateway/advanced-read-tool.js.map +1 -0
  54. package/dist/modules/apimapper/gateway/advanced-tool.js +66 -106
  55. package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -1
  56. package/dist/modules/apimapper/gateway/collect-module-tools.d.ts +17 -0
  57. package/dist/modules/apimapper/gateway/collect-module-tools.js +44 -0
  58. package/dist/modules/apimapper/gateway/collect-module-tools.js.map +1 -0
  59. package/dist/modules/apimapper/gateway/essentials.d.ts +1 -1
  60. package/dist/modules/apimapper/gateway/essentials.js +21 -2
  61. package/dist/modules/apimapper/gateway/essentials.js.map +1 -1
  62. package/dist/modules/apimapper/gateway/gateway-shared.d.ts +21 -0
  63. package/dist/modules/apimapper/gateway/gateway-shared.js +124 -0
  64. package/dist/modules/apimapper/gateway/gateway-shared.js.map +1 -0
  65. package/dist/modules/apimapper/gateway/test-support.d.ts +1 -17
  66. package/dist/modules/apimapper/gateway/test-support.js +4 -33
  67. package/dist/modules/apimapper/gateway/test-support.js.map +1 -1
  68. package/dist/modules/apimapper/get-skill-cores.d.ts +4 -0
  69. package/dist/modules/apimapper/get-skill-cores.js +220 -0
  70. package/dist/modules/apimapper/get-skill-cores.js.map +1 -0
  71. package/dist/modules/apimapper/get-skill.d.ts +1 -1
  72. package/dist/modules/apimapper/get-skill.js +74 -9
  73. package/dist/modules/apimapper/get-skill.js.map +1 -1
  74. package/dist/modules/apimapper/graph-builder.d.ts +85 -2
  75. package/dist/modules/apimapper/graph-builder.js +151 -15
  76. package/dist/modules/apimapper/graph-builder.js.map +1 -1
  77. package/dist/modules/apimapper/graph.js +152 -48
  78. package/dist/modules/apimapper/graph.js.map +1 -1
  79. package/dist/modules/apimapper/index.js +27 -13
  80. package/dist/modules/apimapper/index.js.map +1 -1
  81. package/dist/modules/apimapper/jmespath-test.d.ts +4 -0
  82. package/dist/modules/apimapper/jmespath-test.js +152 -0
  83. package/dist/modules/apimapper/jmespath-test.js.map +1 -0
  84. package/dist/modules/apimapper/library.js +553 -88
  85. package/dist/modules/apimapper/library.js.map +1 -1
  86. package/dist/modules/apimapper/license.js +12 -36
  87. package/dist/modules/apimapper/license.js.map +1 -1
  88. package/dist/modules/apimapper/list-footer.d.ts +27 -0
  89. package/dist/modules/apimapper/list-footer.js +57 -0
  90. package/dist/modules/apimapper/list-footer.js.map +1 -0
  91. package/dist/modules/apimapper/local-sources.js +100 -57
  92. package/dist/modules/apimapper/local-sources.js.map +1 -1
  93. package/dist/modules/apimapper/mcp-client-identity.d.ts +32 -0
  94. package/dist/modules/apimapper/mcp-client-identity.js +70 -0
  95. package/dist/modules/apimapper/mcp-client-identity.js.map +1 -0
  96. package/dist/modules/apimapper/merge-constants.d.ts +6 -0
  97. package/dist/modules/apimapper/merge-constants.js +26 -0
  98. package/dist/modules/apimapper/merge-constants.js.map +1 -0
  99. package/dist/modules/apimapper/misc.js +13 -27
  100. package/dist/modules/apimapper/misc.js.map +1 -1
  101. package/dist/modules/apimapper/node-schema.d.ts +52 -2
  102. package/dist/modules/apimapper/node-schema.js +95 -4
  103. package/dist/modules/apimapper/node-schema.js.map +1 -1
  104. package/dist/modules/apimapper/onboarding.d.ts +59 -1
  105. package/dist/modules/apimapper/onboarding.js +231 -28
  106. package/dist/modules/apimapper/onboarding.js.map +1 -1
  107. package/dist/modules/apimapper/read-cache.d.ts +16 -3
  108. package/dist/modules/apimapper/read-cache.js +59 -4
  109. package/dist/modules/apimapper/read-cache.js.map +1 -1
  110. package/dist/modules/apimapper/render/index.js +26 -5
  111. package/dist/modules/apimapper/render/index.js.map +1 -1
  112. package/dist/modules/apimapper/resource-id.d.ts +13 -0
  113. package/dist/modules/apimapper/resource-id.js +69 -0
  114. package/dist/modules/apimapper/resource-id.js.map +1 -0
  115. package/dist/modules/apimapper/schema.js +9 -18
  116. package/dist/modules/apimapper/schema.js.map +1 -1
  117. package/dist/modules/apimapper/settings.js +49 -52
  118. package/dist/modules/apimapper/settings.js.map +1 -1
  119. package/dist/modules/apimapper/sites-tools.d.ts +29 -0
  120. package/dist/modules/apimapper/sites-tools.js +165 -0
  121. package/dist/modules/apimapper/sites-tools.js.map +1 -0
  122. package/dist/modules/apimapper/tool-result.d.ts +66 -0
  123. package/dist/modules/apimapper/tool-result.js +125 -0
  124. package/dist/modules/apimapper/tool-result.js.map +1 -0
  125. package/dist/modules/apimapper/toolslist-size.d.ts +12 -11
  126. package/dist/modules/apimapper/toolslist-size.js +34 -21
  127. package/dist/modules/apimapper/toolslist-size.js.map +1 -1
  128. package/dist/modules/apimapper/types.d.ts +34 -0
  129. package/dist/modules/apimapper/types.js +1 -1
  130. package/dist/modules/apimapper/types.js.map +1 -1
  131. package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
  132. package/dist/modules/apimapper/whitelist-drift.js +375 -0
  133. package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
  134. package/dist/modules/apimapper/workflows.js +302 -58
  135. package/dist/modules/apimapper/workflows.js.map +1 -1
  136. package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
  137. package/dist/modules/apimapper/yootheme-binding.js +267 -0
  138. package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
  139. package/dist/platform/index.d.ts +56 -0
  140. package/dist/platform/index.js +158 -2
  141. package/dist/platform/index.js.map +1 -1
  142. package/dist/proxy/bridge.d.ts +35 -0
  143. package/dist/proxy/bridge.js +129 -0
  144. package/dist/proxy/bridge.js.map +1 -0
  145. package/dist/proxy/mode.d.ts +9 -0
  146. package/dist/proxy/mode.js +20 -0
  147. package/dist/proxy/mode.js.map +1 -0
  148. package/dist/setup/detect-clients.d.ts +40 -1
  149. package/dist/setup/detect-clients.js +148 -1
  150. package/dist/setup/detect-clients.js.map +1 -1
  151. package/dist/setup/probe-auth.d.ts +51 -0
  152. package/dist/setup/probe-auth.js +141 -0
  153. package/dist/setup/probe-auth.js.map +1 -0
  154. package/dist/setup/probe-handshake.js +40 -7
  155. package/dist/setup/probe-handshake.js.map +1 -1
  156. package/dist/setup/remove-config.d.ts +8 -0
  157. package/dist/setup/remove-config.js +145 -0
  158. package/dist/setup/remove-config.js.map +1 -0
  159. package/dist/setup/uninstall.d.ts +34 -0
  160. package/dist/setup/uninstall.js +147 -0
  161. package/dist/setup/uninstall.js.map +1 -0
  162. package/dist/setup-cli.d.ts +16 -0
  163. package/dist/setup-cli.js +63 -1
  164. package/dist/setup-cli.js.map +1 -1
  165. package/dist/sites/loader.d.ts +48 -0
  166. package/dist/sites/loader.js +134 -0
  167. package/dist/sites/loader.js.map +1 -0
  168. package/dist/sites/schema.d.ts +69 -0
  169. package/dist/sites/schema.js +71 -0
  170. package/dist/sites/schema.js.map +1 -0
  171. package/dist/sites/secret-resolver.d.ts +47 -0
  172. package/dist/sites/secret-resolver.js +150 -0
  173. package/dist/sites/secret-resolver.js.map +1 -0
  174. package/dist/skill-instructions.d.ts +14 -1
  175. package/dist/skill-instructions.js +35 -6
  176. package/dist/skill-instructions.js.map +1 -1
  177. package/dist/transports/stdio.js +4 -4
  178. package/dist/transports/stdio.js.map +1 -1
  179. package/dist/uninstall-skill.d.ts +27 -0
  180. package/dist/uninstall-skill.js +89 -0
  181. package/dist/uninstall-skill.js.map +1 -0
  182. package/docs/architecture.md +21 -21
  183. package/docs/customgraph-internal-migration.md +4 -4
  184. package/docs/security.md +2 -21
  185. package/docs/tools.md +40 -12
  186. package/manifest.json +77 -79
  187. package/package.json +69 -65
  188. package/skills/apimapper/SKILL.md +128 -7
  189. package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
  190. package/skills/apimapper/reference/dynamize-existing-layout.md +158 -0
  191. package/skills/apimapper/reference/jmespath-cookbook.md +241 -0
  192. package/skills/apimapper/reference/jmespath-pitfalls.md +189 -0
  193. package/skills/apimapper/reference/joomla.md +1 -1
  194. package/skills/apimapper/reference/library-template-discovery.md +65 -0
  195. package/skills/apimapper/reference/merge-two-sources-on-key.md +204 -0
  196. package/skills/apimapper/reference/oauth.md +143 -52
  197. package/skills/apimapper/reference/troubleshooting.md +22 -2
  198. package/skills/apimapper/reference/yootheme-source-to-builder-handoff.md +348 -0
  199. package/skills/apimapper/reference/yootheme.md +75 -44
  200. package/dist/auth/oauth-provider.d.ts +0 -68
  201. package/dist/auth/oauth-provider.js +0 -232
  202. package/dist/auth/oauth-provider.js.map +0 -1
  203. package/dist/server-http.d.ts +0 -22
  204. package/dist/server-http.js +0 -159
  205. package/dist/server-http.js.map +0 -1
  206. package/dist/transports/http.d.ts +0 -29
  207. package/dist/transports/http.js +0 -267
  208. package/dist/transports/http.js.map +0 -1
@@ -25,8 +25,11 @@
25
25
  import { createHttpClient } from "@getimo/mcp-toolkit";
26
26
  import { Agent, setGlobalDispatcher } from "undici";
27
27
  import { sanitizeSecrets } from "./credential-sanitizer.js";
28
- import { getCached, setCached, isCacheableRequest, invalidateByPath, } from "./read-cache.js";
29
- import { WordPressPlatform, JoomlaPlatform } from "../../platform/index.js";
28
+ import { getMcpClient } from "./mcp-client-identity.js";
29
+ import { getCached, setCached, isCacheableRequest, invalidateByPath, clearCache, } from "./read-cache.js";
30
+ import { WordPressPlatform, JoomlaPlatform, isPlatformResponseError, isJoomlaUnsupportedPathError } from "../../platform/index.js";
31
+ import { loadSitesRegistry } from "../../sites/loader.js";
32
+ import { resolveSiteBearer } from "../../sites/secret-resolver.js";
30
33
  // Perf-#1 (2026-05-19): HTTP keep-alive connection pool.
31
34
  //
32
35
  // A typical customer flow fires 9-10 sequential tool calls against the same
@@ -159,12 +162,41 @@ function classify(status) {
159
162
  return "auth";
160
163
  if (status === 404)
161
164
  return "not_found";
165
+ // M1 (MCP-relay audit) — 409 is a distinct, recoverable outcome: the
166
+ // library-first guard block and optimistic-lock conflicts both use it. Map
167
+ // it to its own code so callers can branch on "conflict" without re-parsing
168
+ // the status. hintFor() falls through to HEALTH_HINT for it (no auth/retry
169
+ // recovery applies to a deliberate guard block — the structured errorBody
170
+ // carries the actionable next step instead).
171
+ if (status === 409)
172
+ return "conflict";
162
173
  if (status === 429)
163
174
  return "rate_limit";
164
175
  if (status >= 500)
165
176
  return "server";
166
177
  return "unknown";
167
178
  }
179
+ /**
180
+ * Recover the upstream HTTP status carried inside a Joomla com_ajax failure
181
+ * envelope. com_ajax always returns HTTP 200 and signals the real status in a
182
+ * `code` field on the `{success:false, error, code}` body (mirrors what the
183
+ * admin-ui Joomla client reads — `resultObj.code === 409` / `=== 404`). Both a
184
+ * numeric `code` (`409`) and a stringified numeric `code` (`"409"`) are
185
+ * accepted; non-numeric / absent values yield `undefined` so the caller keeps
186
+ * the transport status (or falls through to `"unknown"`). This is the single
187
+ * point that lets `classify()` restore 409/404/422/429/502 fidelity on Joomla.
188
+ */
189
+ function numericCodeFromBody(body) {
190
+ if (!body || typeof body !== "object")
191
+ return undefined;
192
+ const raw = body.code;
193
+ if (typeof raw === "number" && Number.isFinite(raw))
194
+ return raw;
195
+ if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
196
+ return Number.parseInt(raw, 10);
197
+ }
198
+ return undefined;
199
+ }
168
200
  const DEFAULT_TIMEOUT_MS = 45_000;
169
201
  const MIN_TIMEOUT_MS = 1_000;
170
202
  const MAX_TIMEOUT_MS = 300_000;
@@ -172,6 +204,149 @@ function resolveTimeout(opts) {
172
204
  const t = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
173
205
  return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, t));
174
206
  }
207
+ async function performFetch(cfg) {
208
+ const { url, headers, init, opts, parseResponse, preferMessageKey = true, attachErrorBody = false, textSuccessFalseGuard = false, } = cfg;
209
+ let status;
210
+ const timeoutMs = resolveTimeout(opts);
211
+ // Q1 (2026-06-14): performFetch is the ONE outbound chokepoint shared by the
212
+ // WP, Joomla, and absolute request paths. Attaching X-MCP-Client here means
213
+ // every REST call carries the connected AI client's identity (set once
214
+ // post-handshake in index.ts) without touching three caller header builders.
215
+ // Null when no client info → header omitted entirely.
216
+ const mcpClient = getMcpClient();
217
+ try {
218
+ const res = await fetch(url, {
219
+ ...init,
220
+ headers: {
221
+ ...headers,
222
+ ...(mcpClient ? { "X-MCP-Client": mcpClient } : {}),
223
+ },
224
+ signal: AbortSignal.timeout(timeoutMs),
225
+ });
226
+ status = res.status;
227
+ const text = await res.text();
228
+ if (!res.ok) {
229
+ let parsed = text;
230
+ try {
231
+ parsed = JSON.parse(text);
232
+ }
233
+ catch {
234
+ // keep as text
235
+ }
236
+ const errMsg = preferMessageKey && typeof parsed === "object" && parsed && "message" in parsed
237
+ ? String(parsed.message)
238
+ : typeof parsed === "object" && parsed && "error" in parsed
239
+ ? String(parsed.error)
240
+ : `HTTP ${res.status}`;
241
+ const errorBody = typeof parsed === "object" && parsed !== null
242
+ ? parsed
243
+ : undefined;
244
+ return {
245
+ success: false,
246
+ error: sanitizeErrorString(errMsg),
247
+ status,
248
+ errorCode: classify(status),
249
+ // W2-3: the WP path ALWAYS carried an `errorBody` key on a non-2xx
250
+ // response (the value is `undefined` when the body wasn't JSON). The
251
+ // Joomla + absolute paths never set it. `attachErrorBody` reproduces
252
+ // that key-presence difference exactly.
253
+ ...(attachErrorBody ? { errorBody } : {}),
254
+ };
255
+ }
256
+ if (!text)
257
+ return { success: true, data: {}, status };
258
+ let data;
259
+ try {
260
+ data = JSON.parse(text);
261
+ }
262
+ catch {
263
+ // Non-JSON body. The absolute path additionally treats a text body that
264
+ // contains `success:false` as a payload failure under unwrapInnerSuccess.
265
+ if (textSuccessFalseGuard &&
266
+ opts.unwrapInnerSuccess &&
267
+ /success["']?\s*:\s*false/i.test(text)) {
268
+ return {
269
+ success: false,
270
+ error: sanitizeErrorString(`text response indicates success:false — ${text.slice(0, 200)}`),
271
+ status,
272
+ errorCode: "unknown",
273
+ payloadFailed: true,
274
+ };
275
+ }
276
+ // Otherwise pass through as text.
277
+ return { success: true, data: text, status };
278
+ }
279
+ // Run the (platform-specific or identity) envelope unwrap. A Joomla
280
+ // success:false envelope throws a PlatformResponseError carrying the parsed
281
+ // body; we translate that to a structured failure so callers see a uniform
282
+ // shape regardless of platform.
283
+ let unwrapped;
284
+ try {
285
+ unwrapped = parseResponse(data, init.method ?? "GET");
286
+ }
287
+ catch (e) {
288
+ // errorCode fidelity (WP↔Joomla parity fix 2026-06-07): a Joomla
289
+ // com_ajax failure throws a PlatformResponseError carrying the parsed
290
+ // envelope. com_ajax always returns HTTP 200, so the transport `status`
291
+ // is 200 — useless for classification. The REAL upstream status lives in
292
+ // the envelope `code` field. Recover it and run it through the SAME
293
+ // classify() the WordPress non-2xx path uses, restoring 409("conflict") /
294
+ // 404 / 422 / 429 / 502 fidelity on Joomla (fixes the live "guard 409
295
+ // shows as unknown on Joomla" loss). When the envelope carries no numeric
296
+ // code we keep "unknown" (no regression for handlers that omit it) and
297
+ // preserve the transport `status`.
298
+ const errorBody = isPlatformResponseError(e) ? e.body : undefined;
299
+ const envelopeStatus = numericCodeFromBody(errorBody);
300
+ return {
301
+ success: false,
302
+ error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
303
+ status: envelopeStatus ?? status,
304
+ errorCode: envelopeStatus !== undefined ? classify(envelopeStatus) : "unknown",
305
+ payloadFailed: true,
306
+ errorBody,
307
+ };
308
+ }
309
+ // unwrapInnerSuccess: treat 200 + `{success:false}` payload as an error
310
+ // (WP REST sometimes returns inner-success envelopes for connection_test,
311
+ // license_activate, etc.).
312
+ if (opts.unwrapInnerSuccess &&
313
+ unwrapped &&
314
+ typeof unwrapped === "object" &&
315
+ "success" in unwrapped) {
316
+ const inner = unwrapped.success;
317
+ if (inner === false) {
318
+ const innerErr = unwrapped.error !== undefined
319
+ ? String(unwrapped.error)
320
+ : "operation reported success:false in payload";
321
+ return {
322
+ success: false,
323
+ error: sanitizeErrorString(innerErr),
324
+ status,
325
+ errorCode: "unknown",
326
+ payloadFailed: true,
327
+ };
328
+ }
329
+ }
330
+ const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
331
+ return { success: true, data: out, status };
332
+ }
333
+ catch (e) {
334
+ // AbortSignal.timeout fires "TimeoutError" or "AbortError" depending on the
335
+ // Node version — classify both as network/timeout.
336
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
337
+ return {
338
+ success: false,
339
+ error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
340
+ errorCode: "network",
341
+ };
342
+ }
343
+ return {
344
+ success: false,
345
+ error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
346
+ errorCode: "network",
347
+ };
348
+ }
349
+ }
175
350
  /**
176
351
  * Create a Platform-aware HTTP client. The Platform handles URL construction,
177
352
  * auth header injection, and response envelope unwrap (e.g. Joomla's
@@ -183,115 +358,26 @@ export function createPlatformClient(platform) {
183
358
  return {
184
359
  platform,
185
360
  request: async (action, init = {}, opts = {}) => {
186
- let status;
187
- const timeoutMs = resolveTimeout(opts);
188
- try {
189
- const url = platform.buildUrl(action);
190
- const res = await fetch(url, {
191
- ...init,
192
- headers: {
193
- Accept: "application/json",
194
- "Content-Type": "application/json",
195
- "X-Requested-With": "XMLHttpRequest",
196
- ...platform.buildAuthHeaders(init.method ?? "GET"),
197
- ...init.headers,
198
- },
199
- signal: AbortSignal.timeout(timeoutMs),
200
- });
201
- status = res.status;
202
- const text = await res.text();
203
- if (!res.ok) {
204
- let parsed = text;
205
- try {
206
- parsed = JSON.parse(text);
207
- }
208
- catch {
209
- // keep as text
210
- }
211
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
212
- ? String(parsed.message)
213
- : typeof parsed === "object" && parsed && "error" in parsed
214
- ? String(parsed.error)
215
- : `HTTP ${res.status}`;
216
- // W2-3: preserve the parsed JSON error body when present so callers
217
- // can branch on domain-specific fields (e.g. compiler_error_code).
218
- const errorBody = typeof parsed === "object" && parsed !== null
219
- ? parsed
220
- : undefined;
221
- return {
222
- success: false,
223
- error: sanitizeErrorString(errMsg),
224
- status,
225
- errorCode: classify(status),
226
- errorBody,
227
- };
228
- }
229
- if (!text)
230
- return { success: true, data: {}, status };
231
- let data;
232
- try {
233
- data = JSON.parse(text);
234
- }
235
- catch {
236
- // Non-JSON body — pass through as text.
237
- return { success: true, data: text, status };
238
- }
239
- // Run the platform-specific envelope unwrap. Joomla throws on
240
- // success:false; we translate that to a structured failure so
241
- // tools see a uniform shape regardless of platform.
242
- let unwrapped;
243
- try {
244
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
245
- }
246
- catch (e) {
247
- return {
248
- success: false,
249
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
250
- status,
251
- errorCode: "unknown",
252
- payloadFailed: true,
253
- };
254
- }
255
- // unwrapInnerSuccess for WordPress: treat 200 + `{success:false}`
256
- // payload as error (WP REST sometimes returns inner-success envelopes
257
- // for connection_test, license_activate, etc.).
258
- if (opts.unwrapInnerSuccess &&
259
- unwrapped &&
260
- typeof unwrapped === "object" &&
261
- "success" in unwrapped) {
262
- const inner = unwrapped.success;
263
- if (inner === false) {
264
- const innerErr = unwrapped.error !== undefined
265
- ? String(unwrapped.error)
266
- : "operation reported success:false in payload";
267
- return {
268
- success: false,
269
- error: sanitizeErrorString(innerErr),
270
- status,
271
- errorCode: "unknown",
272
- payloadFailed: true,
273
- };
274
- }
275
- }
276
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
277
- return { success: true, data: out, status };
278
- }
279
- catch (e) {
280
- // AbortSignal.timeout fires "TimeoutError" or "AbortError" depending on
281
- // Node version — classify both as network/timeout.
282
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
283
- return {
284
- success: false,
285
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
286
- errorCode: "network",
287
- };
288
- }
289
- return {
290
- success: false,
291
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
292
- errorCode: "network",
293
- };
294
- }
361
+ // A3 dedup: the WP path builds its URL + auth headers, then defers the
362
+ // shared fetch → parse → unwrap → sanitize pipeline to performFetch.
363
+ // attachErrorBody:true preserves the W2-3 errorBody passthrough; the
364
+ // parseResponse is the platform's own envelope unwrap (Joomla throws a
365
+ // PlatformResponseError on success:false, threaded into errorBody by the
366
+ // shared core).
367
+ return performFetch({
368
+ url: platform.buildUrl(action),
369
+ headers: {
370
+ Accept: "application/json",
371
+ "Content-Type": "application/json",
372
+ "X-Requested-With": "XMLHttpRequest",
373
+ ...platform.buildAuthHeaders(init.method ?? "GET"),
374
+ ...init.headers,
375
+ },
376
+ init,
377
+ opts,
378
+ parseResponse: (data, method) => platform.parseResponse(data, method),
379
+ attachErrorBody: true,
380
+ });
295
381
  },
296
382
  };
297
383
  }
@@ -331,24 +417,306 @@ class WordPressBasicAuthPlatform extends WordPressPlatform {
331
417
  // actual decision keeps a future widening (.toLowerCase(), trim, etc.) from
332
418
  // flipping behaviour silently — anything that isn't the literal "joomla"
333
419
  // must continue to default to WordPress.
420
+ //
421
+ // Phase 1 (2026-06-03): PLATFORM_KIND remains the EXPLICIT env→kind mapping
422
+ // (the "hint"). It is read by the build-dxt grep gate (a literal
423
+ // `process.env.APIMAPPER_PLATFORM` consumer must live in src/) and by 6
424
+ // client.test.ts assertions. The NETWORK auto-detect (probePlatform +
425
+ // resolveLegacy below) layers ON TOP of it: it is consulted ONLY when the env
426
+ // var is neither the literal "wordpress" nor "joomla" (i.e. unset or "auto").
334
427
  export const PLATFORM_KIND = process.env.APIMAPPER_PLATFORM === "joomla" ? "joomla" : "wordpress";
335
- const legacyPlatform = PLATFORM_KIND === "joomla"
336
- ? new JoomlaPlatform({
337
- // Joomla uses the same APIMAPPER_WP_BASE env var (or APIMAPPER_SITE_URL
338
- // alias bridged by the DXT bootstrap in src/index.ts). The token is the
339
- // raw amk_live_... bearer the setup CLI stored in the keychain. We
340
- // accept the WP_USER:WP_APP_PASS pair too split on the colon and use
341
- // the password as the bearer for ergonomic parity with the WP env vars.
342
- baseUrl: WP_BASE,
343
- token: process.env.APIMAPPER_TOKEN
344
- ?? (BASIC_TOKEN ? Buffer.from(BASIC_TOKEN, "base64").toString().split(":")[1] ?? "" : ""),
345
- })
346
- : new WordPressBasicAuthPlatform({
347
- baseUrl: WP_BASE,
348
- basicToken: BASIC_TOKEN,
349
- bearerToken: BEARER_TOKEN,
350
- });
351
- const legacyClient = createPlatformClient(legacyPlatform);
428
+ /**
429
+ * The Bearer token the legacy platform clients authenticate with. The setup
430
+ * CLI stores the raw `amk_live_…` MCP key; legacy App-Password users instead
431
+ * set WP_USER:WP_APP_PASS, in which case we recover the password half from the
432
+ * pre-computed BASIC_TOKEN. Computed once both the identity probe and the
433
+ * resolved platform reuse it so the wire-level auth is identical.
434
+ */
435
+ const LEGACY_BEARER = process.env.APIMAPPER_TOKEN
436
+ ?? (BASIC_TOKEN ? Buffer.from(BASIC_TOKEN, "base64").toString().split(":")[1] ?? "" : "");
437
+ /**
438
+ * Build the Authorization header the identity probe sends. Prefers the modern
439
+ * Bearer token (amk_live_… / recovered App-Password) and falls back to the
440
+ * legacy Basic header so a pure WP_USER:WP_APP_PASS install still probes
441
+ * authenticated. Empty object when no credential is configured (the probe then
442
+ * relies on a public identity endpoint, degrading to the WordPress fallback).
443
+ */
444
+ function probeAuthHeader() {
445
+ if (LEGACY_BEARER)
446
+ return { Authorization: `Bearer ${LEGACY_BEARER}` };
447
+ if (BASIC_TOKEN)
448
+ return { Authorization: `Basic ${BASIC_TOKEN}` };
449
+ return {};
450
+ }
451
+ const PROBE_TIMEOUT_MS = 8_000;
452
+ /**
453
+ * Network platform auto-detect. Concurrently probes the WordPress REST identity
454
+ * endpoint and the Joomla com_ajax getIdentity task with the same auth header,
455
+ * and decides which CMS is actually running at WP_BASE:
456
+ *
457
+ * - Joomla wins if its probe is HTTP 200 AND the body parses to
458
+ * `{success:true, data:[{success:true, …}]}` (or `data[0].platform` set).
459
+ * - WordPress wins if its probe is HTTP 200 AND the body is a JSON object
460
+ * that is NOT a `{success:false}` error envelope.
461
+ * - If both look OK (shouldn't happen on a single CMS), prefer the one whose
462
+ * reported identity `.platform` matches its own kind; else WordPress.
463
+ * - If neither answers → return "wordpress" (graceful fallback — no worse
464
+ * than the pre-Phase-1 silent default).
465
+ *
466
+ * All fetch errors are swallowed → fallback. The token is sent on the wire but
467
+ * never logged. Short timeout so a blocked probe host can't stall startup.
468
+ *
469
+ * Phase 3 (2026-06-03): accepts optional `(baseUrl, token)` overrides so a
470
+ * per-site probe (sites-file path) can target the entry's own URL + token. The
471
+ * no-arg call preserves the env-path behaviour exactly (module WP_BASE +
472
+ * probeAuthHeader()).
473
+ */
474
+ export async function probePlatform(baseUrl, token) {
475
+ const base = (baseUrl ?? WP_BASE).replace(/\/$/, "");
476
+ if (!base)
477
+ return "wordpress";
478
+ const headers = {
479
+ Accept: "application/json",
480
+ "X-Requested-With": "XMLHttpRequest",
481
+ ...(token ? { Authorization: `Bearer ${token}` } : probeAuthHeader()),
482
+ };
483
+ const wpUrl = `${base}/wp-json/api-mapper/v1/identity`;
484
+ const joomlaUrl = `${base}/index.php?option=com_ajax&plugin=apimapper&task=getIdentity&format=json`;
485
+ async function probe(url) {
486
+ try {
487
+ const res = await fetch(url, {
488
+ method: "GET",
489
+ headers,
490
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
491
+ });
492
+ if (!res.ok)
493
+ return { ok: false, body: undefined };
494
+ const text = await res.text();
495
+ let body;
496
+ try {
497
+ body = JSON.parse(text);
498
+ }
499
+ catch {
500
+ return { ok: false, body: undefined };
501
+ }
502
+ return { ok: true, body };
503
+ }
504
+ catch {
505
+ // Network error / timeout / abort → treat as "no answer".
506
+ return null;
507
+ }
508
+ }
509
+ const [wp, joomla] = await Promise.all([probe(wpUrl), probe(joomlaUrl)]);
510
+ const joomlaLooksOk = (() => {
511
+ if (!joomla?.ok)
512
+ return false;
513
+ const b = joomla.body;
514
+ if (!b || typeof b !== "object" || !("success" in b))
515
+ return false;
516
+ if (b.success !== true)
517
+ return false;
518
+ const data = b.data;
519
+ if (!Array.isArray(data) || data.length === 0)
520
+ return false;
521
+ const first = data[0];
522
+ if (!first || typeof first !== "object")
523
+ return false;
524
+ const f = first;
525
+ return f.success === true || f.platform !== undefined;
526
+ })();
527
+ const wpLooksOk = (() => {
528
+ if (!wp?.ok)
529
+ return false;
530
+ const b = wp.body;
531
+ if (!b || typeof b !== "object")
532
+ return false;
533
+ // A WP identity object is a plain object; explicitly reject an error
534
+ // envelope ({success:false}) so a Joomla 200 success:false leaking through
535
+ // a misconfigured route doesn't masquerade as a WordPress identity.
536
+ if ("success" in b && b.success === false)
537
+ return false;
538
+ return true;
539
+ })();
540
+ if (joomlaLooksOk && wpLooksOk) {
541
+ // Both answered (one CMS shouldn't expose both). Disambiguate by the
542
+ // self-reported platform, else fall back to WordPress.
543
+ const data = (joomla?.body).data;
544
+ const jPlatform = data?.[0]?.platform;
545
+ if (jPlatform === "joomla")
546
+ return "joomla";
547
+ return "wordpress";
548
+ }
549
+ if (joomlaLooksOk)
550
+ return "joomla";
551
+ if (wpLooksOk)
552
+ return "wordpress";
553
+ return "wordpress";
554
+ }
555
+ /**
556
+ * Build the legacy Platform + PlatformClient for a resolved kind. Reproduces
557
+ * exactly the per-kind construction the old module-eval consts performed.
558
+ */
559
+ function buildLegacy(kind) {
560
+ const platform = kind === "joomla"
561
+ ? new JoomlaPlatform({
562
+ // Joomla uses the same APIMAPPER_WP_BASE env var (or
563
+ // APIMAPPER_SITE_URL alias bridged by the DXT bootstrap). The token
564
+ // is the raw amk_live_… bearer; we also accept the WP_USER:WP_APP_PASS
565
+ // pair (the password half recovered into LEGACY_BEARER).
566
+ baseUrl: WP_BASE,
567
+ token: LEGACY_BEARER,
568
+ })
569
+ : new WordPressBasicAuthPlatform({
570
+ baseUrl: WP_BASE,
571
+ basicToken: BASIC_TOKEN,
572
+ bearerToken: BEARER_TOKEN,
573
+ });
574
+ return { platform, client: createPlatformClient(platform) };
575
+ }
576
+ // ── Phase 3: sites-file multi-site (active-site routing) ───────────────
577
+ //
578
+ // When APIMAPPER_SITES_FILE points at a non-empty sites.json, resolveLegacy()
579
+ // resolves the ACTIVE site's {baseUrl, token, platform} from the file rather
580
+ // than the env single-site vars. The active selection is an in-memory pointer
581
+ // (`_activeSiteId`) that `setActiveSite()` flips; when unset the file's default
582
+ // entry is used. Switching the pointer resets the resolution memo so the very
583
+ // next `request()` retargets the new site. The keychain ProfileStore path
584
+ // (src/auth/profiles.ts) stays the single-machine default — the two mechanisms
585
+ // coexist; the sites-file wins ONLY when the env var is set + the file loads
586
+ // non-empty.
587
+ /** Memoized sites-file registry. `null` = no usable sites-file (env path). */
588
+ let _sitesRegistry;
589
+ /**
590
+ * Lazily load + memoize the sites-file registry from APIMAPPER_SITES_FILE.
591
+ * Returns `null` when the env var is unset/empty, the file is absent, or it has
592
+ * zero sites — in all those cases the caller falls through to the env path.
593
+ * A malformed / schema-invalid file throws `SitesFileError` (loud-fail at first
594
+ * use rather than silently degrading multi-site to single-site).
595
+ */
596
+ export function getSitesRegistry() {
597
+ if (_sitesRegistry !== undefined)
598
+ return _sitesRegistry;
599
+ _sitesRegistry = loadSitesRegistry(process.env.APIMAPPER_SITES_FILE);
600
+ return _sitesRegistry;
601
+ }
602
+ /** In-memory active-site pointer. Only meaningful when a sites-file is loaded. */
603
+ let _activeSiteId;
604
+ /**
605
+ * The currently-active site_id, or `null` when none has been explicitly set
606
+ * (the file's default entry is then used) or when no sites-file is loaded.
607
+ */
608
+ export function getActiveSiteId() {
609
+ return _activeSiteId ?? null;
610
+ }
611
+ /**
612
+ * Switch the active site. Validates that `id` exists in the loaded sites-file
613
+ * registry, updates the in-memory pointer, and resets the resolution memo so
614
+ * the next `request()` re-resolves (URL + token + platform) against the new
615
+ * site. Throws when no sites-file is loaded or the id is unknown — the pointer
616
+ * is NOT changed on error.
617
+ */
618
+ export function setActiveSite(id) {
619
+ const reg = getSitesRegistry();
620
+ if (!reg) {
621
+ throw new Error("setActiveSite: no sites-file loaded (APIMAPPER_SITES_FILE unset or empty). " +
622
+ "Multi-site switching via a sites-file is unavailable.");
623
+ }
624
+ if (!reg.has(id)) {
625
+ throw new Error(`setActiveSite: unknown site_id "${id}". Known: ${reg.listIds().join(", ") || "(none)"}.`);
626
+ }
627
+ _activeSiteId = id;
628
+ _legacyResolution = undefined;
629
+ // The read-cache is keyed by path+method, NOT by site — switching the active
630
+ // backend would otherwise serve the previous site's cached GET responses.
631
+ // Flush it so reads after a switch hit the newly-targeted site.
632
+ clearCache();
633
+ }
634
+ /**
635
+ * Build the legacy Platform + client for a single sites-file entry. The entry's
636
+ * own url + resolved token + platform hint drive construction; an `auto` hint
637
+ * triggers a per-entry network probe against the entry's URL.
638
+ */
639
+ async function buildLegacyForSite(entry) {
640
+ const { token } = await resolveSiteBearer(entry);
641
+ const baseUrl = entry.url.replace(/\/$/, "");
642
+ const kind = entry.platform === "joomla"
643
+ ? "joomla"
644
+ : entry.platform === "wordpress"
645
+ ? "wordpress"
646
+ : await probePlatform(baseUrl, token);
647
+ const platform = kind === "joomla"
648
+ ? new JoomlaPlatform({ baseUrl, token })
649
+ : // Sites-file always carries a Bearer (amk_…) token — use the Bearer
650
+ // branch of the WP platform (no legacy Basic App-Password here).
651
+ new WordPressBasicAuthPlatform({ baseUrl, basicToken: "", bearerToken: token });
652
+ return { platform, client: createPlatformClient(platform) };
653
+ }
654
+ /**
655
+ * Lazy, memoized platform resolution for the legacy `request()` path.
656
+ *
657
+ * Replaces the former module-eval `legacyPlatform`/`legacyClient` consts so the
658
+ * network probe (which is async) can run on first use without blocking ESM
659
+ * evaluation. Resolution order:
660
+ *
661
+ * 1. **sites-file** (APIMAPPER_SITES_FILE set + loads non-empty): the active
662
+ * entry = the in-memory active-site pointer if set, else the file's default
663
+ * entry. Its url/token drive the client; platform = the entry's explicit
664
+ * hint or (when `auto`) a per-entry probe of the entry's URL.
665
+ * 2. explicit env `APIMAPPER_PLATFORM === "joomla"` → Joomla, NO probe.
666
+ * 3. explicit env `APIMAPPER_PLATFORM === "wordpress"` → WordPress, NO probe.
667
+ * 4. anything else (unset / "auto") → `await probePlatform()` against WP_BASE.
668
+ *
669
+ * The result is memoized for the lifetime of the module instance — a second
670
+ * `request()` reuses it without re-probing. `setActiveSite()` and
671
+ * `resetPlatformResolution()` clear the memo. Tests reset via
672
+ * `__resetPlatformResolutionForTests()` (an alias of resetPlatformResolution).
673
+ */
674
+ let _legacyResolution;
675
+ function resolveLegacy() {
676
+ if (_legacyResolution)
677
+ return _legacyResolution;
678
+ _legacyResolution = (async () => {
679
+ const reg = getSitesRegistry();
680
+ if (reg) {
681
+ const entry = (_activeSiteId ? reg.get(_activeSiteId) : null) ?? reg.getDefault();
682
+ return buildLegacyForSite(entry);
683
+ }
684
+ const env = process.env.APIMAPPER_PLATFORM;
685
+ const kind = env === "joomla"
686
+ ? "joomla"
687
+ : env === "wordpress"
688
+ ? "wordpress"
689
+ : await probePlatform();
690
+ return buildLegacy(kind);
691
+ })();
692
+ return _legacyResolution;
693
+ }
694
+ /**
695
+ * Drop the memoized platform resolution (and the cached sites-file registry +
696
+ * active-site pointer) so the next `request()` re-resolves from scratch —
697
+ * re-reading APIMAPPER_SITES_FILE, re-running the env-check / network probe.
698
+ *
699
+ * Production callers: invoke after deliberately changing the active selection
700
+ * outside `setActiveSite()` (the tool wiring uses `setActiveSite`, which already
701
+ * resets the memo). Safe to call any time.
702
+ */
703
+ export function resetPlatformResolution() {
704
+ _legacyResolution = undefined;
705
+ _sitesRegistry = undefined;
706
+ _activeSiteId = undefined;
707
+ // Drop any cached GET responses too — a full re-resolution may re-target a
708
+ // different backend (sites-file reload / active-site change), and the
709
+ // read-cache is not site-aware.
710
+ clearCache();
711
+ }
712
+ /**
713
+ * Test-only alias of {@link resetPlatformResolution}, kept for the existing
714
+ * Phase-1 client tests that reference it by this name. New code should call
715
+ * `resetPlatformResolution()`.
716
+ */
717
+ export function __resetPlatformResolutionForTests() {
718
+ resetPlatformResolution();
719
+ }
352
720
  /**
353
721
  * Issue a request through the toolkit HTTP client + capture HTTP status.
354
722
  *
@@ -380,17 +748,25 @@ export async function request(path, init = {}, opts = {}) {
380
748
  // Strip the leading "/" — Platform.buildUrl() takes a bare action name.
381
749
  // Also tolerate the existing escape-hatch where `path` starts with "http"
382
750
  // (absolute URL); fall back to raw fetch in that case.
751
+ //
752
+ // Phase 1 (2026-06-03): the legacy platform is now resolved lazily (env-hint
753
+ // first, network probe when unset/auto) + memoized. resolveLegacy() awaits at
754
+ // most one probe round-trip on the very first request; subsequent calls reuse
755
+ // the cached resolution.
383
756
  let response;
384
757
  if (path.startsWith("http")) {
385
758
  response = await rawAbsoluteRequest(path, init, opts);
386
759
  }
387
- else if (legacyPlatform instanceof JoomlaPlatform) {
388
- // Joomla branch — translate REST path → com_ajax task + params.
389
- response = await joomlaRequest(legacyPlatform, path, init, opts);
390
- }
391
760
  else {
392
- const action = path.replace(/^\/+/, "");
393
- response = await legacyClient.request(action, init, opts);
761
+ const { platform, client } = await resolveLegacy();
762
+ if (platform instanceof JoomlaPlatform) {
763
+ // Joomla branch — translate REST path → com_ajax task + params.
764
+ response = await joomlaRequest(platform, path, init, opts);
765
+ }
766
+ else {
767
+ const action = path.replace(/^\/+/, "");
768
+ response = await client.request(action, init, opts);
769
+ }
394
770
  }
395
771
  // Cache successful responses; never cache errors so the next call retries.
396
772
  if (cacheEligible && response.success) {
@@ -415,86 +791,51 @@ async function joomlaRequest(platform, path, init, opts) {
415
791
  translated = platform.translateRestPath(path, init.method ?? "GET");
416
792
  }
417
793
  catch (e) {
418
- return {
419
- success: false,
420
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
421
- errorCode: "unknown",
422
- };
423
- }
424
- let status;
425
- const timeoutMs = resolveTimeout(opts);
426
- try {
427
- const url = platform.buildUrl(translated.action, translated.params);
428
- const res = await fetch(url, {
429
- ...init,
430
- headers: {
431
- Accept: "application/json",
432
- "Content-Type": "application/json",
433
- "X-Requested-With": "XMLHttpRequest",
434
- ...platform.buildAuthHeaders(init.method ?? "GET"),
435
- ...init.headers,
436
- },
437
- signal: AbortSignal.timeout(timeoutMs),
438
- });
439
- status = res.status;
440
- const text = await res.text();
441
- if (!res.ok) {
442
- let parsed = text;
443
- try {
444
- parsed = JSON.parse(text);
445
- }
446
- catch {
447
- /* keep as text */
448
- }
449
- const errMsg = typeof parsed === "object" && parsed && "error" in parsed
450
- ? String(parsed.error)
451
- : `HTTP ${res.status}`;
452
- return {
453
- success: false,
454
- error: sanitizeErrorString(errMsg),
455
- status,
456
- errorCode: classify(status),
457
- };
458
- }
459
- if (!text)
460
- return { success: true, data: {}, status };
461
- let data;
462
- try {
463
- data = JSON.parse(text);
464
- }
465
- catch {
466
- return { success: true, data: text, status };
467
- }
468
- let unwrapped;
469
- try {
470
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
471
- }
472
- catch (e) {
473
- return {
474
- success: false,
475
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
476
- status,
477
- errorCode: "unknown",
478
- payloadFailed: true,
479
- };
480
- }
481
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
482
- return { success: true, data: out, status };
483
- }
484
- catch (e) {
485
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
794
+ // HIGH cross-platform finding (Wave-B audit, 2026-06-03): distinguish a
795
+ // genuinely WordPress-only feature from an unknown/typo path. For WP-only
796
+ // paths translateRestPath raises `JoomlaUnsupportedPathError`; surface it
797
+ // as a clean structured result (`errorCode: "not_found"` + `errorBody`
798
+ // carrying `unsupported: true`) so the tool's errorResult reads as
799
+ // "feature not available on Joomla" instead of leaking the opaque
800
+ // "no joomla mapping … (unknown rest path)" internal string.
801
+ if (isJoomlaUnsupportedPathError(e)) {
486
802
  return {
487
803
  success: false,
488
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
489
- errorCode: "network",
804
+ error: sanitizeErrorString(e.message),
805
+ errorCode: "not_found",
806
+ errorBody: {
807
+ unsupported: true,
808
+ platform: "joomla",
809
+ path: e.path,
810
+ },
490
811
  };
491
812
  }
492
813
  return {
493
814
  success: false,
494
815
  error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
495
- errorCode: "network",
816
+ errorCode: "unknown",
496
817
  };
497
818
  }
819
+ // A3 dedup: Joomla builds its translated com_ajax URL + auth headers, then
820
+ // defers to the shared core. preferMessageKey:false mirrors the original
821
+ // Joomla branch that reads only the `error` key; attachErrorBody stays off
822
+ // (the non-2xx errorBody passthrough is WP-only). The success:false com_ajax
823
+ // envelope is raised as a PlatformResponseError by parseResponse and threaded
824
+ // into errorBody by performFetch — identical to the WP path.
825
+ return performFetch({
826
+ url: platform.buildUrl(translated.action, translated.params),
827
+ headers: {
828
+ Accept: "application/json",
829
+ "Content-Type": "application/json",
830
+ "X-Requested-With": "XMLHttpRequest",
831
+ ...platform.buildAuthHeaders(init.method ?? "GET"),
832
+ ...init.headers,
833
+ },
834
+ init,
835
+ opts,
836
+ parseResponse: (data, method) => platform.parseResponse(data, method),
837
+ preferMessageKey: false,
838
+ });
498
839
  }
499
840
  /**
500
841
  * Absolute-URL escape hatch: callers occasionally pass a fully-qualified
@@ -503,121 +844,175 @@ async function joomlaRequest(platform, path, init, opts) {
503
844
  * to raw fetch but preserve the same auth + sanitisation semantics.
504
845
  */
505
846
  async function rawAbsoluteRequest(url, init, opts) {
506
- let status;
507
- const timeoutMs = resolveTimeout(opts);
508
- try {
509
- const res = await fetch(url, {
510
- ...init,
511
- headers: {
512
- Accept: "application/json",
513
- "Content-Type": "application/json",
514
- "X-Requested-With": "XMLHttpRequest",
515
- ...(BASIC_TOKEN ? { Authorization: `Basic ${BASIC_TOKEN}` } : {}),
516
- ...init.headers,
517
- },
518
- signal: AbortSignal.timeout(timeoutMs),
519
- });
520
- status = res.status;
521
- const text = await res.text();
522
- if (!res.ok) {
523
- let parsed = text;
524
- try {
525
- parsed = JSON.parse(text);
526
- }
527
- catch {
528
- /* keep as text */
529
- }
530
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
531
- ? String(parsed.message)
532
- : typeof parsed === "object" && parsed && "error" in parsed
533
- ? String(parsed.error)
534
- : `HTTP ${res.status}`;
535
- return {
536
- success: false,
537
- error: sanitizeErrorString(errMsg),
538
- status,
539
- errorCode: classify(status),
540
- };
541
- }
542
- if (!text)
543
- return { success: true, data: {}, status };
544
- let data;
545
- try {
546
- data = JSON.parse(text);
547
- }
548
- catch {
549
- if (opts.unwrapInnerSuccess && /success["']?\s*:\s*false/i.test(text)) {
550
- return {
551
- success: false,
552
- error: sanitizeErrorString(`text response indicates success:false — ${text.slice(0, 200)}`),
553
- status,
554
- errorCode: "unknown",
555
- payloadFailed: true,
556
- };
557
- }
558
- return { success: true, data: text, status };
559
- }
560
- if (opts.unwrapInnerSuccess && data && typeof data === "object" && "success" in data) {
561
- const inner = data.success;
562
- if (inner === false) {
563
- const innerErr = data.error !== undefined
564
- ? String(data.error)
565
- : "operation reported success:false in payload";
566
- return {
567
- success: false,
568
- error: sanitizeErrorString(innerErr),
569
- status,
570
- errorCode: "unknown",
571
- payloadFailed: true,
572
- };
573
- }
574
- }
575
- const out = opts.sanitize ? sanitizeSecrets(data) : data;
576
- return { success: true, data: out, status };
577
- }
578
- catch (e) {
579
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
580
- return {
581
- success: false,
582
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
583
- errorCode: "network",
584
- };
585
- }
586
- return {
587
- success: false,
588
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
589
- errorCode: "network",
590
- };
591
- }
847
+ // A3 dedup: the absolute path keeps its own Basic-auth header set (it can't
848
+ // use a Platform because the host may not match WP_BASE) and an IDENTITY
849
+ // parseResponse (no envelope to unwrap). textSuccessFalseGuard:true preserves
850
+ // the path-unique non-JSON `success:false` text guard. The unwrapInnerSuccess
851
+ // JSON check then runs on the identity-passed data, exactly as before.
852
+ return performFetch({
853
+ url,
854
+ headers: {
855
+ Accept: "application/json",
856
+ "Content-Type": "application/json",
857
+ "X-Requested-With": "XMLHttpRequest",
858
+ ...(BASIC_TOKEN ? { Authorization: `Basic ${BASIC_TOKEN}` } : {}),
859
+ ...init.headers,
860
+ },
861
+ init,
862
+ opts,
863
+ parseResponse: (data) => data,
864
+ textSuccessFalseGuard: true,
865
+ });
592
866
  }
593
867
  /**
594
- * Map an `errorCode` to a focused hint string for the caller.
595
- * Falls back to the generic health-tool hint.
868
+ * F83 (2026-06-10, C3 cold-agent): the JMESPath depth-limit failure surfaced
869
+ * on the flow-write path. The PHP backend emits it as a 422 with one of two
870
+ * frozen wordings:
871
+ * - FlowHandler::validateJmesPathDepth → "N JMESPath expression(s) exceeded
872
+ * the depth limit of M"
873
+ * - TransformException → "JMESPath expression too deeply nested (N levels).
874
+ * Maximum: M"
875
+ * Neither carries a body-level `error_code`/`suggestion`, so `classify(422)`
876
+ * lands on "unknown" and the bare-code hint is HEALTH_HINT — the WRONG remedy
877
+ * (a cold agent literally got "Use apimapper_health to check connectivity").
878
+ * The actual cure is the two-transform split: a pipe `|` resets the depth
879
+ * counter. We detect the depth-limit message text (not the coarse code) so the
880
+ * remedy fires regardless of how the wire status classifies. This is the
881
+ * canonical depth remedy — the PHP `TransformException::expressionTooDeepWithRemedy`
882
+ * superset emits the same intent; the MCP-side detection here covers the
883
+ * write-path 422s that arrive without a body-level error_code.
884
+ */
885
+ const DEPTH_LIMIT_MESSAGE_RE = /depth limit|too deeply nested/i;
886
+ export const DEPTH_SPLIT_HINT = "JMESPath depth limit hit. This is NOT a connectivity problem — do not run " +
887
+ "apimapper_health. Multi-stage reshape? Pass `transform` as an ARRAY of stages " +
888
+ "— each stage gets its own depth budget. SPLIT the over-nested expression into " +
889
+ "TWO Transform nodes piped together: a pipe `|` resets the depth counter, so " +
890
+ "each half stays under the cap of 10. Move the inner projection/merge into a " +
891
+ "first Transform, then do the per-row shaping in a second Transform reading its " +
892
+ "output. Iterate each half risk-free with apimapper_jmespath_test before " +
893
+ "wiring, and read the copy-paste recipes via " +
894
+ "apimapper_get_skill({ topic: 'jmespath-cookbook' }) → \"Two-Transform split\".";
895
+ /**
896
+ * Map an `errorCode` (and optional {@link HintContext}) to a focused hint
897
+ * string for the caller. Falls back to the generic health-tool hint only for
898
+ * genuinely connectivity-shaped failures.
899
+ *
900
+ * The second argument is a UNION: a bare `string` is treated as
901
+ * `{ message }` so the ~12 legacy call sites that pass the upstream error
902
+ * string verbatim (`hintFor(code, r.error)`) keep working unchanged, while
903
+ * the richer `{ message, origin }` form drives F112/F113 routing. Omit it to
904
+ * keep the legacy code-only behaviour byte-for-byte.
596
905
  */
597
- export function hintFor(code) {
906
+ export function hintFor(code, context) {
907
+ // Normalise the union: a bare string IS the upstream message.
908
+ const ctx = typeof context === "string" ? { message: context } : context;
909
+ // F83: message-driven depth-limit detection wins over the coarse code, so a
910
+ // 422 that classify()s to "unknown" still gets the split remedy.
911
+ if (ctx?.message && DEPTH_LIMIT_MESSAGE_RE.test(ctx.message)) {
912
+ return DEPTH_SPLIT_HINT;
913
+ }
598
914
  switch (code) {
599
915
  case "auth":
600
- return ("Auth failed (HTTP 401/403). Your MCP key (APIMAPPER_TOKEN) is " +
601
- "invalid, revoked, or expired. Recovery steps: " +
602
- "(1) Open API Mapper menu Settings MCP Access. " +
603
- "(2) Revoke the old key if you suspect it was leaked. " +
604
- "(3) Click 'New API key' and copy the value. " +
605
- "(4) Re-run `npx -y @wootsup/mcp setup` and paste the new key — " +
606
- "the wizard rewrites your AI client config in place. " +
607
- "(5) Restart your AI client (Claude Desktop / Cursor / etc.).");
916
+ // F113: separate upstream-credential auth from MCP-transport auth.
917
+ // A 401/403 carried back from a connection/credential fetch is an
918
+ // EXPIRED UPSTREAM token re-authorise it in place; do NOT send the
919
+ // agent to rotate the (valid) MCP key + re-run the setup wizard.
920
+ if (ctx?.origin === "connection" || ctx?.origin === "credential") {
921
+ return CREDENTIAL_EXPIRED_HINT;
922
+ }
923
+ return MCP_KEY_AUTH_HINT;
608
924
  case "not_found":
609
925
  return "Resource not found. Use the matching `*_list` tool to find a valid id.";
610
926
  case "rate_limit":
611
- return "Rate-limited. Retry after a few seconds; consider apimapper_cache_invalidate to reduce churn.";
927
+ // F59 (2026-06-10): a 429 is often recoverable WITHOUT waiting many
928
+ // providers have a same-schema MIRROR connection (same template, a
929
+ // different upstream serving identical data). Point the agent there.
930
+ return ("Rate-limited (HTTP 429). Retry after a few seconds, and raise " +
931
+ "cache_ttl / use apimapper_cache_invalidate to reduce churn. If the " +
932
+ "throttle persists, run apimapper_connection_list and look for a " +
933
+ "same-schema / mirror connection (same library template or a " +
934
+ "duplicate pointing at an alternative upstream) that serves the same " +
935
+ "data — bind the flow to that mirror instead of the throttled source.");
936
+ case "credential_expired":
937
+ // A11: re-authorize the EXISTING credential in place; never delete +
938
+ // recreate (that drops the OAuth refresh token).
939
+ return ("An OAuth credential expired. Re-authorize it IN PLACE with " +
940
+ "apimapper_oauth_authorize_begin({ credential_id }), open the returned " +
941
+ "authorize_url, complete consent, then retry. Keep the same credential " +
942
+ "— recreating it from scratch drops the refresh token. Use " +
943
+ "apimapper_credential_list to find the credential_id.");
944
+ case "tier_limit_reached":
945
+ // A12: the plan's connection / auth-method limit is hit.
946
+ return ("Your plan's limit was reached (connections or auth methods). " +
947
+ "Upgrade the license to raise the limit, or free a slot by deleting an " +
948
+ "unused connection (apimapper_connection_list to review). Check the " +
949
+ "current tier + limits with apimapper_license_status.");
612
950
  case "server":
613
951
  return "Upstream server error. Try again, then run apimapper_health and check WP error logs.";
614
952
  case "network":
615
953
  return "Network/timeout error. Verify APIMAPPER_WP_BASE is reachable from this host.";
616
954
  default:
617
- return HEALTH_HINT;
955
+ // F112: an `unknown`/config error must NOT blanket-route to the
956
+ // connectivity probe. Route on the message shape when we have one.
957
+ return routeUnknownHint(ctx?.message);
618
958
  }
619
959
  }
960
+ /**
961
+ * F112 — message-pattern router for the `unknown` (config/validation) branch.
962
+ * Returns a hint that points at the ACTUAL fix (fill a field, repair the flow
963
+ * shape, detect a schema) instead of the catch-all HEALTH_HINT. Only a
964
+ * genuinely connectivity-shaped message (or no message at all) keeps the
965
+ * health hint.
966
+ */
967
+ function routeUnknownHint(message) {
968
+ if (!message)
969
+ return HEALTH_HINT;
970
+ const m = message.toLowerCase();
971
+ // Connectivity-shaped failures keep pointing at apimapper_health.
972
+ if (/econnrefused|enotfound|etimedout|fetch failed|network|timeout|unreachable|connection refused/.test(m)) {
973
+ return HEALTH_HINT;
974
+ }
975
+ // Empty/stale schema → detect the schema.
976
+ if (/schema (is )?empty|no schema|empty schema|run detect|detect[- ]schema/.test(m)) {
977
+ return "The source schema is empty — run apimapper_flow_detect_schema (or apimapper_flow_full_recompile_publish) to populate it before binding fields.";
978
+ }
979
+ // Flow-SHAPE errors (wiring / nodes / edges) → inspect the flow graph.
980
+ if (/no (target|source|output) node|target node|source node|\bedges?\b|\bnode\b|connect|wiring|no nodes/.test(m)) {
981
+ return "The flow shape is incomplete (missing node, edge, or target). Run apimapper_flow_get to inspect the graph, then fix the node/edge wiring with apimapper_flow_update.";
982
+ }
983
+ // Missing-field / configure errors → supply the field.
984
+ if (/missing required field|missing field|please configure|required field|is required|required:/.test(m)) {
985
+ return "A required argument is missing — supply the named field(s) and call the tool again. Check the tool's input schema for the exact key(s).";
986
+ }
987
+ // Default: still a config error, not a connectivity one — keep the agent
988
+ // out of the health probe and tell it to recheck its arguments.
989
+ return "Check the tool arguments against its input schema and retry; this is a request/config error, not a connectivity problem.";
990
+ }
620
991
  export const HEALTH_HINT = "Use apimapper_health to check connectivity and auth.";
992
+ /**
993
+ * Recovery hint for an auth failure on the MCP transport itself (the bearer
994
+ * key is wrong/revoked/expired). Kept verbatim for the legacy `hintFor('auth')`
995
+ * contract.
996
+ */
997
+ export const MCP_KEY_AUTH_HINT = "Auth failed (HTTP 401/403). Your MCP key (APIMAPPER_TOKEN) is " +
998
+ "invalid, revoked, or expired. Recovery steps: " +
999
+ "(1) Open API Mapper → ⋮ menu → Settings → MCP Access. " +
1000
+ "(2) Revoke the old key if you suspect it was leaked. " +
1001
+ "(3) Click 'New API key' and copy the value. " +
1002
+ "(4) Re-run `npx -y @wootsup/mcp setup` and paste the new key — " +
1003
+ "the wizard rewrites your AI client config in place. " +
1004
+ "(5) Restart your AI client (Claude Desktop / Cursor / etc.).";
1005
+ /**
1006
+ * F113 — recovery hint for an EXPIRED UPSTREAM credential surfaced as a
1007
+ * connection/credential 401/403. The fix is to re-authorise the credential in
1008
+ * place (preserving the refresh_token), NOT to rotate the MCP key.
1009
+ */
1010
+ export const CREDENTIAL_EXPIRED_HINT = "The upstream API rejected the request (HTTP 401/403) because the " +
1011
+ "connection's credential has expired or was revoked — this is the " +
1012
+ "external service's token, NOT your MCP key. Re-authorise it: call " +
1013
+ "apimapper_oauth_authorize_begin with the connection's credential_id to " +
1014
+ "get a fresh consent URL (this preserves the refresh_token). Do NOT " +
1015
+ "rotate your MCP key for this error.";
621
1016
  export function authConfigured() {
622
1017
  // Bearer token (amk_live_…) is sufficient on its own; legacy App-Password
623
1018
  // auth still requires both user + password.