@wootsup/mcp 0.1.0-rc.9 → 0.3.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 (240) hide show
  1. package/CHANGELOG.md +148 -83
  2. package/README.md +36 -32
  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/cli-hint.d.ts +22 -0
  8. package/dist/cli-hint.js +55 -0
  9. package/dist/cli-hint.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +163 -22
  12. package/dist/index.js.map +1 -1
  13. package/dist/install-skill.js +1 -1
  14. package/dist/modules/apimapper/cache.d.ts +2 -2
  15. package/dist/modules/apimapper/cache.js +119 -29
  16. package/dist/modules/apimapper/cache.js.map +1 -1
  17. package/dist/modules/apimapper/client.d.ts +102 -1
  18. package/dist/modules/apimapper/client.js +631 -297
  19. package/dist/modules/apimapper/client.js.map +1 -1
  20. package/dist/modules/apimapper/connections-format.d.ts +51 -0
  21. package/dist/modules/apimapper/connections-format.js +261 -0
  22. package/dist/modules/apimapper/connections-format.js.map +1 -0
  23. package/dist/modules/apimapper/connections-trim.d.ts +82 -0
  24. package/dist/modules/apimapper/connections-trim.js +224 -0
  25. package/dist/modules/apimapper/connections-trim.js.map +1 -0
  26. package/dist/modules/apimapper/connections.d.ts +14 -2
  27. package/dist/modules/apimapper/connections.js +612 -153
  28. package/dist/modules/apimapper/connections.js.map +1 -1
  29. package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
  30. package/dist/modules/apimapper/credential-sanitizer.js +60 -1
  31. package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
  32. package/dist/modules/apimapper/credentials-format.d.ts +21 -0
  33. package/dist/modules/apimapper/credentials-format.js +145 -0
  34. package/dist/modules/apimapper/credentials-format.js.map +1 -0
  35. package/dist/modules/apimapper/credentials.d.ts +12 -2
  36. package/dist/modules/apimapper/credentials.js +226 -73
  37. package/dist/modules/apimapper/credentials.js.map +1 -1
  38. package/dist/modules/apimapper/diagnose.d.ts +54 -2
  39. package/dist/modules/apimapper/diagnose.js +213 -12
  40. package/dist/modules/apimapper/diagnose.js.map +1 -1
  41. package/dist/modules/apimapper/elicitation.d.ts +54 -0
  42. package/dist/modules/apimapper/elicitation.js +90 -0
  43. package/dist/modules/apimapper/elicitation.js.map +1 -0
  44. package/dist/modules/apimapper/flows-format.d.ts +50 -0
  45. package/dist/modules/apimapper/flows-format.js +318 -0
  46. package/dist/modules/apimapper/flows-format.js.map +1 -0
  47. package/dist/modules/apimapper/flows.d.ts +13 -2
  48. package/dist/modules/apimapper/flows.js +312 -122
  49. package/dist/modules/apimapper/flows.js.map +1 -1
  50. package/dist/modules/apimapper/gateway/advanced-tool.d.ts +9 -0
  51. package/dist/modules/apimapper/gateway/advanced-tool.js +265 -0
  52. package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -0
  53. package/dist/modules/apimapper/gateway/capturing-server.d.ts +81 -0
  54. package/dist/modules/apimapper/gateway/capturing-server.js +87 -0
  55. package/dist/modules/apimapper/gateway/capturing-server.js.map +1 -0
  56. package/dist/modules/apimapper/gateway/essentials.d.ts +4 -0
  57. package/dist/modules/apimapper/gateway/essentials.js +35 -0
  58. package/dist/modules/apimapper/gateway/essentials.js.map +1 -0
  59. package/dist/modules/apimapper/gateway/test-support.d.ts +17 -0
  60. package/dist/modules/apimapper/gateway/test-support.js +43 -0
  61. package/dist/modules/apimapper/gateway/test-support.js.map +1 -0
  62. package/dist/modules/apimapper/get-skill.d.ts +3 -3
  63. package/dist/modules/apimapper/get-skill.js +47 -7
  64. package/dist/modules/apimapper/get-skill.js.map +1 -1
  65. package/dist/modules/apimapper/graph-builder.js +1 -1
  66. package/dist/modules/apimapper/graph-builder.js.map +1 -1
  67. package/dist/modules/apimapper/graph.d.ts +2 -2
  68. package/dist/modules/apimapper/graph.js +170 -35
  69. package/dist/modules/apimapper/graph.js.map +1 -1
  70. package/dist/modules/apimapper/index.d.ts +17 -1
  71. package/dist/modules/apimapper/index.js +68 -17
  72. package/dist/modules/apimapper/index.js.map +1 -1
  73. package/dist/modules/apimapper/inspect.d.ts +3 -2
  74. package/dist/modules/apimapper/inspect.js +97 -13
  75. package/dist/modules/apimapper/inspect.js.map +1 -1
  76. package/dist/modules/apimapper/library.d.ts +2 -2
  77. package/dist/modules/apimapper/library.js +665 -80
  78. package/dist/modules/apimapper/library.js.map +1 -1
  79. package/dist/modules/apimapper/license-format.d.ts +22 -0
  80. package/dist/modules/apimapper/license-format.js +149 -0
  81. package/dist/modules/apimapper/license-format.js.map +1 -0
  82. package/dist/modules/apimapper/license.d.ts +16 -2
  83. package/dist/modules/apimapper/license.js +62 -38
  84. package/dist/modules/apimapper/license.js.map +1 -1
  85. package/dist/modules/apimapper/local-sources.d.ts +2 -2
  86. package/dist/modules/apimapper/local-sources.js +44 -30
  87. package/dist/modules/apimapper/local-sources.js.map +1 -1
  88. package/dist/modules/apimapper/misc.d.ts +30 -2
  89. package/dist/modules/apimapper/misc.js +114 -49
  90. package/dist/modules/apimapper/misc.js.map +1 -1
  91. package/dist/modules/apimapper/node-schema.d.ts +52 -0
  92. package/dist/modules/apimapper/node-schema.js +70 -2
  93. package/dist/modules/apimapper/node-schema.js.map +1 -1
  94. package/dist/modules/apimapper/normalizers.d.ts +1 -0
  95. package/dist/modules/apimapper/normalizers.js +51 -0
  96. package/dist/modules/apimapper/normalizers.js.map +1 -1
  97. package/dist/modules/apimapper/onboarding.d.ts +78 -3
  98. package/dist/modules/apimapper/onboarding.js +428 -26
  99. package/dist/modules/apimapper/onboarding.js.map +1 -1
  100. package/dist/modules/apimapper/read-cache.d.ts +31 -2
  101. package/dist/modules/apimapper/read-cache.js +20 -6
  102. package/dist/modules/apimapper/read-cache.js.map +1 -1
  103. package/dist/modules/apimapper/render/_shared.d.ts +24 -0
  104. package/dist/modules/apimapper/render/_shared.js +84 -0
  105. package/dist/modules/apimapper/render/_shared.js.map +1 -0
  106. package/dist/modules/apimapper/render/dag.d.ts +18 -0
  107. package/dist/modules/apimapper/render/dag.js +70 -0
  108. package/dist/modules/apimapper/render/dag.js.map +1 -0
  109. package/dist/modules/apimapper/render/index.d.ts +2 -0
  110. package/dist/modules/apimapper/render/index.js +112 -0
  111. package/dist/modules/apimapper/render/index.js.map +1 -0
  112. package/dist/modules/apimapper/render/renderers/chart-bar.d.ts +2 -0
  113. package/dist/modules/apimapper/render/renderers/chart-bar.js +70 -0
  114. package/dist/modules/apimapper/render/renderers/chart-bar.js.map +1 -0
  115. package/dist/modules/apimapper/render/renderers/chart-line.d.ts +2 -0
  116. package/dist/modules/apimapper/render/renderers/chart-line.js +71 -0
  117. package/dist/modules/apimapper/render/renderers/chart-line.js.map +1 -0
  118. package/dist/modules/apimapper/render/renderers/diff.d.ts +2 -0
  119. package/dist/modules/apimapper/render/renderers/diff.js +154 -0
  120. package/dist/modules/apimapper/render/renderers/diff.js.map +1 -0
  121. package/dist/modules/apimapper/render/renderers/flow-diagram.d.ts +1 -0
  122. package/dist/modules/apimapper/render/renderers/flow-diagram.js +180 -0
  123. package/dist/modules/apimapper/render/renderers/flow-diagram.js.map +1 -0
  124. package/dist/modules/apimapper/render/renderers/json-tree.d.ts +2 -0
  125. package/dist/modules/apimapper/render/renderers/json-tree.js +87 -0
  126. package/dist/modules/apimapper/render/renderers/json-tree.js.map +1 -0
  127. package/dist/modules/apimapper/render/renderers/schema-diagram.d.ts +2 -0
  128. package/dist/modules/apimapper/render/renderers/schema-diagram.js +83 -0
  129. package/dist/modules/apimapper/render/renderers/schema-diagram.js.map +1 -0
  130. package/dist/modules/apimapper/render/renderers/table.d.ts +2 -0
  131. package/dist/modules/apimapper/render/renderers/table.js +75 -0
  132. package/dist/modules/apimapper/render/renderers/table.js.map +1 -0
  133. package/dist/modules/apimapper/render/schemas.d.ts +23 -0
  134. package/dist/modules/apimapper/render/schemas.js +56 -0
  135. package/dist/modules/apimapper/render/schemas.js.map +1 -0
  136. package/dist/modules/apimapper/render/secret-masking.d.ts +5 -0
  137. package/dist/modules/apimapper/render/secret-masking.js +51 -0
  138. package/dist/modules/apimapper/render/secret-masking.js.map +1 -0
  139. package/dist/modules/apimapper/render/sidecar.d.ts +21 -0
  140. package/dist/modules/apimapper/render/sidecar.js +66 -0
  141. package/dist/modules/apimapper/render/sidecar.js.map +1 -0
  142. package/dist/modules/apimapper/render/token-cap.d.ts +21 -0
  143. package/dist/modules/apimapper/render/token-cap.js +57 -0
  144. package/dist/modules/apimapper/render/token-cap.js.map +1 -0
  145. package/dist/modules/apimapper/schema.d.ts +2 -2
  146. package/dist/modules/apimapper/schema.js +92 -33
  147. package/dist/modules/apimapper/schema.js.map +1 -1
  148. package/dist/modules/apimapper/settings-format.d.ts +23 -0
  149. package/dist/modules/apimapper/settings-format.js +135 -0
  150. package/dist/modules/apimapper/settings-format.js.map +1 -0
  151. package/dist/modules/apimapper/settings.d.ts +2 -2
  152. package/dist/modules/apimapper/settings.js +100 -42
  153. package/dist/modules/apimapper/settings.js.map +1 -1
  154. package/dist/modules/apimapper/sites-tools.d.ts +29 -0
  155. package/dist/modules/apimapper/sites-tools.js +165 -0
  156. package/dist/modules/apimapper/sites-tools.js.map +1 -0
  157. package/dist/modules/apimapper/skill-resources.d.ts +2 -2
  158. package/dist/modules/apimapper/skill-resources.js.map +1 -1
  159. package/dist/modules/apimapper/token-baseline.harness.d.ts +91 -0
  160. package/dist/modules/apimapper/token-baseline.harness.js +291 -0
  161. package/dist/modules/apimapper/token-baseline.harness.js.map +1 -0
  162. package/dist/modules/apimapper/tool-result.d.ts +46 -0
  163. package/dist/modules/apimapper/tool-result.js +63 -0
  164. package/dist/modules/apimapper/tool-result.js.map +1 -0
  165. package/dist/modules/apimapper/toolslist-size.d.ts +56 -0
  166. package/dist/modules/apimapper/toolslist-size.js +192 -0
  167. package/dist/modules/apimapper/toolslist-size.js.map +1 -0
  168. package/dist/modules/apimapper/types.d.ts +44 -8
  169. package/dist/modules/apimapper/types.js +26 -1
  170. package/dist/modules/apimapper/types.js.map +1 -1
  171. package/dist/modules/apimapper/use-profile.d.ts +21 -0
  172. package/dist/modules/apimapper/use-profile.js +56 -2
  173. package/dist/modules/apimapper/use-profile.js.map +1 -1
  174. package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
  175. package/dist/modules/apimapper/whitelist-drift.js +360 -0
  176. package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
  177. package/dist/modules/apimapper/workflows.d.ts +2 -2
  178. package/dist/modules/apimapper/workflows.js +202 -20
  179. package/dist/modules/apimapper/workflows.js.map +1 -1
  180. package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
  181. package/dist/modules/apimapper/yootheme-binding.js +186 -0
  182. package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
  183. package/dist/platform/index.d.ts +56 -0
  184. package/dist/platform/index.js +195 -7
  185. package/dist/platform/index.js.map +1 -1
  186. package/dist/setup/detect-clients.d.ts +40 -1
  187. package/dist/setup/detect-clients.js +148 -1
  188. package/dist/setup/detect-clients.js.map +1 -1
  189. package/dist/setup/probe-handshake.js +40 -7
  190. package/dist/setup/probe-handshake.js.map +1 -1
  191. package/dist/setup/remove-config.d.ts +8 -0
  192. package/dist/setup/remove-config.js +145 -0
  193. package/dist/setup/remove-config.js.map +1 -0
  194. package/dist/setup/uninstall.d.ts +34 -0
  195. package/dist/setup/uninstall.js +147 -0
  196. package/dist/setup/uninstall.js.map +1 -0
  197. package/dist/setup-cli.d.ts +60 -0
  198. package/dist/setup-cli.js +155 -5
  199. package/dist/setup-cli.js.map +1 -1
  200. package/dist/sites/loader.d.ts +41 -0
  201. package/dist/sites/loader.js +119 -0
  202. package/dist/sites/loader.js.map +1 -0
  203. package/dist/sites/schema.d.ts +69 -0
  204. package/dist/sites/schema.js +71 -0
  205. package/dist/sites/schema.js.map +1 -0
  206. package/dist/sites/secret-resolver.d.ts +47 -0
  207. package/dist/sites/secret-resolver.js +150 -0
  208. package/dist/sites/secret-resolver.js.map +1 -0
  209. package/dist/skill-instructions.d.ts +1 -1
  210. package/dist/skill-instructions.js +5 -0
  211. package/dist/skill-instructions.js.map +1 -1
  212. package/dist/transports/stdio.js +4 -4
  213. package/dist/transports/stdio.js.map +1 -1
  214. package/dist/uninstall-skill.d.ts +27 -0
  215. package/dist/uninstall-skill.js +89 -0
  216. package/dist/uninstall-skill.js.map +1 -0
  217. package/docs/architecture.md +22 -22
  218. package/docs/customgraph-internal-migration.md +4 -4
  219. package/docs/security.md +2 -21
  220. package/docs/tools.md +40 -12
  221. package/manifest.json +77 -70
  222. package/package.json +68 -60
  223. package/skills/apimapper/SKILL.md +53 -7
  224. package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
  225. package/skills/apimapper/reference/jmespath-pitfalls.md +108 -0
  226. package/skills/apimapper/reference/joomla.md +1 -1
  227. package/skills/apimapper/reference/library-template-discovery.md +65 -0
  228. package/skills/apimapper/reference/merge-two-sources-on-key.md +99 -0
  229. package/skills/apimapper/reference/render.md +132 -0
  230. package/skills/apimapper/reference/troubleshooting.md +21 -1
  231. package/skills/apimapper/reference/yootheme.md +1 -1
  232. package/dist/auth/oauth-provider.d.ts +0 -68
  233. package/dist/auth/oauth-provider.js +0 -232
  234. package/dist/auth/oauth-provider.js.map +0 -1
  235. package/dist/server-http.d.ts +0 -22
  236. package/dist/server-http.js +0 -159
  237. package/dist/server-http.js.map +0 -1
  238. package/dist/transports/http.d.ts +0 -29
  239. package/dist/transports/http.js +0 -267
  240. package/dist/transports/http.js.map +0 -1
@@ -1,23 +1,128 @@
1
1
  import { z } from "zod";
2
- import { formatResult, autoFormatTable, readOnly, creating, mutating, destructive, errorWithSuggestion, } from "@getimo/mcp-toolkit";
2
+ import { formatResult, tableResult, errorResult, readOnly, creating, mutating, destructive, pickFields, createProgressReporter, elicitChoice, } from "@getimo/mcp-toolkit";
3
3
  import { request, hintFor, WP_BASE, WP_USER, authConfigured } from "./client.js";
4
+ import { restErrorResult } from "./tool-result.js";
5
+ import { toRows } from "./types.js";
4
6
  import { unwrapEntity } from "./envelope.js";
5
- export function registerConnectionTools(server) {
7
+ import { ambiguityFallbackError, } from "./elicitation.js";
8
+ import { fetchWithTimeout } from "./diagnose.js";
9
+ import { filterByNameQuery } from "./node-schema.js";
10
+ import { CONNECTION_TABLE_COLUMNS, CONNECTION_COMPACT_COLUMNS, CONNECTION_LIST_NEXT_STEPS, RESOURCE_TABLE_COLUMNS, mapConnectionRow, compactConnectionRow, mapResourceRow, buildConnectionDetail, buildConnectionTestStats, buildHealthCheckStats, buildHealthStats, } from "./connections-format.js";
11
+ // W3 Stage-2 hardening — applyTrim + extractResourceList live in the
12
+ // sibling connections-trim.ts (pure functions, ~200 lines). Imported for
13
+ // internal use by the connection_data + connection_resources handlers,
14
+ // and re-exported so the public surface
15
+ // `import { ... } from "./connections.js"` stays unchanged for existing
16
+ // callers and the test suite.
17
+ import { applyTrim, extractResourceList, } from "./connections-trim.js";
18
+ export { applyTrim, extractResourceList };
19
+ // F-LS-04 (live-smoke 2026-05-20) — the PHP /connections endpoint emits
20
+ // camelCase keys (authType, credentialId, ...). Every formatter + consumer
21
+ // in this module reads snake_case. The wire-shape normaliser bridges
22
+ // the two so the AI no longer sees auth=none / credential=— on real
23
+ // upstream connections. Applied at the connection_list handler — that is
24
+ // the only tool that fans out raw connection items to a downstream
25
+ // formatter; connection_get / _update / _delete pass through unwrapEntity
26
+ // + the rich-card builder which already reads from the same row map.
27
+ import { normalizeConnectionFromWire } from "./normalizers.js";
28
+ /**
29
+ * F-15 (W1.9) — downstream probe of api.wootsup.com from `apimapper_health`.
30
+ *
31
+ * The customer's WP-REST probe alone cannot distinguish "your server is fine
32
+ * but getimo's license/release infra is degraded" from a generic failure.
33
+ * Hitting the upstream `/health` endpoint surfaces that signal so an AI
34
+ * client can route the user to the right troubleshooting branch.
35
+ */
36
+ const APIMAPPER_API_HEALTH_URL = "https://api.wootsup.com/health";
37
+ /**
38
+ * 3-second budget — health is meant to be a fast snapshot. Shorter than
39
+ * diagnose's 5s because diagnose probes a customer-controlled server (which
40
+ * may legitimately be slow under load), while api.wootsup.com is
41
+ * getimo-controlled CDN-backed infra that should respond in <500ms.
42
+ */
43
+ const APIMAPPER_API_HEALTH_TIMEOUT_MS = 3_000;
44
+ /**
45
+ * F-15: probe api.wootsup.com/health and map the outcome to a stable
46
+ * status-string. NEVER throws — the health tool's contract is to surface
47
+ * downstream failure as data, not to fail itself when the downstream is
48
+ * unreachable. The four return values form a closed enum:
49
+ *
50
+ * - "OK" — 2xx response from api.wootsup.com
51
+ * - "HTTP N" — non-2xx response (e.g. "HTTP 503")
52
+ * - "timeout" — AbortError/TimeoutError after 3s
53
+ * - "unreachable" — any other fetch error (DNS, refused, TLS, etc.)
54
+ *
55
+ * Reuses fetchWithTimeout from diagnose.ts so the AbortSignal.timeout
56
+ * wiring lives in exactly one place (DRY across probe call sites).
57
+ */
58
+ async function probeApimapperApi() {
59
+ try {
60
+ const res = await fetchWithTimeout(APIMAPPER_API_HEALTH_URL, { method: "GET" }, APIMAPPER_API_HEALTH_TIMEOUT_MS);
61
+ if (res.ok)
62
+ return "OK";
63
+ return `HTTP ${res.status}`;
64
+ }
65
+ catch (e) {
66
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
67
+ return "timeout";
68
+ }
69
+ return "unreachable";
70
+ }
71
+ }
72
+ /**
73
+ * Register the connection CRUD + probe + sample + pipeline tools.
74
+ *
75
+ * @param server the tool registrar (essentials forward, rest captured).
76
+ * @param elicitation optional elicitation capability (the real McpServer, or
77
+ * any `{ server: { elicitInput } }`). Supplied only by the host wiring in
78
+ * index.ts. When omitted, `connection_create` falls back to a structured
79
+ * error on genuine credential ambiguity instead of prompting.
80
+ */
81
+ export function registerConnectionTools(server, elicitation) {
6
82
  // ── apimapper_health ───────────────────────────────────────────────
7
83
  server.registerTool("apimapper_health", {
8
84
  title: "API Mapper REST Health",
9
85
  description: "Check connectivity + auth against the API Mapper REST namespace. " +
10
- "Returns {wp_base, wp_user, auth, connectivity, connection_count, http_status}. " +
86
+ "Returns {wp_base, wp_user, auth, connectivity, connection_count, http_status, apimapper_api_status}. " +
87
+ "`apimapper_api_status` reports the downstream api.wootsup.com health (OK/HTTP N/timeout/unreachable) " +
88
+ "so a degradation of getimo infra is distinguishable from a customer-server issue. " +
11
89
  "Run this first if any other tool fails." +
12
90
  "\n\nExample:\n apimapper_health({})",
13
91
  inputSchema: {},
14
- annotations: readOnly(),
92
+ annotations: readOnly({ title: "Health Check", openWorld: true }),
15
93
  }, async () => {
16
94
  const checks = {
17
95
  wp_base: WP_BASE,
18
96
  wp_user: WP_USER,
19
97
  auth: authConfigured() ? "configured" : "MISSING (set APIMAPPER_WP_APP_PASS)",
20
98
  };
99
+ // L-5 (W3F-5): when the customer uses bearer-only auth (the modern
100
+ // `amk_live_…` flow), APIMAPPER_WP_USER is intentionally unset, so
101
+ // the previous render emitted `WP user: ""`. Probe `/identity` and
102
+ // fall back to its `.username` field — it's anon-readable on both
103
+ // platforms and never throws. The probe is cheap (single GET) and
104
+ // runs alongside the existing api.wootsup.com probe.
105
+ if (checks.wp_user === "") {
106
+ try {
107
+ const idRes = await request("/identity");
108
+ const fromIdentity = idRes.success && typeof idRes.data?.username === "string"
109
+ ? idRes.data.username
110
+ : "";
111
+ if (fromIdentity !== "") {
112
+ checks.wp_user = fromIdentity;
113
+ }
114
+ }
115
+ catch {
116
+ // Identity probe failure is non-fatal — the rest of the
117
+ // health snapshot still surfaces.
118
+ }
119
+ }
120
+ // F-15 (W1.9) — downstream api.wootsup.com probe. Runs before the
121
+ // WP-REST check; both are independent, but sequential is cheap (3s
122
+ // worst-case for the api probe, then the WP probe) and keeps the
123
+ // result ordering deterministic. Probe never throws — surfaces as
124
+ // status string per probeApimapperApi contract.
125
+ checks.apimapper_api_status = await probeApimapperApi();
21
126
  try {
22
127
  const r = await request("/connections");
23
128
  checks.http_status = r.status ?? 0;
@@ -32,92 +137,138 @@ export function registerConnectionTools(server) {
32
137
  }
33
138
  }
34
139
  catch (e) {
35
- const err = errorWithSuggestion(e, { healthTool: "apimapper_health" });
36
- checks.connectivity = `ERROR: ${err.message}`;
140
+ // A4 (Wave-B audit, 2026-06-03): this catch builds a STRING into the
141
+ // health-snapshot `checks` map and then continues to buildHealthStats —
142
+ // it is NOT an MCP error-result return, so the structured
143
+ // errorResult/restErrorResult builders do not apply here (they would
144
+ // early-return and abandon the snapshot). The lone `errorWithSuggestion`
145
+ // call is dropped in favour of the same raw, already-sanitised message
146
+ // the sibling `FAIL: ${r.error}` branch above surfaces — `request()`
147
+ // sanitises and never throws, so this remains a defensive guard.
148
+ const message = e instanceof Error ? e.message : String(e);
149
+ checks.connectivity = `ERROR: ${message}`;
37
150
  }
38
- return formatResult(checks, false, { maxChars: 2000 });
151
+ // W3.1 health snapshot is a flat scalar set: statsResult is the
152
+ // goldstandard fit. Every check becomes a stat so an AI client reads
153
+ // connectivity/auth/downstream state without parsing free text.
154
+ return buildHealthStats(checks);
39
155
  });
40
156
  // ── apimapper_connection_list ──────────────────────────────────────
41
157
  server.registerTool("apimapper_connection_list", {
42
158
  title: "List Connections",
43
- description: "List all API Mapper connections. Use apimapper_connection_get for full details." +
44
- "\n\nExample:\n apimapper_connection_list({ source: 'user', limit: 25 })",
159
+ // rc.10 A5 (2026-05-19) description states the default limit
160
+ // already covers a typical install. The Maria-walkthrough log
161
+ // showed AI defaulting to limit:200/500 reflexively.
162
+ description: "List all API Mapper connections. The default limit of 50 already covers a typical install — " +
163
+ "most customers run with 5-25 connections, increase the limit only if you have a specific " +
164
+ "reason to expect more. Use apimapper_connection_get for full details of a single connection." +
165
+ "\n\nExample:\n apimapper_connection_list({}) // returns up to 50 connections in one call\n" +
166
+ " apimapper_connection_list({ source: 'user' }) // narrow to user-created only",
45
167
  inputSchema: {
46
168
  source: z
47
169
  .enum(["library", "demo", "user", "all"])
48
170
  .default("all")
49
171
  .describe("Filter by origin"),
50
- limit: z.number().min(1).max(500).default(50).describe("Max items (1-500)"),
172
+ limit: z.number().min(1).max(500).default(50).describe("Max items (1-500). Default 50 already covers a typical install — do not raise unless you have evidence the list is larger."),
173
+ // W1.18 (F-32) — case-insensitive substring filter applied
174
+ // in-memory AFTER the upstream fetch (no per-name REST call).
175
+ // Min 2 chars at the schema boundary blocks the single-char
176
+ // probe-thrash pattern observed in the Maria-walkthrough logs.
177
+ name_query: z
178
+ .string()
179
+ .min(2)
180
+ .optional()
181
+ .describe("Case-insensitive substring filter on connection name. Applied in-memory after the upstream fetch — does NOT change the REST query. Must be 2+ characters; single-char probes are blocked. Combine with `source` for narrower results."),
51
182
  },
52
- annotations: readOnly(),
53
- }, async ({ source, limit }) => {
183
+ annotations: readOnly({ title: "List Connections", openWorld: true }),
184
+ }, async ({ source, limit, name_query }) => {
54
185
  const r = await request("/connections");
55
186
  if (!r.success) {
56
- return formatResult({
57
- error: r.error,
58
- status: r.status,
59
- errorCode: r.errorCode,
60
- context: { source, limit },
61
- hint: hintFor(r.errorCode),
62
- }, true);
187
+ return restErrorResult(r, { source, limit, name_query }, { message: "connection list failed" });
63
188
  }
64
- let items = Array.isArray(r.data?.connections) ? r.data.connections : [];
189
+ // F-LS-04 normalise the camelCase wire shape (authType, credentialId,
190
+ // …) to snake_case BEFORE anything downstream reads it. The
191
+ // `Connection` domain type uses snake_case keys, so the result is
192
+ // safely cast back: `normalizeConnectionFromWire` only adds keys, it
193
+ // never drops them, and existing snake_case values win on collision.
194
+ const rawItems = Array.isArray(r.data?.connections) ? r.data.connections : [];
195
+ let items = rawItems.map((c) => normalizeConnectionFromWire(c));
65
196
  if (source !== "all")
66
197
  items = items.filter((c) => c.source === source);
198
+ // W1.18 (F-32) — filter BEFORE slice so limit applies to the matched
199
+ // subset, not the haystack. See `filterByNameQuery` JSDoc for the
200
+ // case-folding + undefined-skip contract shared with flow_list.
201
+ items = filterByNameQuery(items, name_query);
67
202
  items = items.slice(0, limit);
68
- return autoFormatTable(items.map((c) => ({
69
- id: c.id,
70
- name: c.name,
71
- source: c.source,
72
- endpoint: c.endpoint || "(template)",
73
- method: c.method || "GET",
74
- auth_type: c.auth_type || "none",
75
- credential_id: c.credential_id || "—",
76
- })), {
77
- columns: [
78
- // Width 36 accommodates 30+ char IDs (e.g. legacy template
79
- // connections like conn_calendly_jla_229fcbce95b7). Truncation
80
- // produces orphan IDs that downstream tools fail to resolve
81
- // via *_get; +6 headroom matches the longest historical IDs.
82
- { key: "id", label: "ID", width: 36 },
83
- { key: "name", label: "NAME", width: 28 },
84
- { key: "source", label: "SRC", width: 8 },
85
- { key: "endpoint", label: "ENDPOINT", width: 30 },
86
- { key: "method", label: "M", width: 5 },
87
- { key: "auth_type", label: "AUTH", width: 12 },
88
- { key: "credential_id", label: "CRED", width: 26 },
89
- ],
203
+ // W3.1 tableResult: ASCII table for the LLM + typed DataTable payload
204
+ // for the Rich Card. T1 (W3.2): explicit compactColumns/compactMap drop
205
+ // ENDPOINT/METHOD/CRED at 21+ rows. IA-7: id stays llmOnly. IA-10: the
206
+ // footer carries the next-step guidance.
207
+ return tableResult(toRows(items), {
208
+ columns: CONNECTION_TABLE_COLUMNS,
209
+ compactColumns: CONNECTION_COMPACT_COLUMNS,
210
+ map: mapConnectionRow,
211
+ compactMap: compactConnectionRow,
90
212
  header: (n) => `${n} connections`,
91
- footer: "Use apimapper_connection_get <id> for full details.",
213
+ footer: CONNECTION_LIST_NEXT_STEPS,
92
214
  });
93
215
  });
94
216
  // ── apimapper_connection_get ───────────────────────────────────────
95
217
  server.registerTool("apimapper_connection_get", {
96
218
  title: "Get Connection",
97
- description: "Fetch full configuration of a single connection by ID." +
219
+ description: "Get the full configuration of one connection by ID (endpoints, auth/" +
220
+ "credential link, default params + headers, items_path, pipeline). Use to " +
221
+ "inspect or debug a single connection before editing it or wiring it into a flow. " +
222
+ "Keywords: get connection, connection detail, inspect connection, show config, by ID. " +
223
+ "When NOT to use: to enumerate all connections use apimapper_connection_list; to " +
224
+ "fetch sample rows use apimapper_connection_data; to probe reachability/auth use " +
225
+ "apimapper_connection_test; to change fields use apimapper_connection_update." +
98
226
  "\n\nExample:\n apimapper_connection_get({ id: 'con_abc123' })",
99
227
  inputSchema: {
100
228
  id: z.string().describe('Connection ID (e.g., "conn_Mz33OVPF1z3ap8fbbQtpx"). Use apimapper_connection_list.'),
101
229
  },
102
- annotations: readOnly(),
230
+ annotations: readOnly({ title: "Get Connection", openWorld: true }),
103
231
  }, async ({ id }) => {
104
232
  // PHP wraps via fromControllerResponse($response, 'connection') →
105
233
  // `{success:true, connection:{…}}`. Unwrap defensively so a future
106
234
  // flatten on the PHP side doesn't break us. Audit: F-A1-01.
107
- const r = await request(`/connections/${encodeURIComponent(id)}`);
235
+ // S-MED-1 (Wave-B 2026-06-03): sanitize the connection read. The
236
+ // Connection shape carries `headers`/`params` arrays that can hold an
237
+ // inline Authorization / X-API-Key value; {sanitize:true} runs the
238
+ // response through sanitizeSecrets (which now also scrubs {name,value}
239
+ // header pairs) so the SECURITY.md "every response is sanitized"
240
+ // guarantee holds structurally, not only by buildConnectionDetail's
241
+ // whitelist curation.
242
+ const r = await request(`/connections/${encodeURIComponent(id)}`, {}, { sanitize: true });
108
243
  if (!r.success) {
109
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
244
+ return restErrorResult(r, { id }, { message: "connection get failed" });
110
245
  }
111
246
  const conn = unwrapEntity(r.data, "connection");
112
247
  if (!conn || Object.keys(conn).length === 0) {
113
- return formatResult({ error: "connection not found", status: r.status, context: { id }, hint: hintFor("not_found") }, true);
248
+ return errorResult({
249
+ message: "connection not found",
250
+ code: r.status ? String(r.status) : "not_found",
251
+ suggestion: hintFor("not_found"),
252
+ details: { id },
253
+ });
114
254
  }
115
- return formatResult(conn, false, { maxChars: 4000 });
255
+ // W3.1 detailResult: grouped key-value detail for the Rich Card.
256
+ // IA-7: opaque IDs are copyable code entries. IA-10: a dedicated
257
+ // "Next steps" group carries the follow-up calls.
258
+ return buildConnectionDetail(id, conn);
116
259
  });
117
260
  // ── apimapper_connection_create ────────────────────────────────────
118
261
  server.registerTool("apimapper_connection_create", {
119
262
  title: "Create Connection",
120
- description: "Create a new connection (custom not from library)." +
263
+ description: "Create a custom connection. **Use ONLY when no library template matches your target API.** " +
264
+ "First call `apimapper_library_featured()` or `apimapper_library_list({ query: '<api-name>' })` — " +
265
+ "Google Sheets, Calendly, Notion, Airtable, GitHub, Pexels, Unsplash, OpenWeatherMap, REST Countries, " +
266
+ "Google Drive/Docs/Slides/Tasks all have ready-to-use templates with auto-configured auth and " +
267
+ "field-detection. Library activation via `apimapper_library_activate({ id })` is the canonical " +
268
+ "customer path; connection_create is the fallback for niche or unknown APIs. " +
269
+ "The server enforces this: a custom create on an API already covered by a curated template is " +
270
+ "blocked with a 409 naming the template to activate — pass `acknowledge_no_library: true` to " +
271
+ "intentionally override that block." +
121
272
  "\n\nExample:\n apimapper_connection_create({ name: 'Pexels API', endpoint: 'https://api.pexels.com/v1/search', method: 'GET', auth_type: 'bearer', items_path: 'photos' })",
122
273
  inputSchema: {
123
274
  name: z.string().min(1).describe('Connection name (e.g., "My Blog API")'),
@@ -128,30 +279,117 @@ export function registerConnectionTools(server) {
128
279
  items_path: z.string().optional().describe('JSONPath to items array (snake_case wire key)'),
129
280
  cache_ttl: z.number().int().min(0).default(3600).describe("Cache TTL in seconds (snake_case wire key)"),
130
281
  description: z.string().optional().describe("Free-text description"),
282
+ // Phase 6 — audited override for the server-side library-first guard.
283
+ // The PHP guard blocks custom creates on APIs that already have a
284
+ // curated library template (returning a 409 whose message names the
285
+ // template via apimapper_library_activate). Setting this flag relays
286
+ // `acknowledge_no_library: true` into the POST body so the guard lets
287
+ // the create through. Flows into the body via `resolvedInput = {...input}`.
288
+ acknowledge_no_library: z.boolean().optional().describe("Set true ONLY when no library template fits and you intentionally need a custom connection. " +
289
+ "The server blocks custom creates on APIs that already have a curated library template; " +
290
+ "this flag is the audited override. Prefer apimapper_library_activate({ id }) when a template exists."),
131
291
  },
132
- annotations: creating(),
292
+ annotations: creating({ title: "Create Connection", openWorld: true }),
133
293
  }, async (input) => {
294
+ // W3.6 — when the connection's auth_type needs a credential and none is
295
+ // given: exactly 1 stored credential → auto-pick; >1 → elicitChoice;
296
+ // elicitChoice null (unsupported client / declined) → structured
297
+ // candidate-list error. auth_type "none" never needs a credential.
298
+ let resolvedInput = { ...input };
299
+ if (input.auth_type !== "none" && !input.credential_id) {
300
+ const lr = await request("/credentials", {}, { sanitize: true });
301
+ if (!lr.success) {
302
+ return restErrorResult(lr, { name: input.name, auth_type: input.auth_type }, { message: "credential lookup failed" });
303
+ }
304
+ const creds = Array.isArray(lr.data?.credentials) ? lr.data.credentials : [];
305
+ if (creds.length === 0) {
306
+ return errorResult({
307
+ message: `auth_type "${input.auth_type}" needs a credential, but none are stored.`,
308
+ code: "credential_not_found",
309
+ suggestion: "Create one with apimapper_credential_create, then retry with its credential_id.",
310
+ details: { name: input.name, auth_type: input.auth_type },
311
+ });
312
+ }
313
+ if (creds.length === 1) {
314
+ resolvedInput = { ...input, credential_id: creds[0].id };
315
+ }
316
+ else {
317
+ const candidates = creds.map((c) => ({
318
+ id: c.id,
319
+ label: c.name,
320
+ }));
321
+ const picked = elicitation
322
+ ? await elicitChoice(elicitation, `The "${input.name}" connection (auth_type: ${input.auth_type}) needs a ` +
323
+ "credential. Pick which stored credential to use.", candidates.map((c) => c.id))
324
+ : null;
325
+ if (picked === null) {
326
+ return ambiguityFallbackError({
327
+ code: "credential_ambiguous",
328
+ paramName: "credential_id",
329
+ what: "credentials",
330
+ candidates,
331
+ extraDetails: { name: input.name, auth_type: input.auth_type },
332
+ });
333
+ }
334
+ resolvedInput = { ...input, credential_id: picked };
335
+ }
336
+ }
134
337
  // PHP fromControllerResponse(_, 'connection', 201) → {success, connection:{…}}.
135
338
  // Audit: F-A1-02.
136
339
  const r = await request("/connections", {
137
340
  method: "POST",
138
- body: JSON.stringify(input),
341
+ body: JSON.stringify(resolvedInput),
139
342
  });
140
343
  if (!r.success) {
141
- return formatResult({
142
- error: r.error,
143
- status: r.status,
144
- errorCode: r.errorCode,
145
- context: { name: input.name, endpoint: input.endpoint },
146
- hint: hintFor(r.errorCode),
147
- }, true);
344
+ // M1 (MCP-relay audit) — wire-shape parity with the admin-ui. On the
345
+ // library-first guard block the PHP REST body carries a structured
346
+ // `{error, error_code:'library_template_available', library_suggestion:
347
+ // {matched_host, templates[], activate_call}}`. The admin-ui consumes
348
+ // that object; the MCP relay must too. We surface the structured
349
+ // `library_suggestion` + `error_code` in `details` (→ machine-readable
350
+ // _meta.ui.payload.details) IN ADDITION to keeping the actionable
351
+ // `message` string verbatim — the message embeds the activate call so
352
+ // a text-only agent still recovers. `errorBody` is the parsed non-2xx
353
+ // body threaded through the shared client (see client.ts request()).
354
+ const errorBody = r.errorBody;
355
+ const isLibraryBlock = errorBody !== undefined &&
356
+ errorBody.error_code === "library_template_available";
357
+ return restErrorResult(r,
358
+ // Per-site nuance: on a library-first guard block (409 / Joomla
359
+ // com_ajax) the structured `error_code` + `library_suggestion` from
360
+ // the parsed body are surfaced in details IN ADDITION to the verbatim
361
+ // message string. Non-library failures keep the plain identity echo.
362
+ isLibraryBlock
363
+ ? {
364
+ name: input.name,
365
+ endpoint: input.endpoint,
366
+ error_code: errorBody.error_code,
367
+ library_suggestion: errorBody.library_suggestion,
368
+ }
369
+ : { name: input.name, endpoint: input.endpoint }, { message: "connection create failed" });
148
370
  }
149
371
  const conn = unwrapEntity(r.data, "connection");
372
+ // F-3.2 (Pass-1 consolidation) — mutating-tool payload-shape rationale.
373
+ //
374
+ // connection_create + connection_update stay on the flat
375
+ // `formatResult({ ... })` shape rather than migrating to the
376
+ // structured detailResult / actionResult builders. The mutating-tool
377
+ // payload here is intentionally minimal (the wire echo confirms the
378
+ // op + identity), and the detail builders are tuned for fully
379
+ // populated entities (badge/group/entry layouts) — wrapping a 3-field
380
+ // confirmation in a Rich Card would add noise without surfacing more
381
+ // information. This mirrors the connection_data passthrough rationale:
382
+ // the LLM benefits from the bare structured echo; UI hosts that want
383
+ // a richer view re-fetch via connection_get. IA-10 uniformity is
384
+ // restored via `next_steps[]` (array, single-element) below.
150
385
  return formatResult({
151
386
  created: true,
152
387
  id: conn?.id,
153
388
  name: conn?.name,
154
389
  source: conn?.source,
390
+ next_steps: [
391
+ "Use apimapper_connection_test to verify reachability, then apimapper_flow_create to wire it into a flow.",
392
+ ],
155
393
  }, false, { maxChars: 2000 });
156
394
  });
157
395
  // ── apimapper_connection_update ────────────────────────────────────
@@ -165,9 +403,16 @@ export function registerConnectionTools(server) {
165
403
  .record(z.string(), z.unknown())
166
404
  .describe('Fields to update — snake_case keys. ' +
167
405
  'Examples: {"name":"Renamed"}, {"cache_ttl":7200}, {"endpoint":"/v2/posts"}, ' +
168
- '{"credential_id":"cred_xxx"}'),
406
+ '{"credential_id":"cred_xxx"}. ' +
407
+ // DATA-LOW (Wave-B 2026-06-03): kept as an open record on purpose.
408
+ // The settable column set is large and may widen server-side; the
409
+ // PHP backend column-whitelists on write (Wave A), so any unknown
410
+ // key is dropped server-side rather than persisted. Enumerating a
411
+ // .strict() allow-list here risks rejecting a legit field the
412
+ // server accepts, so the schema stays permissive.
413
+ 'Unknown keys are dropped by the server (column-whitelisted on write).'),
169
414
  },
170
- annotations: mutating(),
415
+ annotations: mutating({ title: "Update Connection", openWorld: true }),
171
416
  }, async ({ id, patch }) => {
172
417
  // PHP fromControllerResponse(_, 'connection') → {success, connection:{…}}.
173
418
  // Audit: F-A1-03.
@@ -176,10 +421,123 @@ export function registerConnectionTools(server) {
176
421
  body: JSON.stringify(patch),
177
422
  });
178
423
  if (!r.success) {
179
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
424
+ return restErrorResult(r, { id }, { message: "connection update failed" });
180
425
  }
181
426
  const conn = unwrapEntity(r.data, "connection");
182
- return formatResult({ updated: true, id: conn?.id, name: conn?.name }, false, { maxChars: 2000 });
427
+ // F-3.2 (Pass-1 consolidation) see connection_create for the
428
+ // mutating-tool payload-shape rationale. next_steps[] restored for
429
+ // IA-10 uniformity (single-element array, not flat `next:`).
430
+ return formatResult({
431
+ updated: true,
432
+ id: conn?.id,
433
+ name: conn?.name,
434
+ next_steps: [
435
+ "Re-run apimapper_connection_test if endpoint/auth changed; flows referencing this connection may need to be republished.",
436
+ ],
437
+ }, false, { maxChars: 2000 });
438
+ });
439
+ // ── apimapper_connection_recover ───────────────────────────────────
440
+ // Friction-6 fix (Task #46, 2026-05-29) — recover from a connection whose
441
+ // `params` field was persisted as a corrupt JSON-string holding UI metadata
442
+ // blobs (resource_picker, sheets_range, ...) instead of a proper key-value
443
+ // object. Pre-fix: connection_update rejected the patch because the existing
444
+ // shape failed downstream validation; the connection was un-editable.
445
+ //
446
+ // This tool issues a forgiving PUT that resets `params` to {} (and optionally
447
+ // sets a clean replacement via `params_replacement`), letting the customer
448
+ // / AI proceed with a fresh configuration. It also clears any extra cruft
449
+ // keys the AI agent identifies via `clear_fields`.
450
+ server.registerTool("apimapper_connection_recover", {
451
+ title: "Recover Stuck Connection",
452
+ description: "Recover a connection that is un-editable because its `params` field " +
453
+ "is corrupt (e.g. stored as a JSON-string with embedded UI metadata " +
454
+ "instead of an object). Resets `params` to an empty object — or to " +
455
+ "the value of `params_replacement` if supplied. Optionally clears " +
456
+ "additional fields named in `clear_fields`." +
457
+ "\n\nUse when: `apimapper_connection_data` returns 'Please select an " +
458
+ "endpoint' AND `apimapper_connection_update` rejects subsequent " +
459
+ "patches with a validation error about params shape." +
460
+ "\n\nExample:\n apimapper_connection_recover({ id: 'con_abc123' })" +
461
+ "\n apimapper_connection_recover({ id: 'con_abc123', params_replacement: { per_page: '20' }, endpoint: 'values' })",
462
+ inputSchema: {
463
+ id: z
464
+ .string()
465
+ .describe("Connection ID. Use apimapper_connection_list to find."),
466
+ params_replacement: z
467
+ .record(z.string(), z.unknown())
468
+ .optional()
469
+ .describe("Clean replacement for params (key-value object). Default: {} (empty). " +
470
+ // DATA-LOW (Wave-B 2026-06-03): open record by design — the value
471
+ // becomes the connection's `params` column, which the PHP backend
472
+ // column-whitelists on write (Wave A). Free-form key-value pairs
473
+ // are the intended shape here, so no .strict() allow-list applies.
474
+ "Free-form key-value pairs; the server whitelists the persisted columns."),
475
+ endpoint: z
476
+ .string()
477
+ .optional()
478
+ .describe("If the connection has no endpoint configured, set it here in the " +
479
+ "same call (e.g., 'values' for Google Sheets, 'Search' for Pexels). " +
480
+ "Use apimapper_advanced({tool:'apimapper_connection_get'}) to see " +
481
+ "available endpoints[]."),
482
+ clear_fields: z
483
+ .array(z.string().min(1))
484
+ .max(20)
485
+ .optional()
486
+ .describe("Additional fields to clear (set to null). Example: ['template_fields']."),
487
+ confirm: z
488
+ .boolean()
489
+ .default(false)
490
+ .describe("Must be true to execute. On confirm:false, returns a preview of " +
491
+ "the planned reset. Ask user to confirm first."),
492
+ },
493
+ annotations: mutating({ title: "Recover Stuck Connection", openWorld: true }),
494
+ }, async ({ id, params_replacement, endpoint, clear_fields, confirm }) => {
495
+ const patch = {
496
+ params: params_replacement ?? {},
497
+ };
498
+ if (endpoint !== undefined)
499
+ patch.endpoint = endpoint;
500
+ if (Array.isArray(clear_fields)) {
501
+ for (const field of clear_fields) {
502
+ patch[field] = null;
503
+ }
504
+ }
505
+ if (!confirm) {
506
+ return formatResult({
507
+ preview: true,
508
+ notice: "RECOVER — Connection will be patched with the reset shown below. " +
509
+ "This is a forgiving reset path; existing `params` corrupted shape " +
510
+ "will be overwritten.",
511
+ target: { id },
512
+ patch_preview: patch,
513
+ instruction: "Ask user to confirm, then call again with confirm: true.",
514
+ });
515
+ }
516
+ const r = await request(`/connections/${encodeURIComponent(id)}`, {
517
+ method: "PUT",
518
+ body: JSON.stringify(patch),
519
+ });
520
+ if (!r.success) {
521
+ return restErrorResult(r, { id, patch_keys: Object.keys(patch) }, {
522
+ message: "connection recover failed",
523
+ // Per-site nuance: keep the original `hintFor(...) ?? <deep-corrupt
524
+ // fallback>` suggestion verbatim (hintFor always returns a string,
525
+ // so the `??` arm is a defensive no-op preserved for parity).
526
+ suggestion: hintFor(r.errorCode) ??
527
+ "If recover still fails, the connection row may be deeper-corrupt — " +
528
+ "delete it via apimapper_connection_delete and re-activate the library template.",
529
+ });
530
+ }
531
+ const conn = unwrapEntity(r.data, "connection");
532
+ return formatResult({
533
+ recovered: true,
534
+ id: conn?.id ?? id,
535
+ name: conn?.name,
536
+ applied_patch_keys: Object.keys(patch),
537
+ next_steps: [
538
+ "Verify with apimapper_connection_get; re-run apimapper_connection_test before re-publishing flows.",
539
+ ],
540
+ }, false, { maxChars: 2000 });
183
541
  });
184
542
  // ── apimapper_connection_delete ────────────────────────────────────
185
543
  server.registerTool("apimapper_connection_delete", {
@@ -194,7 +552,7 @@ export function registerConnectionTools(server) {
194
552
  .default(false)
195
553
  .describe("Must be true to execute. On confirm:false, returns a preview."),
196
554
  },
197
- annotations: destructive(),
555
+ annotations: destructive({ title: "Delete Connection", openWorld: true }),
198
556
  }, async ({ id, confirm }) => {
199
557
  if (!confirm) {
200
558
  // PHP fromControllerResponse(_, 'connection') wraps as
@@ -218,7 +576,7 @@ export function registerConnectionTools(server) {
218
576
  }
219
577
  const r = await request(`/connections/${encodeURIComponent(id)}`, { method: "DELETE" });
220
578
  if (!r.success) {
221
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
579
+ return restErrorResult(r, { id }, { message: "connection delete failed" });
222
580
  }
223
581
  return formatResult({ deleted: true, id }, false, { maxChars: 1500 });
224
582
  });
@@ -231,33 +589,30 @@ export function registerConnectionTools(server) {
231
589
  inputSchema: {
232
590
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
233
591
  },
234
- annotations: readOnly(),
592
+ annotations: readOnly({ title: "Test Connection", openWorld: true }),
235
593
  }, async ({ id }) => {
594
+ // PHP wraps probe fields one level deeper:
595
+ // {success:true, data:{actionType, http_code, duration_ms, body_preview, …}, items_count}
596
+ // unwrapInnerSuccess sees outer success:true and lets it through, but
597
+ // probe fields still live under `data.data.*`. Audit: F-A1-05.
236
598
  const r = await request("/connections/test", { method: "POST", body: JSON.stringify({ connection_id: id }) }, { unwrapInnerSuccess: true });
237
599
  if (!r.success) {
238
- return formatResult({
239
- error: r.error,
240
- status: r.status,
241
- errorCode: r.errorCode,
242
- payloadFailed: r.payloadFailed,
243
- context: { id },
244
- hint: r.payloadFailed
600
+ return restErrorResult(r, { id, payloadFailed: r.payloadFailed }, {
601
+ message: "connection test failed",
602
+ // Per-site nuance: a 200 + success:false (payloadFailed) probe gets
603
+ // an upstream-status hint; everything else falls back to hintFor().
604
+ suggestion: r.payloadFailed
245
605
  ? "Probe ran but reported failure — check the upstream API status, credentials, or connection endpoint."
246
606
  : hintFor(r.errorCode),
247
- }, true);
607
+ });
248
608
  }
249
609
  // Defensive unwrap: prefer `r.data.data` if present, fall back to `r.data` itself
250
610
  // so a future PHP flatten doesn't break this tool.
251
611
  const outer = (r.data && typeof r.data === "object" ? r.data : {});
252
612
  const probe = (outer.data && typeof outer.data === "object" ? outer.data : outer);
253
- return formatResult({
254
- ok: true,
255
- id,
256
- actionType: probe.actionType ?? "ok",
257
- http_code: probe.http_code,
258
- duration_ms: probe.duration_ms,
259
- body_preview: probe.body_preview?.slice(0, 500),
260
- }, false, { maxChars: 2500 });
613
+ // W3.1 — statsResult: the probe outcome is a small flat scalar set.
614
+ // IA-10: the follow-up guidance rides in the stats description.
615
+ return buildConnectionTestStats(id, probe);
261
616
  });
262
617
  // ── apimapper_connection_health_check ──────────────────────────────
263
618
  server.registerTool("apimapper_connection_health_check", {
@@ -271,20 +626,19 @@ export function registerConnectionTools(server) {
271
626
  .default([])
272
627
  .describe('Connection IDs to probe (empty = all). REST wire-key: connection_ids.'),
273
628
  },
274
- annotations: readOnly(),
275
- }, async ({ connection_ids }) => {
629
+ annotations: readOnly({ title: "Connection Health Check", openWorld: true }),
630
+ }, async ({ connection_ids }, extra) => {
631
+ // W3.5 — coarse progress side-channel. `null` when the caller sent no
632
+ // progressToken; `progress?.report(...)` then no-ops. The batch probe
633
+ // can take 10-30s, so a start/done signal keeps the caller informed.
634
+ const progress = extra ? createProgressReporter(extra) : null;
635
+ await progress?.report(0, 1, "Probing connections…");
276
636
  const r = await request("/connections/health-check", {
277
637
  method: "POST",
278
638
  body: JSON.stringify({ connection_ids }),
279
639
  });
280
640
  if (!r.success) {
281
- return formatResult({
282
- error: r.error,
283
- status: r.status,
284
- errorCode: r.errorCode,
285
- context: { count: connection_ids.length },
286
- hint: hintFor(r.errorCode),
287
- }, true);
641
+ return restErrorResult(r, { count: connection_ids.length }, { message: "connection health check failed" });
288
642
  }
289
643
  const data = (r.data && typeof r.data === "object") ? r.data : {};
290
644
  // PHP returns map keyed by connection id, NOT under a 'results' key —
@@ -297,71 +651,165 @@ export function registerConnectionTools(server) {
297
651
  const o = x;
298
652
  return o.success === true || o.actionType === "ok";
299
653
  }).length;
300
- return formatResult({
301
- ok_count: okCount,
302
- fail_count: results.length - okCount,
303
- total: results.length,
304
- results: data,
305
- }, false, { maxChars: 6000 });
654
+ await progress?.report(1, 1, `Probed ${results.length} connection(s)`);
655
+ // W3.1 — statsResult: the batch outcome is a counts dashboard.
656
+ // IA-10: the description routes the AI to connection_test for the
657
+ // per-connection probe detail (the full result map is too large for
658
+ // a stat grid).
659
+ return buildHealthCheckStats(okCount, results.length - okCount, results.length);
306
660
  });
307
661
  // ── apimapper_connection_data ──────────────────────────────────────
662
+ // rc.10.1 C (2026-05-19) — exposes `limit` + `offset` to AI agents so
663
+ // they can constrain large API responses without injecting template
664
+ // hacks like `template_fields.per_page`. Trim happens IN-MEMORY on
665
+ // the TS side after the response is unwrapped — this is single-shot
666
+ // upstream, no multi-page fetch is triggered. Real cursor / page-loop
667
+ // pagination is a separate roadmap item (see plan Section E1).
308
668
  server.registerTool("apimapper_connection_data", {
309
669
  title: "Fetch Connection Sample Data",
310
670
  description: "Fetch sample data from a connection's configured endpoint. The PHP handler reads ALL " +
311
- "query params as source-context for template substitution." +
312
- "\n\nExample:\n apimapper_connection_data({ id: 'con_abc123', params: { query: 'nature', per_page: 10 } })",
671
+ "query params as source-context for template substitution. Use `limit`/`offset` to trim " +
672
+ "the response items in-memory this does NOT trigger multi-page fetch upstream (the " +
673
+ "request is single-shot). Use `fields` to project each item down to a whitelist of leaf " +
674
+ "keys, cutting response size on sparse queries." +
675
+ "\n\nExample:\n" +
676
+ " apimapper_connection_data({ id: 'con_abc123', limit: 10 }) // first 10 items\n" +
677
+ " apimapper_connection_data({ id: 'con_abc123', limit: 5, offset: 10 }) // skip 10, take 5\n" +
678
+ " apimapper_connection_data({ id: 'con_abc123', fields: ['title', 'image.url'] }) // 2 leaf keys per item\n" +
679
+ " apimapper_connection_data({ id: 'con_abc123', template_fields: { query: 'nature' } })",
313
680
  inputSchema: {
314
681
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
315
- endpoint: z.string().optional().describe('Override endpoint (e.g., "Scheduled Events")'),
682
+ endpoint: z
683
+ .string()
684
+ .trim()
685
+ .min(1)
686
+ .optional()
687
+ .describe('Override endpoint (e.g., "Scheduled Events"). Empty-string and whitespace-only values are rejected at the schema boundary to prevent "?endpoint=" query garbage; omit the field entirely to use the connection default.'),
316
688
  template_fields: z
317
- .record(z.string(), z.string())
689
+ .record(z.string(), z.union([z.string(), z.number().finite(), z.boolean()]))
690
+ .optional()
691
+ .describe('Template field values flattened into the query string. Accepts string/number/boolean values; non-string values are stringified via JS String() (number 25 → "25", true → "true", false → "false"). NaN/Infinity numbers are rejected by schema (defense-in-depth). Nested objects/arrays are rejected. Example: { spreadsheet_id: "abc", per_page: 25, active: true } → ?spreadsheet_id=abc&per_page=25&active=true'),
692
+ limit: z
693
+ .number()
694
+ .int()
695
+ .min(1)
696
+ .max(500)
318
697
  .optional()
319
- .describe('Template field values flattened into query string (e.g., {"spreadsheet_id":"...","user_uri":"..."})'),
698
+ .describe("Trim the returned items array to at most N (1-500). Applied in-memory after the upstream fetch — does NOT request fewer items from the source API. Omit to return the full response."),
699
+ offset: z
700
+ .number()
701
+ .int()
702
+ .min(0)
703
+ .optional()
704
+ .describe("Skip the first N items before applying `limit`. Applied in-memory. Useful for inspecting later items in a large response when combined with `limit`."),
705
+ // rc.13 W3.3 (2026-05-20) — optional per-item field whitelist.
706
+ // When the data payload is an array of objects each item is mapped
707
+ // through pickFields() so only the named leaf keys survive.
708
+ fields: z
709
+ .array(z.string().min(1))
710
+ .max(40)
711
+ .optional()
712
+ .describe("Optional whitelist of fields to keep per item (supports nested paths like 'image.url'). Default: all fields. Cuts response size on sparse queries — e.g. fields:['title','image.url'] keeps only those two leaf keys per record."),
320
713
  },
321
- annotations: readOnly(),
322
- }, async ({ id, endpoint, template_fields }) => {
714
+ annotations: readOnly({ title: "Get Connection Data", openWorld: true }),
715
+ }, async ({ id, endpoint, template_fields, limit, offset, fields }) => {
323
716
  const params = new URLSearchParams();
324
717
  if (endpoint)
325
718
  params.set("endpoint", endpoint);
326
719
  // Flatten template_fields directly as query params — PHP handler does
327
720
  // array_filter() across get_query_params() and uses each key as a
328
- // template variable.
721
+ // template variable. Non-string primitives (number/boolean — F-10/W1.5)
722
+ // are explicitly stringified via String(): the schema admits
723
+ // string|number|boolean unions, so the handler must canonicalise
724
+ // numbers to their decimal repr ("25") and booleans to "true"/"false"
725
+ // before they hit URLSearchParams. URLSearchParams.set would coerce
726
+ // implicitly, but the explicit guard documents the contract and keeps
727
+ // TypeScript happy under the wider schema.
329
728
  if (template_fields) {
330
729
  for (const [k, v] of Object.entries(template_fields)) {
331
- params.set(k, v);
730
+ params.set(k, typeof v === "string" ? v : String(v));
332
731
  }
333
732
  }
334
733
  const qs = params.toString();
335
- const r = await request(`/connections/${encodeURIComponent(id)}/data${qs ? `?${qs}` : ""}`);
734
+ // S-MED-1 (Wave-B 2026-06-03): sanitize the data read. The {…,connection}
735
+ // envelope echoes the connection config (incl. `headers`/`params` arrays
736
+ // that can hold an inline Authorization / X-API-Key value); {sanitize:true}
737
+ // scrubs it — and {name,value} header pairs — before the payload crosses
738
+ // the MCP boundary. The data rows themselves are customer data and are not
739
+ // mutated by the secret-only sanitizer.
740
+ const r = await request(`/connections/${encodeURIComponent(id)}/data${qs ? `?${qs}` : ""}`, {}, { sanitize: true });
336
741
  if (!r.success) {
337
- return formatResult({
338
- error: r.error,
339
- status: r.status,
340
- errorCode: r.errorCode,
341
- context: { id, endpoint, template_fields },
342
- hint: hintFor(r.errorCode),
343
- }, true);
742
+ return restErrorResult(r, { id, endpoint, template_fields, limit, offset }, { message: "connection data fetch failed" });
344
743
  }
345
744
  // PHP success body: {data:[…items…], connection, body?, content_type?, status?}.
346
745
  // Operate on the inner `data` payload, not on the envelope. Surface
347
746
  // `body`/`content_type` for the non-JSON path. Audit: F-A1-06.
348
747
  const envelope = (r.data && typeof r.data === "object" ? r.data : {});
349
- const inner = "data" in envelope ? envelope.data : envelope;
748
+ const innerRaw = "data" in envelope ? envelope.data : envelope;
350
749
  const body = typeof envelope.body === "string" ? envelope.body : undefined;
351
750
  const contentType = typeof envelope.content_type === "string" ? envelope.content_type : undefined;
352
- const isArray = Array.isArray(inner);
353
- const items = isArray
354
- ? inner
355
- : inner && typeof inner === "object" && Array.isArray(inner.items)
356
- ? inner.items
357
- : null;
751
+ // Silent-zero UX gap (2026-06, PR #732 follow-through) — on a 0-row
752
+ // read the PHP /connections/{id}/data handler attaches a structured
753
+ // `diagnostic` ({reason, message}) explaining WHY nothing showed
754
+ // (unresolved endpoint placeholders, items_path mismatch, or a genuine
755
+ // empty result). Forward it additively into the tool output so the AI
756
+ // agent sees the explanation instead of a silent {item_count:0,
757
+ // payload:[]}. Present only when the upstream supplied it (i.e. on
758
+ // empty results), so the non-empty envelope shape is unchanged. We
759
+ // keep the value verbatim — the PHP EmptyResultDiagnostic::classify
760
+ // owns the {reason, message} contract; the TS layer is a pure
761
+ // passthrough and must not reshape it.
762
+ const diagnostic = envelope.diagnostic !== null &&
763
+ typeof envelope.diagnostic === "object" &&
764
+ !Array.isArray(envelope.diagnostic)
765
+ ? envelope.diagnostic
766
+ : undefined;
767
+ // rc.10.1 C + rc.13 W1.2 (F-01+F-38) — apply in-memory trim via
768
+ // extracted pure function. See the `applyTrim` JSDoc in
769
+ // connections-trim.ts for the shape-detection contract and
770
+ // shallow-clone guarantees.
771
+ const trim = applyTrim(innerRaw, limit, offset);
772
+ // rc.13 W3.3 — optional field-projection. Runs AFTER trim: the
773
+ // trimmed slice is the smallest correct surface to project, and
774
+ // projection composes cleanly on top of it (project-of-trimmed ≡
775
+ // narrowing a smaller list). Projection is applied ONLY when the
776
+ // (trimmed) payload is an array of plain objects — each item is
777
+ // mapped through pickFields(), which flattens nested paths to leaf
778
+ // keys and silently drops missing fields. For every other shape
779
+ // (single object, scalar, nested-items envelope, array of
780
+ // primitives) projection is skipped and the payload passes through
781
+ // unchanged — there is no per-record surface to whitelist there, so
782
+ // narrowing would be lossy or meaningless. `projected_fields` is
783
+ // still echoed back in the envelope whenever `fields` was supplied,
784
+ // so the AI client always knows projection was requested even when
785
+ // the shape made it a no-op.
786
+ let projectedPayload = trim.payload;
787
+ if (fields && Array.isArray(trim.payload)) {
788
+ projectedPayload = trim.payload.map((item) => item !== null && typeof item === "object" && !Array.isArray(item)
789
+ ? pickFields(item, fields)
790
+ : item);
791
+ }
358
792
  return formatResult({
359
793
  ok: true,
360
- shape: isArray ? "array" : typeof inner,
361
- item_count: items ? items.length : undefined,
794
+ // F-38 — surface a stable, narrow shape enum instead of typeof.
795
+ // "object" is preserved for the legacy non-iterable case so
796
+ // existing wire-shape pins keep working.
797
+ shape: trim.detectedShape === "other" ? typeof trim.payload : trim.detectedShape,
798
+ item_count: trim.itemCount,
799
+ ...(trim.trimmed && trim.totalBeforeTrim !== undefined
800
+ ? { total_before_trim: trim.totalBeforeTrim, trimmed: true }
801
+ : {}),
802
+ // W3.3 — echo the requested whitelist so the AI knows the
803
+ // per-item shape was (or was meant to be) reduced.
804
+ ...(fields ? { projected_fields: fields } : {}),
805
+ // Silent-zero UX gap (2026-06) — forward the upstream
806
+ // EmptyResultDiagnostic so a 0-row read explains WHY. Spread
807
+ // additively: absent on the happy path, so existing non-empty
808
+ // wire-shape pins are unchanged.
809
+ ...(diagnostic ? { diagnostic } : {}),
362
810
  body,
363
811
  content_type: contentType,
364
- payload: inner,
812
+ payload: projectedPayload,
365
813
  }, false, { maxChars: 6000 });
366
814
  });
367
815
  // ── apimapper_connection_resources ─────────────────────────────────
@@ -375,9 +823,15 @@ export function registerConnectionTools(server) {
375
823
  field: z
376
824
  .string()
377
825
  .describe('Resource-picker field name (e.g., "spreadsheet_id", "drive_file_id"). REST wire-key: field.'),
378
- query: z.string().optional().describe("Free-text search query"),
826
+ // rc.10 A4 (2026-05-19): min(3) blocks the single-char "p", "j", "u"
827
+ // probing pattern observed in Maria-walkthrough logs.
828
+ // rc.13 W1.17 (F-29): Master-Doc-Drift — Master requested min(2), kept
829
+ // min(3) (stricter is OK, less regression-risk). Pin-test in
830
+ // connections.test.ts "F-29 connection_resources query min length pin"
831
+ // guards against loosening.
832
+ query: z.string().min(3).optional().describe("Free-text search query. Must be 3+ characters — single-char and 2-char probes are blocked because the resource lists are small enough to scan without typeahead."),
379
833
  },
380
- annotations: readOnly(),
834
+ annotations: readOnly({ title: "List Connection Resources", openWorld: true }),
381
835
  }, async ({ id, field, query }) => {
382
836
  const params = new URLSearchParams();
383
837
  params.set("field", field);
@@ -385,42 +839,47 @@ export function registerConnectionTools(server) {
385
839
  params.set("query", query);
386
840
  const r = await request(`/connections/${encodeURIComponent(id)}/resources?${params.toString()}`);
387
841
  if (!r.success) {
388
- return formatResult({
389
- error: r.error,
390
- status: r.status,
391
- errorCode: r.errorCode,
392
- context: { id, field, query },
393
- hint: hintFor(r.errorCode),
394
- }, true);
842
+ return restErrorResult(r, { id, field, query }, { message: "connection resources failed" });
395
843
  }
396
- const data = r.data;
397
- const resources = Array.isArray(data)
398
- ? data
399
- : data && typeof data === "object" && Array.isArray(data.resources)
400
- ? data.resources
401
- : data && typeof data === "object" && Array.isArray(data.items)
402
- ? data.items
403
- : [];
404
- return formatResult({
405
- field,
406
- query: query || null,
407
- resource_count: resources.length,
408
- resources: resources.slice(0, 100),
409
- note: resources.length > 100 ? `Showing first 100 of ${resources.length} resources.` : undefined,
410
- }, false, { maxChars: 6000 });
844
+ // A1-P3-1: pure helper handles the three wire-shapes (bare array,
845
+ // `{ resources }`, `{ items }`) + fallback to `[]` for unknown
846
+ // shapes. Pinned per-branch in connections.test.ts.
847
+ const resources = extractResourceList(r.data);
848
+ // W3.1 — tableResult: resources are a flat row list. IA-7: opaque id
849
+ // stays llmOnly. IA-10: the footer carries the next-step guidance and
850
+ // the truncation note when more than 100 resources are returned.
851
+ const truncated = resources.length > 100;
852
+ const footerLines = [
853
+ `Picker field: ${field}${query ? ` · filter: "${query}"` : ""}.`,
854
+ ];
855
+ if (truncated) {
856
+ footerLines.push(`Showing first 100 of ${resources.length} resources — narrow with the query filter.`);
857
+ }
858
+ footerLines.push("Next: pass a chosen resource ID into apimapper_connection_update " +
859
+ "({ id, patch }) or the connection's pipeline default_params.");
860
+ return tableResult(resources.slice(0, 100), {
861
+ columns: RESOURCE_TABLE_COLUMNS,
862
+ map: mapResourceRow,
863
+ header: (n) => `${n} resources on ${id}`,
864
+ footer: footerLines.join("\n"),
865
+ });
411
866
  });
412
867
  // ── apimapper_connection_pipeline_update ───────────────────────────
413
868
  server.registerTool("apimapper_connection_pipeline_update", {
414
869
  title: "Update Connection Pipeline",
415
- description: "Update the connection's pipeline (endpoints, default_params, headers, items_path, items_shape)." +
416
- "\n\nExample:\n apimapper_connection_pipeline_update({ id: 'con_abc123', items_path: 'photos', items_shape: 'auto', default_params: { per_page: '20' } })",
870
+ description: "Update the connection's pipeline (endpoints, default_params, default_headers, headers, items_path, items_shape, body, body_type, graphql_variables, response_type)." +
871
+ "\n\nitems_shape values: 'flat' (default, list of objects), 'auto' (best-effort detection), 'headers_rows' (pivot 2D array [[h1,h2],[v1,v2],...] into named-object rows — use this for Google-Sheets-style responses), 'object_keys' (turn a keyed object into a list)." +
872
+ "\n\nExample:\n apimapper_connection_pipeline_update({ id: 'con_abc123', pipeline: { items_path: 'photos', items_shape: 'auto', default_params: { per_page: '20' } } })" +
873
+ "\n\nGoogle-Sheets 2D array example:\n apimapper_connection_pipeline_update({ id: 'con_sheet', pipeline: { items_shape: 'headers_rows' } })",
417
874
  inputSchema: {
418
875
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
419
876
  pipeline: z
420
877
  .record(z.string(), z.unknown())
421
- .describe('Pipeline JSON (e.g., {"endpoints":[...], "items_path":"data", "items_shape":"flat"})'),
878
+ .describe('Pipeline JSON. Accepts connection-column keys: endpoints, default_params, default_headers, headers, items_path, items_shape, body, body_type, graphql_variables, response_type. ' +
879
+ 'items_shape options: "flat" | "auto" | "headers_rows" | "object_keys". ' +
880
+ 'Example: {"items_path":"data","items_shape":"headers_rows"}.'),
422
881
  },
423
- annotations: mutating(),
882
+ annotations: mutating({ title: "Update Connection Pipeline", openWorld: true }),
424
883
  }, async ({ id, pipeline }) => {
425
884
  // PHP ConnectionPipelineHandler uses fromControllerResponse(_, 'connection')
426
885
  // → {success, connection:{…}}. Unwrap defensively. Audit: F-A1-07.
@@ -429,7 +888,7 @@ export function registerConnectionTools(server) {
429
888
  body: JSON.stringify(pipeline),
430
889
  });
431
890
  if (!r.success) {
432
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
891
+ return restErrorResult(r, { id }, { message: "connection pipeline update failed" });
433
892
  }
434
893
  const conn = unwrapEntity(r.data, "connection");
435
894
  return formatResult({ updated: true, id: conn?.id, name: conn?.name }, false, { maxChars: 2000 });