@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
@@ -1,12 +1,14 @@
1
1
  import { z } from "zod";
2
- import { formatResult, tableResult, errorResult, readOnly, creating, mutating, destructive, errorWithSuggestion, pickFields, createProgressReporter, elicitChoice, } from "@getimo/mcp-toolkit";
2
+ import { formatResult, tableResult, errorResult, readOnly, creating, mutating, destructive, pickFields, createProgressReporter, } from "@getimo/mcp-toolkit";
3
3
  import { request, hintFor, WP_BASE, WP_USER, authConfigured } from "./client.js";
4
+ import { restErrorResult } from "./tool-result.js";
4
5
  import { toRows } from "./types.js";
5
6
  import { unwrapEntity } from "./envelope.js";
6
7
  import { ambiguityFallbackError, } from "./elicitation.js";
7
8
  import { fetchWithTimeout } from "./diagnose.js";
8
9
  import { filterByNameQuery } from "./node-schema.js";
9
- 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";
10
+ import { withOverflowFooter, truncatedByLimitNote } from "./list-footer.js";
11
+ import { CONNECTION_TABLE_COLUMNS, CONNECTION_FULL_COLUMNS, CONNECTION_COMPACT_COLUMNS, CONNECTION_LIST_NEXT_STEPS, RESOURCE_TABLE_COLUMNS, mapConnectionRow, mapConnectionRowFull, compactConnectionRow, mapResourceRow, buildConnectionDetail, buildConnectionTestStats, buildHealthCheckStats, buildHealthStats, } from "./connections-format.js";
10
12
  // W3 Stage-2 hardening — applyTrim + extractResourceList live in the
11
13
  // sibling connections-trim.ts (pure functions, ~200 lines). Imported for
12
14
  // internal use by the connection_data + connection_resources handlers,
@@ -24,6 +26,7 @@ export { applyTrim, extractResourceList };
24
26
  // formatter; connection_get / _update / _delete pass through unwrapEntity
25
27
  // + the rich-card builder which already reads from the same row map.
26
28
  import { normalizeConnectionFromWire } from "./normalizers.js";
29
+ import { normalizeResourceIdFields } from "./resource-id.js";
27
30
  /**
28
31
  * F-15 (W1.9) — downstream probe of api.wootsup.com from `apimapper_health`.
29
32
  *
@@ -71,13 +74,16 @@ async function probeApimapperApi() {
71
74
  /**
72
75
  * Register the connection CRUD + probe + sample + pipeline tools.
73
76
  *
74
- * @param server the tool registrar (essentials forward, rest captured).
75
- * @param elicitation optional elicitation capability (the real McpServer, or
76
- * any `{ server: { elicitInput } }`). Supplied only by the host wiring in
77
- * index.ts. When omitted, `connection_create` falls back to a structured
78
- * error on genuine credential ambiguity instead of prompting.
77
+ * Chat-first disambiguation (decision 2026-06-15): on genuine credential
78
+ * ambiguity `connection_create` returns a structured candidate-list error
79
+ * (the assistant asks in plain chat) rather than driving the native
80
+ * elicitation picker. The picker is reserved for OAuth authorization
81
+ * (`apimapper_oauth_authorize_begin` in credentials.ts), so this module no
82
+ * longer takes an elicitation capability.
83
+ *
84
+ * @param server the tool registrar (essentials forward, rest captured).
79
85
  */
80
- export function registerConnectionTools(server, elicitation) {
86
+ export function registerConnectionTools(server) {
81
87
  // ── apimapper_health ───────────────────────────────────────────────
82
88
  server.registerTool("apimapper_health", {
83
89
  title: "API Mapper REST Health",
@@ -136,8 +142,16 @@ export function registerConnectionTools(server, elicitation) {
136
142
  }
137
143
  }
138
144
  catch (e) {
139
- const err = errorWithSuggestion(e, { healthTool: "apimapper_health" });
140
- checks.connectivity = `ERROR: ${err.message}`;
145
+ // A4 (Wave-B audit, 2026-06-03): this catch builds a STRING into the
146
+ // health-snapshot `checks` map and then continues to buildHealthStats —
147
+ // it is NOT an MCP error-result return, so the structured
148
+ // errorResult/restErrorResult builders do not apply here (they would
149
+ // early-return and abandon the snapshot). The lone `errorWithSuggestion`
150
+ // call is dropped in favour of the same raw, already-sanitised message
151
+ // the sibling `FAIL: ${r.error}` branch above surfaces — `request()`
152
+ // sanitises and never throws, so this remains a defensive guard.
153
+ const message = e instanceof Error ? e.message : String(e);
154
+ checks.connectivity = `ERROR: ${message}`;
141
155
  }
142
156
  // W3.1 — health snapshot is a flat scalar set: statsResult is the
143
157
  // goldstandard fit. Every check becomes a stat so an AI client reads
@@ -150,17 +164,28 @@ export function registerConnectionTools(server, elicitation) {
150
164
  // rc.10 A5 (2026-05-19) — description states the default limit
151
165
  // already covers a typical install. The Maria-walkthrough log
152
166
  // showed AI defaulting to limit:200/500 reflexively.
153
- description: "List all API Mapper connections. The default limit of 50 already covers a typical install " +
154
- "most customers run with 5-25 connections, increase the limit only if you have a specific " +
155
- "reason to expect more. Use apimapper_connection_get for full details of a single connection." +
156
- "\n\nExample:\n apimapper_connection_list({}) // returns up to 50 connections in one call\n" +
157
- " apimapper_connection_list({ source: 'user' }) // narrow to user-created only",
167
+ description: "List API Mapper connections a LEAN projection by default (id / name / source / host / health). " +
168
+ "The default limit of 25 already covers a typical install most customers run with 5-25 " +
169
+ "connections, increase the limit only if you have a specific reason to expect more. " +
170
+ "Pass `fields: ['all']` for the full dump (endpoint URL, method, auth_type, credential_id), " +
171
+ "or a list of specific extra columns. Use apimapper_connection_get for full details of a single connection." +
172
+ "\n\nExample:\n apimapper_connection_list({}) // lean projection, up to 25 connections\n" +
173
+ " apimapper_connection_list({ source: 'user' }) // narrow to user-created only\n" +
174
+ " apimapper_connection_list({ fields: ['all'] }) // full detail dump",
158
175
  inputSchema: {
159
176
  source: z
160
177
  .enum(["library", "demo", "user", "all"])
161
178
  .default("all")
162
179
  .describe("Filter by origin"),
163
- 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."),
180
+ limit: z.number().min(1).max(500).default(25).describe("Max items (1-500). Default 25 already covers a typical install — do not raise unless you have evidence the list is larger."),
181
+ // F198 — opt-in full detail. The default projection is the lean
182
+ // id/name/source/host/health set; `fields` widens it. `['all']` (or
183
+ // any non-empty array) returns the full endpoint/method/auth/cred dump.
184
+ fields: z
185
+ .array(z.string().min(1))
186
+ .optional()
187
+ .describe("Opt into extra columns. Omit for the lean default (id/name/source/host/health). " +
188
+ "Pass ['all'] for the full dump (endpoint URL, method, auth_type, credential_id)."),
164
189
  // W1.18 (F-32) — case-insensitive substring filter applied
165
190
  // in-memory AFTER the upstream fetch (no per-name REST call).
166
191
  // Min 2 chars at the schema boundary blocks the single-char
@@ -172,15 +197,10 @@ export function registerConnectionTools(server, elicitation) {
172
197
  .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."),
173
198
  },
174
199
  annotations: readOnly({ title: "List Connections", openWorld: true }),
175
- }, async ({ source, limit, name_query }) => {
200
+ }, async ({ source, limit, name_query, fields }) => {
176
201
  const r = await request("/connections");
177
202
  if (!r.success) {
178
- return errorResult({
179
- message: r.error ?? "connection list failed",
180
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
181
- suggestion: hintFor(r.errorCode),
182
- details: { source, limit, name_query },
183
- });
203
+ return restErrorResult(r, { source, limit, name_query }, { message: "connection list failed" });
184
204
  }
185
205
  // F-LS-04 — normalise the camelCase wire shape (authType, credentialId,
186
206
  // …) to snake_case BEFORE anything downstream reads it. The
@@ -195,24 +215,53 @@ export function registerConnectionTools(server, elicitation) {
195
215
  // subset, not the haystack. See `filterByNameQuery` JSDoc for the
196
216
  // case-folding + undefined-skip contract shared with flow_list.
197
217
  items = filterByNameQuery(items, name_query);
218
+ // F198 — capture the matched total BEFORE the slice so the footer can
219
+ // report exactly how many rows were dropped by the limit.
220
+ const totalMatched = items.length;
198
221
  items = items.slice(0, limit);
222
+ // F198 — opt into the full detail dump only when `fields` is supplied.
223
+ // Any non-empty `fields` array (typically ['all']) widens the table to
224
+ // the full column set; the default stays the lean projection.
225
+ const wantFull = Array.isArray(fields) && fields.length > 0;
226
+ // F198 — the honest "N more — filter with name_query or raise limit"
227
+ // note fires whenever the limit actually dropped matched rows. The
228
+ // compact-render note (withOverflowFooter) still rides along for the
229
+ // truncated-text case.
230
+ const overflowNote = truncatedByLimitNote(totalMatched, limit, "connections");
231
+ const baseFooter = overflowNote
232
+ ? `${CONNECTION_LIST_NEXT_STEPS}\n${overflowNote}`
233
+ : CONNECTION_LIST_NEXT_STEPS;
199
234
  // W3.1 — tableResult: ASCII table for the LLM + typed DataTable payload
200
- // for the Rich Card. T1 (W3.2): explicit compactColumns/compactMap drop
201
- // ENDPOINT/METHOD/CRED at 21+ rows. IA-7: id stays llmOnly. IA-10: the
202
- // footer carries the next-step guidance.
235
+ // for the Rich Card. Default = lean projection (id/name/source/host/
236
+ // health); `fields` = full dump. IA-7: id stays llmOnly. IA-10: the
237
+ // footer carries the next-step guidance + the F198 overflow note.
203
238
  return tableResult(toRows(items), {
204
- columns: CONNECTION_TABLE_COLUMNS,
239
+ columns: wantFull ? CONNECTION_FULL_COLUMNS : CONNECTION_TABLE_COLUMNS,
205
240
  compactColumns: CONNECTION_COMPACT_COLUMNS,
206
- map: mapConnectionRow,
241
+ map: wantFull ? mapConnectionRowFull : mapConnectionRow,
207
242
  compactMap: compactConnectionRow,
208
- header: (n) => `${n} connections`,
209
- footer: CONNECTION_LIST_NEXT_STEPS,
243
+ // F198 the "N more" overflow note rides in the HEADER (rendered
244
+ // first, never truncated) so it survives even when a 21+ row table
245
+ // compacts to 2000 chars and drops the footer. The footer keeps the
246
+ // next-step guidance + the compact-render note for the non-overflow
247
+ // truncation case.
248
+ header: (n) => (overflowNote ? `${n} connections — ${overflowNote}` : `${n} connections`),
249
+ // Minor (2026-06-10): at 21+ rows the toolkit compacts the LLM-visible
250
+ // table to 2000 chars — append an honest "use filters" note so the
251
+ // agent does not mistake a truncated list for the full set.
252
+ footer: withOverflowFooter(baseFooter, items.length, "connections"),
210
253
  });
211
254
  });
212
255
  // ── apimapper_connection_get ───────────────────────────────────────
213
256
  server.registerTool("apimapper_connection_get", {
214
257
  title: "Get Connection",
215
- description: "Fetch full configuration of a single connection by ID." +
258
+ description: "Get the full configuration of one connection by ID (endpoints, auth/" +
259
+ "credential link, default params + headers, items_path, pipeline). Use to " +
260
+ "inspect or debug a single connection before editing it or wiring it into a flow. " +
261
+ "Keywords: get connection, connection detail, inspect connection, show config, by ID. " +
262
+ "When NOT to use: to enumerate all connections use apimapper_connection_list; to " +
263
+ "fetch sample rows use apimapper_connection_data; to probe reachability/auth use " +
264
+ "apimapper_connection_test; to change fields use apimapper_connection_update." +
216
265
  "\n\nExample:\n apimapper_connection_get({ id: 'con_abc123' })",
217
266
  inputSchema: {
218
267
  id: z.string().describe('Connection ID (e.g., "conn_Mz33OVPF1z3ap8fbbQtpx"). Use apimapper_connection_list.'),
@@ -222,14 +271,16 @@ export function registerConnectionTools(server, elicitation) {
222
271
  // PHP wraps via fromControllerResponse($response, 'connection') →
223
272
  // `{success:true, connection:{…}}`. Unwrap defensively so a future
224
273
  // flatten on the PHP side doesn't break us. Audit: F-A1-01.
225
- const r = await request(`/connections/${encodeURIComponent(id)}`);
274
+ // S-MED-1 (Wave-B 2026-06-03): sanitize the connection read. The
275
+ // Connection shape carries `headers`/`params` arrays that can hold an
276
+ // inline Authorization / X-API-Key value; {sanitize:true} runs the
277
+ // response through sanitizeSecrets (which now also scrubs {name,value}
278
+ // header pairs) so the SECURITY.md "every response is sanitized"
279
+ // guarantee holds structurally, not only by buildConnectionDetail's
280
+ // whitelist curation.
281
+ const r = await request(`/connections/${encodeURIComponent(id)}`, {}, { sanitize: true });
226
282
  if (!r.success) {
227
- return errorResult({
228
- message: r.error ?? "connection get failed",
229
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
230
- suggestion: hintFor(r.errorCode),
231
- details: { id },
232
- });
283
+ return restErrorResult(r, { id }, { message: "connection get failed" });
233
284
  }
234
285
  const conn = unwrapEntity(r.data, "connection");
235
286
  if (!conn || Object.keys(conn).length === 0) {
@@ -243,25 +294,70 @@ export function registerConnectionTools(server, elicitation) {
243
294
  // W3.1 — detailResult: grouped key-value detail for the Rich Card.
244
295
  // IA-7: opaque IDs are copyable code entries. IA-10: a dedicated
245
296
  // "Next steps" group carries the follow-up calls.
246
- return buildConnectionDetail(id, conn);
297
+ // F9 (2026-06-09): run the same camelCase→snake_case wire bridge the
298
+ // list path applies (F-LS-04) — buildConnectionDetail reads
299
+ // c.auth_type/credential_id; the raw wire shape carries authType/
300
+ // credentialId, so without this the detail rendered auth "none"
301
+ // while the list showed the real auth for the SAME connection.
302
+ return buildConnectionDetail(id, normalizeConnectionFromWire(conn));
247
303
  });
248
304
  // ── apimapper_connection_create ────────────────────────────────────
249
305
  server.registerTool("apimapper_connection_create", {
250
306
  title: "Create Connection",
251
- description: "Create a new connection (custom not from library)." +
307
+ description: "Create a custom connection. **Use ONLY when no library template matches your target API.** " +
308
+ "First call `apimapper_library_featured()` or `apimapper_library_list({ search: '<api-name>' })` (min 3 chars) — " +
309
+ "Google Sheets, Calendly, Notion, Airtable, GitHub, Pexels, Unsplash, OpenWeatherMap, REST Countries, " +
310
+ "Google Drive/Docs/Slides/Tasks all have ready-to-use templates with auto-configured auth and " +
311
+ "field-detection. Library activation via `apimapper_library_activate({ id })` is the canonical " +
312
+ "customer path; connection_create is the fallback for niche or unknown APIs. " +
313
+ "The server enforces this: a custom create on an API already covered by a curated template is " +
314
+ "blocked with a 409 naming the template to activate — pass `acknowledge_no_library: true` to " +
315
+ "intentionally override that block. " +
316
+ // A16 (2026-06-10): the guard matches on the HOST, so a genuinely
317
+ // uncovered endpoint never blocks — do NOT set acknowledge_no_library
318
+ // pre-emptively.
319
+ "The guard matches on the request HOST against the curated library; a connection to an " +
320
+ "uncovered host is NEVER blocked and needs no `acknowledge_no_library` — only set that flag " +
321
+ "AFTER you actually receive a 409." +
322
+ "\n\nNote: connection auth_type spellings differ from apimapper_credential_create's: " +
323
+ "here it is `basic` / `oauth2`, whereas credential_create uses `basic_auth` / `oauth2_code` / `oauth2_cc`. " +
324
+ "Use the spelling for the tool you are calling." +
252
325
  "\n\nExample:\n apimapper_connection_create({ name: 'Pexels API', endpoint: 'https://api.pexels.com/v1/search', method: 'GET', auth_type: 'bearer', items_path: 'photos' })",
253
326
  inputSchema: {
254
327
  name: z.string().min(1).describe('Connection name (e.g., "My Blog API")'),
255
328
  endpoint: z.string().describe('Endpoint path or full URL (e.g., "https://api.example.com/posts")'),
256
329
  method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET").describe("HTTP method"),
257
- auth_type: z.enum(["none", "api_key", "bearer", "basic", "oauth2"]).default("none").describe("Auth type (snake_case wire key)"),
330
+ auth_type: z
331
+ .enum(["none", "api_key", "bearer", "basic", "oauth2"])
332
+ .default("none")
333
+ .describe("Auth type (snake_case wire key). " +
334
+ "NB: credential_create spells these `basic_auth` (here `basic`) and `oauth2_code`/`oauth2_cc` (here `oauth2`)."),
258
335
  credential_id: z.string().optional().describe("Credential ID (snake_case wire key) if auth_type requires one"),
259
336
  items_path: z.string().optional().describe('JSONPath to items array (snake_case wire key)'),
260
337
  cache_ttl: z.number().int().min(0).default(3600).describe("Cache TTL in seconds (snake_case wire key)"),
261
338
  description: z.string().optional().describe("Free-text description"),
339
+ // Phase 6 — audited override for the server-side library-first guard.
340
+ // The PHP guard blocks custom creates on APIs that already have a
341
+ // curated library template (returning a 409 whose message names the
342
+ // template via apimapper_library_activate). Setting this flag relays
343
+ // `acknowledge_no_library: true` into the POST body so the guard lets
344
+ // the create through. Flows into the body via `resolvedInput = {...input}`.
345
+ acknowledge_no_library: z.boolean().optional().describe("Set true ONLY when no library template fits and you intentionally need a custom connection. " +
346
+ "The server blocks custom creates on APIs that already have a curated library template; " +
347
+ "this flag is the audited override. Prefer apimapper_library_activate({ id }) when a template exists."),
348
+ // Phase 2.7 (change protocol): the action is IMPLICIT
349
+ // (`connection.created`) — only `summary` is needed; the handler
350
+ // defaults the action. Threaded into the POST body as
351
+ // `change: { summary }` (conditional, never a top-level key).
352
+ summary: z
353
+ .string()
354
+ .max(280)
355
+ .optional()
356
+ .describe('Optional one-line summary of this change for the change history ' +
357
+ '(e.g., "Added a Pexels photo connection").'),
262
358
  },
263
359
  annotations: creating({ title: "Create Connection", openWorld: true }),
264
- }, async (input) => {
360
+ }, async ({ summary, ...input }) => {
265
361
  // W3.6 — when the connection's auth_type needs a credential and none is
266
362
  // given: exactly 1 stored credential → auto-pick; >1 → elicitChoice;
267
363
  // elicitChoice null (unsupported client / declined) → structured
@@ -270,12 +366,7 @@ export function registerConnectionTools(server, elicitation) {
270
366
  if (input.auth_type !== "none" && !input.credential_id) {
271
367
  const lr = await request("/credentials", {}, { sanitize: true });
272
368
  if (!lr.success) {
273
- return errorResult({
274
- message: lr.error ?? "credential lookup failed",
275
- code: lr.errorCode ?? (lr.status ? String(lr.status) : undefined),
276
- suggestion: hintFor(lr.errorCode),
277
- details: { name: input.name, auth_type: input.auth_type },
278
- });
369
+ return restErrorResult(lr, { name: input.name, auth_type: input.auth_type }, { message: "credential lookup failed" });
279
370
  }
280
371
  const creds = Array.isArray(lr.data?.credentials) ? lr.data.credentials : [];
281
372
  if (creds.length === 0) {
@@ -290,39 +381,67 @@ export function registerConnectionTools(server, elicitation) {
290
381
  resolvedInput = { ...input, credential_id: creds[0].id };
291
382
  }
292
383
  else {
384
+ // Chat-first disambiguation (decision 2026-06-15): the native
385
+ // elicitation picker renders as a clunky collapsed TUI in some
386
+ // hosts. For an ordinary credential pick the assistant asking in
387
+ // plain chat is nicer, so we skip the picker and return the
388
+ // structured candidate-list error directly — its `candidates`
389
+ // carry id+label, so the assistant sees the names and can ask.
390
+ // (The OAuth authorize path KEEPS the picker: explicit consent on
391
+ // WHICH credential to authorize is security-relevant.)
293
392
  const candidates = creds.map((c) => ({
294
393
  id: c.id,
295
394
  label: c.name,
296
395
  }));
297
- const picked = elicitation
298
- ? await elicitChoice(elicitation, `The "${input.name}" connection (auth_type: ${input.auth_type}) needs a ` +
299
- "credential. Pick which stored credential to use.", candidates.map((c) => c.id))
300
- : null;
301
- if (picked === null) {
302
- return ambiguityFallbackError({
303
- code: "credential_ambiguous",
304
- paramName: "credential_id",
305
- what: "credentials",
306
- candidates,
307
- extraDetails: { name: input.name, auth_type: input.auth_type },
308
- });
309
- }
310
- resolvedInput = { ...input, credential_id: picked };
396
+ return ambiguityFallbackError({
397
+ code: "credential_ambiguous",
398
+ paramName: "credential_id",
399
+ what: "credentials",
400
+ candidates,
401
+ extraDetails: { name: input.name, auth_type: input.auth_type },
402
+ });
311
403
  }
312
404
  }
405
+ // Phase 2.7 — implicit action (`connection.created`); attach `change:
406
+ // { summary }` only when supplied so the body is unchanged otherwise.
407
+ // `summary` was destructured out of `input` above, so it never leaks as a
408
+ // top-level connection column.
409
+ const createBody = summary !== undefined
410
+ ? { ...resolvedInput, change: { summary } }
411
+ : resolvedInput;
313
412
  // PHP fromControllerResponse(_, 'connection', 201) → {success, connection:{…}}.
314
413
  // Audit: F-A1-02.
315
414
  const r = await request("/connections", {
316
415
  method: "POST",
317
- body: JSON.stringify(resolvedInput),
416
+ body: JSON.stringify(createBody),
318
417
  });
319
418
  if (!r.success) {
320
- return errorResult({
321
- message: r.error ?? "connection create failed",
322
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
323
- suggestion: hintFor(r.errorCode),
324
- details: { name: input.name, endpoint: input.endpoint },
325
- });
419
+ // M1 (MCP-relay audit) — wire-shape parity with the admin-ui. On the
420
+ // library-first guard block the PHP REST body carries a structured
421
+ // `{error, error_code:'library_template_available', library_suggestion:
422
+ // {matched_host, templates[], activate_call}}`. The admin-ui consumes
423
+ // that object; the MCP relay must too. We surface the structured
424
+ // `library_suggestion` + `error_code` in `details` (→ machine-readable
425
+ // _meta.ui.payload.details) IN ADDITION to keeping the actionable
426
+ // `message` string verbatim — the message embeds the activate call so
427
+ // a text-only agent still recovers. `errorBody` is the parsed non-2xx
428
+ // body threaded through the shared client (see client.ts request()).
429
+ const errorBody = r.errorBody;
430
+ const isLibraryBlock = errorBody !== undefined &&
431
+ errorBody.error_code === "library_template_available";
432
+ return restErrorResult(r,
433
+ // Per-site nuance: on a library-first guard block (409 / Joomla
434
+ // com_ajax) the structured `error_code` + `library_suggestion` from
435
+ // the parsed body are surfaced in details IN ADDITION to the verbatim
436
+ // message string. Non-library failures keep the plain identity echo.
437
+ isLibraryBlock
438
+ ? {
439
+ name: input.name,
440
+ endpoint: input.endpoint,
441
+ error_code: errorBody.error_code,
442
+ library_suggestion: errorBody.library_suggestion,
443
+ }
444
+ : { name: input.name, endpoint: input.endpoint }, { message: "connection create failed" });
326
445
  }
327
446
  const conn = unwrapEntity(r.data, "connection");
328
447
  // F-3.2 (Pass-1 consolidation) — mutating-tool payload-shape rationale.
@@ -344,7 +463,7 @@ export function registerConnectionTools(server, elicitation) {
344
463
  name: conn?.name,
345
464
  source: conn?.source,
346
465
  next_steps: [
347
- "Use apimapper_connection_test to verify reachability, then apimapper_flow_create to wire it into a flow.",
466
+ 'Verify reachability via apimapper_advanced({ tool: "apimapper_connection_test", arguments: { id } }), then build a flow with apimapper_flow_setup_with_sources (or apimapper_advanced({ tool: "apimapper_flow_create" })).',
348
467
  ],
349
468
  }, false, { maxChars: 2000 });
350
469
  });
@@ -359,23 +478,39 @@ export function registerConnectionTools(server, elicitation) {
359
478
  .record(z.string(), z.unknown())
360
479
  .describe('Fields to update — snake_case keys. ' +
361
480
  'Examples: {"name":"Renamed"}, {"cache_ttl":7200}, {"endpoint":"/v2/posts"}, ' +
362
- '{"credential_id":"cred_xxx"}'),
481
+ '{"credential_id":"cred_xxx"}. ' +
482
+ // DATA-LOW (Wave-B 2026-06-03): kept as an open record on purpose.
483
+ // The settable column set is large and may widen server-side; the
484
+ // PHP backend column-whitelists on write (Wave A), so any unknown
485
+ // key is dropped server-side rather than persisted. Enumerating a
486
+ // .strict() allow-list here risks rejecting a legit field the
487
+ // server accepts, so the schema stays permissive.
488
+ 'Unknown keys are dropped by the server (column-whitelisted on write).'),
489
+ // Phase 2.7 (change protocol): the action is IMPLICIT
490
+ // (`connection.updated`) — only `summary` is needed; the handler
491
+ // defaults the action. Threaded into the PUT body as
492
+ // `change: { summary }` (sibling of the patch fields).
493
+ summary: z
494
+ .string()
495
+ .max(280)
496
+ .optional()
497
+ .describe('Optional one-line summary of this change (e.g., "Pointed the endpoint at /v2/posts").'),
363
498
  },
364
499
  annotations: mutating({ title: "Update Connection", openWorld: true }),
365
- }, async ({ id, patch }) => {
500
+ }, async ({ id, patch, summary }) => {
501
+ // Phase 2.7 — implicit action (`connection.updated`); attach `change:
502
+ // { summary }` only when supplied so the body is unchanged otherwise.
503
+ const updateBody = summary !== undefined
504
+ ? { ...patch, change: { summary } }
505
+ : patch;
366
506
  // PHP fromControllerResponse(_, 'connection') → {success, connection:{…}}.
367
507
  // Audit: F-A1-03.
368
508
  const r = await request(`/connections/${encodeURIComponent(id)}`, {
369
509
  method: "PUT",
370
- body: JSON.stringify(patch),
510
+ body: JSON.stringify(updateBody),
371
511
  });
372
512
  if (!r.success) {
373
- return errorResult({
374
- message: r.error ?? "connection update failed",
375
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
376
- suggestion: hintFor(r.errorCode),
377
- details: { id },
378
- });
513
+ return restErrorResult(r, { id }, { message: "connection update failed" });
379
514
  }
380
515
  const conn = unwrapEntity(r.data, "connection");
381
516
  // F-3.2 (Pass-1 consolidation) — see connection_create for the
@@ -386,7 +521,110 @@ export function registerConnectionTools(server, elicitation) {
386
521
  id: conn?.id,
387
522
  name: conn?.name,
388
523
  next_steps: [
389
- "Re-run apimapper_connection_test if endpoint/auth changed; flows referencing this connection may need to be republished.",
524
+ 'Re-test if endpoint/auth changed via apimapper_advanced({ tool: "apimapper_connection_test", arguments: { id } }); flows referencing this connection may need to be republished via apimapper_flow_full_recompile_publish.',
525
+ ],
526
+ }, false, { maxChars: 2000 });
527
+ });
528
+ // ── apimapper_connection_recover ───────────────────────────────────
529
+ // Friction-6 fix (Task #46, 2026-05-29) — recover from a connection whose
530
+ // `params` field was persisted as a corrupt JSON-string holding UI metadata
531
+ // blobs (resource_picker, sheets_range, ...) instead of a proper key-value
532
+ // object. Pre-fix: connection_update rejected the patch because the existing
533
+ // shape failed downstream validation; the connection was un-editable.
534
+ //
535
+ // This tool issues a forgiving PUT that resets `params` to {} (and optionally
536
+ // sets a clean replacement via `params_replacement`), letting the customer
537
+ // / AI proceed with a fresh configuration. It also clears any extra cruft
538
+ // keys the AI agent identifies via `clear_fields`.
539
+ server.registerTool("apimapper_connection_recover", {
540
+ title: "Recover Stuck Connection",
541
+ description: "Recover a connection that is un-editable because its `params` field " +
542
+ "is corrupt (e.g. stored as a JSON-string with embedded UI metadata " +
543
+ "instead of an object). Resets `params` to an empty object — or to " +
544
+ "the value of `params_replacement` if supplied. Optionally clears " +
545
+ "additional fields named in `clear_fields`." +
546
+ "\n\nUse when: `apimapper_connection_data` returns 'Please select an " +
547
+ "endpoint' AND `apimapper_connection_update` rejects subsequent " +
548
+ "patches with a validation error about params shape." +
549
+ "\n\nExample:\n apimapper_connection_recover({ id: 'con_abc123' })" +
550
+ "\n apimapper_connection_recover({ id: 'con_abc123', params_replacement: { per_page: '20' }, endpoint: 'values' })",
551
+ inputSchema: {
552
+ id: z
553
+ .string()
554
+ .describe("Connection ID. Use apimapper_connection_list to find."),
555
+ params_replacement: z
556
+ .record(z.string(), z.unknown())
557
+ .optional()
558
+ .describe("Clean replacement for params (key-value object). Default: {} (empty). " +
559
+ // DATA-LOW (Wave-B 2026-06-03): open record by design — the value
560
+ // becomes the connection's `params` column, which the PHP backend
561
+ // column-whitelists on write (Wave A). Free-form key-value pairs
562
+ // are the intended shape here, so no .strict() allow-list applies.
563
+ "Free-form key-value pairs; the server whitelists the persisted columns."),
564
+ endpoint: z
565
+ .string()
566
+ .optional()
567
+ .describe("If the connection has no endpoint configured, set it here in the " +
568
+ "same call (e.g., 'values' for Google Sheets, 'Search' for Pexels). " +
569
+ "Use apimapper_advanced({tool:'apimapper_connection_get'}) to see " +
570
+ "available endpoints[]."),
571
+ clear_fields: z
572
+ .array(z.string().min(1))
573
+ .max(20)
574
+ .optional()
575
+ .describe("Additional fields to clear (set to null). Example: ['template_fields']."),
576
+ confirm: z
577
+ .boolean()
578
+ .default(false)
579
+ .describe("Must be true to execute. On confirm:false, returns a preview of " +
580
+ "the planned reset. Ask user to confirm first."),
581
+ },
582
+ annotations: mutating({ title: "Recover Stuck Connection", openWorld: true }),
583
+ }, async ({ id, params_replacement, endpoint, clear_fields, confirm }) => {
584
+ const patch = {
585
+ params: params_replacement ?? {},
586
+ };
587
+ if (endpoint !== undefined)
588
+ patch.endpoint = endpoint;
589
+ if (Array.isArray(clear_fields)) {
590
+ for (const field of clear_fields) {
591
+ patch[field] = null;
592
+ }
593
+ }
594
+ if (!confirm) {
595
+ return formatResult({
596
+ preview: true,
597
+ notice: "RECOVER — Connection will be patched with the reset shown below. " +
598
+ "This is a forgiving reset path; existing `params` corrupted shape " +
599
+ "will be overwritten.",
600
+ target: { id },
601
+ patch_preview: patch,
602
+ instruction: "Ask user to confirm, then call again with confirm: true.",
603
+ });
604
+ }
605
+ const r = await request(`/connections/${encodeURIComponent(id)}`, {
606
+ method: "PUT",
607
+ body: JSON.stringify(patch),
608
+ });
609
+ if (!r.success) {
610
+ return restErrorResult(r, { id, patch_keys: Object.keys(patch) }, {
611
+ message: "connection recover failed",
612
+ // Per-site nuance: keep the original `hintFor(...) ?? <deep-corrupt
613
+ // fallback>` suggestion verbatim (hintFor always returns a string,
614
+ // so the `??` arm is a defensive no-op preserved for parity).
615
+ suggestion: hintFor(r.errorCode) ??
616
+ "If recover still fails, the connection row may be deeper-corrupt — " +
617
+ "delete it via apimapper_connection_delete and re-activate the library template.",
618
+ });
619
+ }
620
+ const conn = unwrapEntity(r.data, "connection");
621
+ return formatResult({
622
+ recovered: true,
623
+ id: conn?.id ?? id,
624
+ name: conn?.name,
625
+ applied_patch_keys: Object.keys(patch),
626
+ next_steps: [
627
+ 'Verify via apimapper_advanced({ tool: "apimapper_connection_get", arguments: { id } }); re-test via apimapper_advanced({ tool: "apimapper_connection_test", arguments: { id } }) before re-publishing flows.',
390
628
  ],
391
629
  }, false, { maxChars: 2000 });
392
630
  });
@@ -402,14 +640,29 @@ export function registerConnectionTools(server, elicitation) {
402
640
  .boolean()
403
641
  .default(false)
404
642
  .describe("Must be true to execute. On confirm:false, returns a preview."),
643
+ // Phase 2.7 (change protocol): the action is IMPLICIT
644
+ // (`connection.deleted`) — only `summary` is needed; the handler
645
+ // defaults the action. Attached to the DELETE body as
646
+ // `change: { summary }` (the delete otherwise has no body), and only on
647
+ // the real delete — the preview branch never records a change.
648
+ summary: z
649
+ .string()
650
+ .max(280)
651
+ .optional()
652
+ .describe('Optional one-line summary of this deletion (e.g., "Removed the unused Pexels connection").'),
405
653
  },
406
654
  annotations: destructive({ title: "Delete Connection", openWorld: true }),
407
- }, async ({ id, confirm }) => {
655
+ }, async ({ id, confirm, summary }) => {
408
656
  if (!confirm) {
409
657
  // PHP fromControllerResponse(_, 'connection') wraps as
410
658
  // {success, connection:{…}}. Unwrap defensively. Audit: F-A1-04.
411
659
  const preview = await request(`/connections/${encodeURIComponent(id)}`);
412
- const conn = preview.success ? unwrapEntity(preview.data, "connection") : undefined;
660
+ const rawConn = preview.success ? unwrapEntity(preview.data, "connection") : undefined;
661
+ // F9 (2026-06-09): same wire-shape bridge as connection_get — the
662
+ // preview target read auth_type off the raw camelCase wire shape.
663
+ const conn = rawConn
664
+ ? normalizeConnectionFromWire(rawConn)
665
+ : undefined;
413
666
  return formatResult({
414
667
  preview: true,
415
668
  warning: "DESTRUCTIVE — Connection delete cannot be undone. Dependent flows will break.",
@@ -425,14 +678,17 @@ export function registerConnectionTools(server, elicitation) {
425
678
  instruction: "Ask user to confirm, then call again with confirm: true.",
426
679
  });
427
680
  }
428
- const r = await request(`/connections/${encodeURIComponent(id)}`, { method: "DELETE" });
681
+ // Phase 2.7 implicit action (`connection.deleted`); attach `change:
682
+ // { summary }` only when supplied so the request is unchanged otherwise.
683
+ const r = await request(`/connections/${encodeURIComponent(id)}`, summary !== undefined
684
+ ? {
685
+ method: "DELETE",
686
+ body: JSON.stringify({ change: { summary } }),
687
+ headers: { "Content-Type": "application/json" },
688
+ }
689
+ : { method: "DELETE" });
429
690
  if (!r.success) {
430
- return errorResult({
431
- message: r.error ?? "connection delete failed",
432
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
433
- suggestion: hintFor(r.errorCode),
434
- details: { id },
435
- });
691
+ return restErrorResult(r, { id }, { message: "connection delete failed" });
436
692
  }
437
693
  return formatResult({ deleted: true, id }, false, { maxChars: 1500 });
438
694
  });
@@ -453,13 +709,13 @@ export function registerConnectionTools(server, elicitation) {
453
709
  // probe fields still live under `data.data.*`. Audit: F-A1-05.
454
710
  const r = await request("/connections/test", { method: "POST", body: JSON.stringify({ connection_id: id }) }, { unwrapInnerSuccess: true });
455
711
  if (!r.success) {
456
- return errorResult({
457
- message: r.error ?? "connection test failed",
458
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
712
+ return restErrorResult(r, { id, payloadFailed: r.payloadFailed }, {
713
+ message: "connection test failed",
714
+ // Per-site nuance: a 200 + success:false (payloadFailed) probe gets
715
+ // an upstream-status hint; everything else falls back to hintFor().
459
716
  suggestion: r.payloadFailed
460
717
  ? "Probe ran but reported failure — check the upstream API status, credentials, or connection endpoint."
461
718
  : hintFor(r.errorCode),
462
- details: { id, payloadFailed: r.payloadFailed },
463
719
  });
464
720
  }
465
721
  // Defensive unwrap: prefer `r.data.data` if present, fall back to `r.data` itself
@@ -494,12 +750,7 @@ export function registerConnectionTools(server, elicitation) {
494
750
  body: JSON.stringify({ connection_ids }),
495
751
  });
496
752
  if (!r.success) {
497
- return errorResult({
498
- message: r.error ?? "connection health check failed",
499
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
500
- suggestion: hintFor(r.errorCode),
501
- details: { count: connection_ids.length },
502
- });
753
+ return restErrorResult(r, { count: connection_ids.length }, { message: "connection health check failed" });
503
754
  }
504
755
  const data = (r.data && typeof r.data === "object") ? r.data : {};
505
756
  // PHP returns map keyed by connection id, NOT under a 'results' key —
@@ -533,6 +784,11 @@ export function registerConnectionTools(server, elicitation) {
533
784
  "the response items in-memory — this does NOT trigger multi-page fetch upstream (the " +
534
785
  "request is single-shot). Use `fields` to project each item down to a whitelist of leaf " +
535
786
  "keys, cutting response size on sparse queries." +
787
+ "\n\nF144: if a library/template connection returns a 'Please configure …' / 'Please select an " +
788
+ "endpoint' diagnostic, the missing values are TEMPLATE FIELDS — supply them here via " +
789
+ "`template_fields` (e.g., { spreadsheet_id: '…' }) rather than re-creating the connection or " +
790
+ "starting a new OAuth flow. A connection whose template_fields are already resolved IS " +
791
+ "configured; inspect them with apimapper_connection_get({ id })." +
536
792
  "\n\nExample:\n" +
537
793
  " apimapper_connection_data({ id: 'con_abc123', limit: 10 }) // first 10 items\n" +
538
794
  " apimapper_connection_data({ id: 'con_abc123', limit: 5, offset: 10 }) // skip 10, take 5\n" +
@@ -545,7 +801,11 @@ export function registerConnectionTools(server, elicitation) {
545
801
  .trim()
546
802
  .min(1)
547
803
  .optional()
548
- .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.'),
804
+ .describe('Named endpoint SELECTION NOT a URL. This is the label of a configured endpoint on the ' +
805
+ 'connection (e.g., "Scheduled Events", "Get Values"), the same names connection_create exposes ' +
806
+ 'as endpoints[]. List them via apimapper_connection_get({ id }). Empty-string and ' +
807
+ 'whitespace-only values are rejected at the schema boundary to prevent "?endpoint=" query ' +
808
+ "garbage; omit the field entirely to use the connection default."),
549
809
  template_fields: z
550
810
  .record(z.string(), z.union([z.string(), z.number().finite(), z.boolean()]))
551
811
  .optional()
@@ -587,19 +847,24 @@ export function registerConnectionTools(server, elicitation) {
587
847
  // implicitly, but the explicit guard documents the contract and keeps
588
848
  // TypeScript happy under the wider schema.
589
849
  if (template_fields) {
590
- for (const [k, v] of Object.entries(template_fields)) {
850
+ // A5 (2026-06-10): normalize a pasted Drive/Sheets URL in
851
+ // template_fields.spreadsheet_id to the bare ID before it becomes a
852
+ // query param — the {{spreadsheet_id}} placeholder needs the bare id.
853
+ const normalizedFields = normalizeResourceIdFields(template_fields);
854
+ for (const [k, v] of Object.entries(normalizedFields)) {
591
855
  params.set(k, typeof v === "string" ? v : String(v));
592
856
  }
593
857
  }
594
858
  const qs = params.toString();
595
- const r = await request(`/connections/${encodeURIComponent(id)}/data${qs ? `?${qs}` : ""}`);
859
+ // S-MED-1 (Wave-B 2026-06-03): sanitize the data read. The {…,connection}
860
+ // envelope echoes the connection config (incl. `headers`/`params` arrays
861
+ // that can hold an inline Authorization / X-API-Key value); {sanitize:true}
862
+ // scrubs it — and {name,value} header pairs — before the payload crosses
863
+ // the MCP boundary. The data rows themselves are customer data and are not
864
+ // mutated by the secret-only sanitizer.
865
+ const r = await request(`/connections/${encodeURIComponent(id)}/data${qs ? `?${qs}` : ""}`, {}, { sanitize: true });
596
866
  if (!r.success) {
597
- return errorResult({
598
- message: r.error ?? "connection data fetch failed",
599
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
600
- suggestion: hintFor(r.errorCode),
601
- details: { id, endpoint, template_fields, limit, offset },
602
- });
867
+ return restErrorResult(r, { id, endpoint, template_fields, limit, offset }, { message: "connection data fetch failed" });
603
868
  }
604
869
  // PHP success body: {data:[…items…], connection, body?, content_type?, status?}.
605
870
  // Operate on the inner `data` payload, not on the envelope. Surface
@@ -608,6 +873,22 @@ export function registerConnectionTools(server, elicitation) {
608
873
  const innerRaw = "data" in envelope ? envelope.data : envelope;
609
874
  const body = typeof envelope.body === "string" ? envelope.body : undefined;
610
875
  const contentType = typeof envelope.content_type === "string" ? envelope.content_type : undefined;
876
+ // Silent-zero UX gap (2026-06, PR #732 follow-through) — on a 0-row
877
+ // read the PHP /connections/{id}/data handler attaches a structured
878
+ // `diagnostic` ({reason, message}) explaining WHY nothing showed
879
+ // (unresolved endpoint placeholders, items_path mismatch, or a genuine
880
+ // empty result). Forward it additively into the tool output so the AI
881
+ // agent sees the explanation instead of a silent {item_count:0,
882
+ // payload:[]}. Present only when the upstream supplied it (i.e. on
883
+ // empty results), so the non-empty envelope shape is unchanged. We
884
+ // keep the value verbatim — the PHP EmptyResultDiagnostic::classify
885
+ // owns the {reason, message} contract; the TS layer is a pure
886
+ // passthrough and must not reshape it.
887
+ const diagnostic = envelope.diagnostic !== null &&
888
+ typeof envelope.diagnostic === "object" &&
889
+ !Array.isArray(envelope.diagnostic)
890
+ ? envelope.diagnostic
891
+ : undefined;
611
892
  // rc.10.1 C + rc.13 W1.2 (F-01+F-38) — apply in-memory trim via
612
893
  // extracted pure function. See the `applyTrim` JSDoc in
613
894
  // connections-trim.ts for the shape-detection contract and
@@ -633,6 +914,25 @@ export function registerConnectionTools(server, elicitation) {
633
914
  ? pickFields(item, fields)
634
915
  : item);
635
916
  }
917
+ // F198 (Desktop-E2E #2 forensics, 2026-06-12): a 0-row read used to ship
918
+ // the full {ok, shape, item_count, payload:[], body, content_type}
919
+ // envelope — pure overhead when there is nothing to show. Collapse it to
920
+ // a compact 1-liner that names the likely culprits (template_fields /
921
+ // range) and forwards the upstream diagnostic verbatim when present.
922
+ // Only the array-empty case collapses; an empty object/scalar payload is
923
+ // a different (rare) shape and keeps the full envelope.
924
+ if (Array.isArray(trim.payload) && trim.itemCount === 0) {
925
+ const diagnosticMsg = diagnostic && typeof diagnostic.message === "string"
926
+ ? ` ${diagnostic.message}`
927
+ : "";
928
+ return formatResult({
929
+ ok: true,
930
+ item_count: 0,
931
+ note: "0 rows — check template_fields (e.g. spreadsheet_id / range) or the connection " +
932
+ "endpoint; sample the config with apimapper_connection_get({ id })." +
933
+ diagnosticMsg,
934
+ }, false, { maxChars: 600 });
935
+ }
636
936
  return formatResult({
637
937
  ok: true,
638
938
  // F-38 — surface a stable, narrow shape enum instead of typeof.
@@ -646,6 +946,11 @@ export function registerConnectionTools(server, elicitation) {
646
946
  // W3.3 — echo the requested whitelist so the AI knows the
647
947
  // per-item shape was (or was meant to be) reduced.
648
948
  ...(fields ? { projected_fields: fields } : {}),
949
+ // Silent-zero UX gap (2026-06) — forward the upstream
950
+ // EmptyResultDiagnostic so a 0-row read explains WHY. Spread
951
+ // additively: absent on the happy path, so existing non-empty
952
+ // wire-shape pins are unchanged.
953
+ ...(diagnostic ? { diagnostic } : {}),
649
954
  body,
650
955
  content_type: contentType,
651
956
  payload: projectedPayload,
@@ -656,12 +961,14 @@ export function registerConnectionTools(server, elicitation) {
656
961
  title: "List Connection Resources",
657
962
  description: "List browseable resources on a connection (drive files, sheets, IG media). " +
658
963
  "REST contract: `field` (resource-picker field name) + optional `query` filter." +
659
- "\n\nExample:\n apimapper_connection_resources({ id: 'con_drive_xyz', field: 'fileId', query: 'budget' })",
964
+ // F213: gateway-only tool show the call routed THROUGH the read
965
+ // gateway so the first live invocation doesn't fail invalid_arguments.
966
+ "\n\nExample (gateway-routed):\n apimapper_advanced_read({ tool: \"apimapper_connection_resources\", arguments: { id: \"con_drive_xyz\", field: \"fileId\", query: \"budget\" } })",
660
967
  inputSchema: {
661
968
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
662
969
  field: z
663
970
  .string()
664
- .describe('Resource-picker field name (e.g., "spreadsheet_id", "drive_file_id"). REST wire-key: field.'),
971
+ .describe('REQUIRED — resource-picker field name (e.g., "spreadsheet_id", "drive_file_id"). REST wire-key: field.'),
665
972
  // rc.10 A4 (2026-05-19): min(3) blocks the single-char "p", "j", "u"
666
973
  // probing pattern observed in Maria-walkthrough logs.
667
974
  // rc.13 W1.17 (F-29): Master-Doc-Drift — Master requested min(2), kept
@@ -678,12 +985,7 @@ export function registerConnectionTools(server, elicitation) {
678
985
  params.set("query", query);
679
986
  const r = await request(`/connections/${encodeURIComponent(id)}/resources?${params.toString()}`);
680
987
  if (!r.success) {
681
- return errorResult({
682
- message: r.error ?? "connection resources failed",
683
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
684
- suggestion: hintFor(r.errorCode),
685
- details: { id, field, query },
686
- });
988
+ return restErrorResult(r, { id, field, query }, { message: "connection resources failed" });
687
989
  }
688
990
  // A1-P3-1: pure helper handles the three wire-shapes (bare array,
689
991
  // `{ resources }`, `{ items }`) + fallback to `[]` for unknown
@@ -711,32 +1013,52 @@ export function registerConnectionTools(server, elicitation) {
711
1013
  // ── apimapper_connection_pipeline_update ───────────────────────────
712
1014
  server.registerTool("apimapper_connection_pipeline_update", {
713
1015
  title: "Update Connection Pipeline",
714
- description: "Update the connection's pipeline (endpoints, default_params, headers, items_path, items_shape)." +
715
- "\n\nExample:\n apimapper_connection_pipeline_update({ id: 'con_abc123', items_path: 'photos', items_shape: 'auto', default_params: { per_page: '20' } })",
1016
+ description: "Update the connection's pipeline (endpoints, default_params, default_headers, headers, items_path, items_shape, body, body_type, graphql_variables, response_type)." +
1017
+ "\n\nWorks on connections WITHOUT a stored pipeline: the protocol keys version/enabled/steps are NOT required — send only the connection-column keys you care about (F184)." +
1018
+ "\n\nPARTIAL UPDATES ARE SAFE (F145) — send only the keys you want to change:" +
1019
+ "\n • Object-map keys (default_params, default_headers, headers, graphql_variables) MERGE into the stored map: changing one entry preserves the others. Pass a value of null to delete a single stored entry (e.g. { default_params: { stale_key: null } })." +
1020
+ "\n • Scalar keys (items_path, items_shape, body, body_type, response_type) replace their single value." +
1021
+ "\n • List keys (endpoints) are REPLACED wholesale (positional merge is ambiguous) — resend every endpoint you want to keep. If a replace drops endpoints, the response carries a `warnings` array naming what was removed." +
1022
+ "\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)." +
1023
+ "\n\nExample (partial — preserves any other stored params):\n apimapper_connection_pipeline_update({ id: 'con_abc123', pipeline: { items_path: 'photos', items_shape: 'auto', default_params: { per_page: '20' } } })" +
1024
+ "\n\nGoogle-Sheets 2D array example:\n apimapper_connection_pipeline_update({ id: 'con_sheet', pipeline: { items_shape: 'headers_rows' } })",
716
1025
  inputSchema: {
717
1026
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
718
1027
  pipeline: z
719
1028
  .record(z.string(), z.unknown())
720
- .describe('Pipeline JSON (e.g., {"endpoints":[...], "items_path":"data", "items_shape":"flat"})'),
1029
+ .describe('Pipeline JSON (partial allowed). Accepts connection-column keys: endpoints, default_params, default_headers, headers, items_path, items_shape, body, body_type, graphql_variables, response_type. ' +
1030
+ 'Object-map keys (default_params/default_headers/headers/graphql_variables) MERGE into stored values (null deletes one entry); scalar keys replace; list keys (endpoints) replace wholesale and report dropped entries in a warnings array. ' +
1031
+ 'items_shape options: "flat" | "auto" | "headers_rows" | "object_keys". ' +
1032
+ 'Example: {"items_path":"data","items_shape":"headers_rows"}.'),
721
1033
  },
722
1034
  annotations: mutating({ title: "Update Connection Pipeline", openWorld: true }),
723
1035
  }, async ({ id, pipeline }) => {
724
1036
  // PHP ConnectionPipelineHandler uses fromControllerResponse(_, 'connection')
725
- // → {success, connection:{…}}. Unwrap defensively. Audit: F-A1-07.
1037
+ // → {success, connection:{…}}. F145: a list-key (endpoints) full-replace
1038
+ // also carries a top-level `warnings` array naming dropped entries.
1039
+ // Unwrap defensively. Audit: F-A1-07.
726
1040
  const r = await request(`/connections/${encodeURIComponent(id)}/pipeline`, {
727
1041
  method: "PUT",
728
1042
  body: JSON.stringify(pipeline),
729
1043
  });
730
1044
  if (!r.success) {
731
- return errorResult({
732
- message: r.error ?? "connection pipeline update failed",
733
- code: r.errorCode ?? (r.status ? String(r.status) : undefined),
734
- suggestion: hintFor(r.errorCode),
735
- details: { id },
736
- });
1045
+ return restErrorResult(r, { id }, { message: "connection pipeline update failed" });
737
1046
  }
738
1047
  const conn = unwrapEntity(r.data, "connection");
739
- return formatResult({ updated: true, id: conn?.id, name: conn?.name }, false, { maxChars: 2000 });
1048
+ // Surface F145 dropped-entry warnings so the agent never silently loses
1049
+ // endpoints on a partial replace.
1050
+ const warnings = r.data && typeof r.data === "object" && Array.isArray(r.data.warnings)
1051
+ ? r.data.warnings
1052
+ : undefined;
1053
+ const payload = {
1054
+ updated: true,
1055
+ id: conn?.id,
1056
+ name: conn?.name,
1057
+ };
1058
+ if (warnings && warnings.length > 0) {
1059
+ payload.warnings = warnings;
1060
+ }
1061
+ return formatResult(payload, false, { maxChars: 2000 });
740
1062
  });
741
1063
  }
742
1064
  //# sourceMappingURL=connections.js.map