@wootsup/mcp 0.1.0-rc.8 → 0.1.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 (171) hide show
  1. package/README.md +7 -7
  2. package/dist/index.d.ts +19 -0
  3. package/dist/index.js +72 -6
  4. package/dist/index.js.map +1 -1
  5. package/dist/modules/apimapper/cache.d.ts +2 -2
  6. package/dist/modules/apimapper/cache.js +107 -25
  7. package/dist/modules/apimapper/cache.js.map +1 -1
  8. package/dist/modules/apimapper/client.d.ts +40 -0
  9. package/dist/modules/apimapper/client.js +82 -12
  10. package/dist/modules/apimapper/client.js.map +1 -1
  11. package/dist/modules/apimapper/connections-format.d.ts +51 -0
  12. package/dist/modules/apimapper/connections-format.js +261 -0
  13. package/dist/modules/apimapper/connections-format.js.map +1 -0
  14. package/dist/modules/apimapper/connections-trim.d.ts +82 -0
  15. package/dist/modules/apimapper/connections-trim.js +224 -0
  16. package/dist/modules/apimapper/connections-trim.js.map +1 -0
  17. package/dist/modules/apimapper/connections.d.ts +14 -2
  18. package/dist/modules/apimapper/connections.js +447 -143
  19. package/dist/modules/apimapper/connections.js.map +1 -1
  20. package/dist/modules/apimapper/credentials-format.d.ts +21 -0
  21. package/dist/modules/apimapper/credentials-format.js +145 -0
  22. package/dist/modules/apimapper/credentials-format.js.map +1 -0
  23. package/dist/modules/apimapper/credentials.d.ts +12 -2
  24. package/dist/modules/apimapper/credentials.js +253 -72
  25. package/dist/modules/apimapper/credentials.js.map +1 -1
  26. package/dist/modules/apimapper/diagnose.d.ts +54 -2
  27. package/dist/modules/apimapper/diagnose.js +193 -11
  28. package/dist/modules/apimapper/diagnose.js.map +1 -1
  29. package/dist/modules/apimapper/elicitation.d.ts +54 -0
  30. package/dist/modules/apimapper/elicitation.js +90 -0
  31. package/dist/modules/apimapper/elicitation.js.map +1 -0
  32. package/dist/modules/apimapper/flows-format.d.ts +50 -0
  33. package/dist/modules/apimapper/flows-format.js +318 -0
  34. package/dist/modules/apimapper/flows-format.js.map +1 -0
  35. package/dist/modules/apimapper/flows.d.ts +13 -2
  36. package/dist/modules/apimapper/flows.js +325 -118
  37. package/dist/modules/apimapper/flows.js.map +1 -1
  38. package/dist/modules/apimapper/gateway/advanced-tool.d.ts +9 -0
  39. package/dist/modules/apimapper/gateway/advanced-tool.js +214 -0
  40. package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -0
  41. package/dist/modules/apimapper/gateway/capturing-server.d.ts +81 -0
  42. package/dist/modules/apimapper/gateway/capturing-server.js +87 -0
  43. package/dist/modules/apimapper/gateway/capturing-server.js.map +1 -0
  44. package/dist/modules/apimapper/gateway/essentials.d.ts +4 -0
  45. package/dist/modules/apimapper/gateway/essentials.js +28 -0
  46. package/dist/modules/apimapper/gateway/essentials.js.map +1 -0
  47. package/dist/modules/apimapper/gateway/test-support.d.ts +17 -0
  48. package/dist/modules/apimapper/gateway/test-support.js +43 -0
  49. package/dist/modules/apimapper/gateway/test-support.js.map +1 -0
  50. package/dist/modules/apimapper/get-skill.d.ts +3 -3
  51. package/dist/modules/apimapper/get-skill.js +4 -2
  52. package/dist/modules/apimapper/get-skill.js.map +1 -1
  53. package/dist/modules/apimapper/graph-builder.js +1 -1
  54. package/dist/modules/apimapper/graph-builder.js.map +1 -1
  55. package/dist/modules/apimapper/graph.d.ts +2 -2
  56. package/dist/modules/apimapper/graph.js +165 -34
  57. package/dist/modules/apimapper/graph.js.map +1 -1
  58. package/dist/modules/apimapper/index.d.ts +17 -1
  59. package/dist/modules/apimapper/index.js +66 -17
  60. package/dist/modules/apimapper/index.js.map +1 -1
  61. package/dist/modules/apimapper/inspect.d.ts +3 -2
  62. package/dist/modules/apimapper/inspect.js +97 -13
  63. package/dist/modules/apimapper/inspect.js.map +1 -1
  64. package/dist/modules/apimapper/library.d.ts +2 -2
  65. package/dist/modules/apimapper/library.js +303 -60
  66. package/dist/modules/apimapper/library.js.map +1 -1
  67. package/dist/modules/apimapper/license-format.d.ts +22 -0
  68. package/dist/modules/apimapper/license-format.js +149 -0
  69. package/dist/modules/apimapper/license-format.js.map +1 -0
  70. package/dist/modules/apimapper/license.d.ts +16 -2
  71. package/dist/modules/apimapper/license.js +85 -37
  72. package/dist/modules/apimapper/license.js.map +1 -1
  73. package/dist/modules/apimapper/local-sources.d.ts +2 -2
  74. package/dist/modules/apimapper/local-sources.js +58 -30
  75. package/dist/modules/apimapper/local-sources.js.map +1 -1
  76. package/dist/modules/apimapper/misc.d.ts +30 -2
  77. package/dist/modules/apimapper/misc.js +129 -50
  78. package/dist/modules/apimapper/misc.js.map +1 -1
  79. package/dist/modules/apimapper/node-schema.d.ts +52 -0
  80. package/dist/modules/apimapper/node-schema.js +70 -2
  81. package/dist/modules/apimapper/node-schema.js.map +1 -1
  82. package/dist/modules/apimapper/normalizers.d.ts +1 -0
  83. package/dist/modules/apimapper/normalizers.js +51 -0
  84. package/dist/modules/apimapper/normalizers.js.map +1 -1
  85. package/dist/modules/apimapper/onboarding.d.ts +48 -2
  86. package/dist/modules/apimapper/onboarding.js +324 -17
  87. package/dist/modules/apimapper/onboarding.js.map +1 -1
  88. package/dist/modules/apimapper/read-cache.d.ts +31 -2
  89. package/dist/modules/apimapper/read-cache.js +20 -6
  90. package/dist/modules/apimapper/read-cache.js.map +1 -1
  91. package/dist/modules/apimapper/render/_shared.d.ts +24 -0
  92. package/dist/modules/apimapper/render/_shared.js +84 -0
  93. package/dist/modules/apimapper/render/_shared.js.map +1 -0
  94. package/dist/modules/apimapper/render/dag.d.ts +18 -0
  95. package/dist/modules/apimapper/render/dag.js +70 -0
  96. package/dist/modules/apimapper/render/dag.js.map +1 -0
  97. package/dist/modules/apimapper/render/index.d.ts +2 -0
  98. package/dist/modules/apimapper/render/index.js +112 -0
  99. package/dist/modules/apimapper/render/index.js.map +1 -0
  100. package/dist/modules/apimapper/render/renderers/chart-bar.d.ts +2 -0
  101. package/dist/modules/apimapper/render/renderers/chart-bar.js +70 -0
  102. package/dist/modules/apimapper/render/renderers/chart-bar.js.map +1 -0
  103. package/dist/modules/apimapper/render/renderers/chart-line.d.ts +2 -0
  104. package/dist/modules/apimapper/render/renderers/chart-line.js +71 -0
  105. package/dist/modules/apimapper/render/renderers/chart-line.js.map +1 -0
  106. package/dist/modules/apimapper/render/renderers/diff.d.ts +2 -0
  107. package/dist/modules/apimapper/render/renderers/diff.js +154 -0
  108. package/dist/modules/apimapper/render/renderers/diff.js.map +1 -0
  109. package/dist/modules/apimapper/render/renderers/flow-diagram.d.ts +1 -0
  110. package/dist/modules/apimapper/render/renderers/flow-diagram.js +180 -0
  111. package/dist/modules/apimapper/render/renderers/flow-diagram.js.map +1 -0
  112. package/dist/modules/apimapper/render/renderers/json-tree.d.ts +2 -0
  113. package/dist/modules/apimapper/render/renderers/json-tree.js +87 -0
  114. package/dist/modules/apimapper/render/renderers/json-tree.js.map +1 -0
  115. package/dist/modules/apimapper/render/renderers/schema-diagram.d.ts +2 -0
  116. package/dist/modules/apimapper/render/renderers/schema-diagram.js +83 -0
  117. package/dist/modules/apimapper/render/renderers/schema-diagram.js.map +1 -0
  118. package/dist/modules/apimapper/render/renderers/table.d.ts +2 -0
  119. package/dist/modules/apimapper/render/renderers/table.js +75 -0
  120. package/dist/modules/apimapper/render/renderers/table.js.map +1 -0
  121. package/dist/modules/apimapper/render/schemas.d.ts +23 -0
  122. package/dist/modules/apimapper/render/schemas.js +56 -0
  123. package/dist/modules/apimapper/render/schemas.js.map +1 -0
  124. package/dist/modules/apimapper/render/secret-masking.d.ts +5 -0
  125. package/dist/modules/apimapper/render/secret-masking.js +51 -0
  126. package/dist/modules/apimapper/render/secret-masking.js.map +1 -0
  127. package/dist/modules/apimapper/render/sidecar.d.ts +21 -0
  128. package/dist/modules/apimapper/render/sidecar.js +66 -0
  129. package/dist/modules/apimapper/render/sidecar.js.map +1 -0
  130. package/dist/modules/apimapper/render/token-cap.d.ts +21 -0
  131. package/dist/modules/apimapper/render/token-cap.js +57 -0
  132. package/dist/modules/apimapper/render/token-cap.js.map +1 -0
  133. package/dist/modules/apimapper/schema.d.ts +2 -2
  134. package/dist/modules/apimapper/schema.js +100 -32
  135. package/dist/modules/apimapper/schema.js.map +1 -1
  136. package/dist/modules/apimapper/settings-format.d.ts +23 -0
  137. package/dist/modules/apimapper/settings-format.js +135 -0
  138. package/dist/modules/apimapper/settings-format.js.map +1 -0
  139. package/dist/modules/apimapper/settings.d.ts +2 -2
  140. package/dist/modules/apimapper/settings.js +101 -40
  141. package/dist/modules/apimapper/settings.js.map +1 -1
  142. package/dist/modules/apimapper/skill-resources.d.ts +2 -2
  143. package/dist/modules/apimapper/skill-resources.js.map +1 -1
  144. package/dist/modules/apimapper/token-baseline.harness.d.ts +91 -0
  145. package/dist/modules/apimapper/token-baseline.harness.js +291 -0
  146. package/dist/modules/apimapper/token-baseline.harness.js.map +1 -0
  147. package/dist/modules/apimapper/toolslist-size.d.ts +55 -0
  148. package/dist/modules/apimapper/toolslist-size.js +190 -0
  149. package/dist/modules/apimapper/toolslist-size.js.map +1 -0
  150. package/dist/modules/apimapper/types.d.ts +23 -8
  151. package/dist/modules/apimapper/types.js +26 -1
  152. package/dist/modules/apimapper/types.js.map +1 -1
  153. package/dist/modules/apimapper/use-profile.d.ts +21 -0
  154. package/dist/modules/apimapper/use-profile.js +56 -2
  155. package/dist/modules/apimapper/use-profile.js.map +1 -1
  156. package/dist/modules/apimapper/workflows.d.ts +2 -2
  157. package/dist/modules/apimapper/workflows.js +143 -16
  158. package/dist/modules/apimapper/workflows.js.map +1 -1
  159. package/dist/platform/index.js +44 -5
  160. package/dist/platform/index.js.map +1 -1
  161. package/dist/setup-cli.d.ts +53 -0
  162. package/dist/setup-cli.js +135 -6
  163. package/dist/setup-cli.js.map +1 -1
  164. package/docs/architecture.md +1 -1
  165. package/docs/tools.md +1 -1
  166. package/manifest.json +12 -3
  167. package/package.json +9 -4
  168. package/skills/apimapper/SKILL.md +1 -1
  169. package/skills/apimapper/reference/render.md +132 -0
  170. package/skills/apimapper/reference/troubleshooting.md +1 -1
  171. package/skills/apimapper/reference/yootheme.md +1 -1
@@ -1,23 +1,127 @@
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, errorWithSuggestion, pickFields, createProgressReporter, elicitChoice, } from "@getimo/mcp-toolkit";
3
3
  import { request, hintFor, WP_BASE, WP_USER, authConfigured } from "./client.js";
4
+ import { toRows } from "./types.js";
4
5
  import { unwrapEntity } from "./envelope.js";
5
- export function registerConnectionTools(server) {
6
+ import { ambiguityFallbackError, } from "./elicitation.js";
7
+ import { fetchWithTimeout } from "./diagnose.js";
8
+ 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
+ // W3 Stage-2 hardening — applyTrim + extractResourceList live in the
11
+ // sibling connections-trim.ts (pure functions, ~200 lines). Imported for
12
+ // internal use by the connection_data + connection_resources handlers,
13
+ // and re-exported so the public surface
14
+ // `import { ... } from "./connections.js"` stays unchanged for existing
15
+ // callers and the test suite.
16
+ import { applyTrim, extractResourceList, } from "./connections-trim.js";
17
+ export { applyTrim, extractResourceList };
18
+ // F-LS-04 (live-smoke 2026-05-20) — the PHP /connections endpoint emits
19
+ // camelCase keys (authType, credentialId, ...). Every formatter + consumer
20
+ // in this module reads snake_case. The wire-shape normaliser bridges
21
+ // the two so the AI no longer sees auth=none / credential=— on real
22
+ // upstream connections. Applied at the connection_list handler — that is
23
+ // the only tool that fans out raw connection items to a downstream
24
+ // formatter; connection_get / _update / _delete pass through unwrapEntity
25
+ // + the rich-card builder which already reads from the same row map.
26
+ import { normalizeConnectionFromWire } from "./normalizers.js";
27
+ /**
28
+ * F-15 (W1.9) — downstream probe of api.wootsup.com from `apimapper_health`.
29
+ *
30
+ * The customer's WP-REST probe alone cannot distinguish "your server is fine
31
+ * but getimo's license/release infra is degraded" from a generic failure.
32
+ * Hitting the upstream `/health` endpoint surfaces that signal so an AI
33
+ * client can route the user to the right troubleshooting branch.
34
+ */
35
+ const APIMAPPER_API_HEALTH_URL = "https://api.wootsup.com/health";
36
+ /**
37
+ * 3-second budget — health is meant to be a fast snapshot. Shorter than
38
+ * diagnose's 5s because diagnose probes a customer-controlled server (which
39
+ * may legitimately be slow under load), while api.wootsup.com is
40
+ * getimo-controlled CDN-backed infra that should respond in <500ms.
41
+ */
42
+ const APIMAPPER_API_HEALTH_TIMEOUT_MS = 3_000;
43
+ /**
44
+ * F-15: probe api.wootsup.com/health and map the outcome to a stable
45
+ * status-string. NEVER throws — the health tool's contract is to surface
46
+ * downstream failure as data, not to fail itself when the downstream is
47
+ * unreachable. The four return values form a closed enum:
48
+ *
49
+ * - "OK" — 2xx response from api.wootsup.com
50
+ * - "HTTP N" — non-2xx response (e.g. "HTTP 503")
51
+ * - "timeout" — AbortError/TimeoutError after 3s
52
+ * - "unreachable" — any other fetch error (DNS, refused, TLS, etc.)
53
+ *
54
+ * Reuses fetchWithTimeout from diagnose.ts so the AbortSignal.timeout
55
+ * wiring lives in exactly one place (DRY across probe call sites).
56
+ */
57
+ async function probeApimapperApi() {
58
+ try {
59
+ const res = await fetchWithTimeout(APIMAPPER_API_HEALTH_URL, { method: "GET" }, APIMAPPER_API_HEALTH_TIMEOUT_MS);
60
+ if (res.ok)
61
+ return "OK";
62
+ return `HTTP ${res.status}`;
63
+ }
64
+ catch (e) {
65
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
66
+ return "timeout";
67
+ }
68
+ return "unreachable";
69
+ }
70
+ }
71
+ /**
72
+ * Register the connection CRUD + probe + sample + pipeline tools.
73
+ *
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.
79
+ */
80
+ export function registerConnectionTools(server, elicitation) {
6
81
  // ── apimapper_health ───────────────────────────────────────────────
7
82
  server.registerTool("apimapper_health", {
8
83
  title: "API Mapper REST Health",
9
84
  description: "Check connectivity + auth against the API Mapper REST namespace. " +
10
- "Returns {wp_base, wp_user, auth, connectivity, connection_count, http_status}. " +
85
+ "Returns {wp_base, wp_user, auth, connectivity, connection_count, http_status, apimapper_api_status}. " +
86
+ "`apimapper_api_status` reports the downstream api.wootsup.com health (OK/HTTP N/timeout/unreachable) " +
87
+ "so a degradation of getimo infra is distinguishable from a customer-server issue. " +
11
88
  "Run this first if any other tool fails." +
12
89
  "\n\nExample:\n apimapper_health({})",
13
90
  inputSchema: {},
14
- annotations: readOnly(),
91
+ annotations: readOnly({ title: "Health Check", openWorld: true }),
15
92
  }, async () => {
16
93
  const checks = {
17
94
  wp_base: WP_BASE,
18
95
  wp_user: WP_USER,
19
96
  auth: authConfigured() ? "configured" : "MISSING (set APIMAPPER_WP_APP_PASS)",
20
97
  };
98
+ // L-5 (W3F-5): when the customer uses bearer-only auth (the modern
99
+ // `amk_live_…` flow), APIMAPPER_WP_USER is intentionally unset, so
100
+ // the previous render emitted `WP user: ""`. Probe `/identity` and
101
+ // fall back to its `.username` field — it's anon-readable on both
102
+ // platforms and never throws. The probe is cheap (single GET) and
103
+ // runs alongside the existing api.wootsup.com probe.
104
+ if (checks.wp_user === "") {
105
+ try {
106
+ const idRes = await request("/identity");
107
+ const fromIdentity = idRes.success && typeof idRes.data?.username === "string"
108
+ ? idRes.data.username
109
+ : "";
110
+ if (fromIdentity !== "") {
111
+ checks.wp_user = fromIdentity;
112
+ }
113
+ }
114
+ catch {
115
+ // Identity probe failure is non-fatal — the rest of the
116
+ // health snapshot still surfaces.
117
+ }
118
+ }
119
+ // F-15 (W1.9) — downstream api.wootsup.com probe. Runs before the
120
+ // WP-REST check; both are independent, but sequential is cheap (3s
121
+ // worst-case for the api probe, then the WP probe) and keeps the
122
+ // result ordering deterministic. Probe never throws — surfaces as
123
+ // status string per probeApimapperApi contract.
124
+ checks.apimapper_api_status = await probeApimapperApi();
21
125
  try {
22
126
  const r = await request("/connections");
23
127
  checks.http_status = r.status ?? 0;
@@ -35,60 +139,74 @@ export function registerConnectionTools(server) {
35
139
  const err = errorWithSuggestion(e, { healthTool: "apimapper_health" });
36
140
  checks.connectivity = `ERROR: ${err.message}`;
37
141
  }
38
- return formatResult(checks, false, { maxChars: 2000 });
142
+ // W3.1 health snapshot is a flat scalar set: statsResult is the
143
+ // goldstandard fit. Every check becomes a stat so an AI client reads
144
+ // connectivity/auth/downstream state without parsing free text.
145
+ return buildHealthStats(checks);
39
146
  });
40
147
  // ── apimapper_connection_list ──────────────────────────────────────
41
148
  server.registerTool("apimapper_connection_list", {
42
149
  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 })",
150
+ // rc.10 A5 (2026-05-19) description states the default limit
151
+ // already covers a typical install. The Maria-walkthrough log
152
+ // 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",
45
158
  inputSchema: {
46
159
  source: z
47
160
  .enum(["library", "demo", "user", "all"])
48
161
  .default("all")
49
162
  .describe("Filter by origin"),
50
- limit: z.number().min(1).max(500).default(50).describe("Max items (1-500)"),
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."),
164
+ // W1.18 (F-32) — case-insensitive substring filter applied
165
+ // in-memory AFTER the upstream fetch (no per-name REST call).
166
+ // Min 2 chars at the schema boundary blocks the single-char
167
+ // probe-thrash pattern observed in the Maria-walkthrough logs.
168
+ name_query: z
169
+ .string()
170
+ .min(2)
171
+ .optional()
172
+ .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
173
  },
52
- annotations: readOnly(),
53
- }, async ({ source, limit }) => {
174
+ annotations: readOnly({ title: "List Connections", openWorld: true }),
175
+ }, async ({ source, limit, name_query }) => {
54
176
  const r = await request("/connections");
55
177
  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);
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
+ });
63
184
  }
64
- let items = Array.isArray(r.data?.connections) ? r.data.connections : [];
185
+ // F-LS-04 normalise the camelCase wire shape (authType, credentialId,
186
+ // …) to snake_case BEFORE anything downstream reads it. The
187
+ // `Connection` domain type uses snake_case keys, so the result is
188
+ // safely cast back: `normalizeConnectionFromWire` only adds keys, it
189
+ // never drops them, and existing snake_case values win on collision.
190
+ const rawItems = Array.isArray(r.data?.connections) ? r.data.connections : [];
191
+ let items = rawItems.map((c) => normalizeConnectionFromWire(c));
65
192
  if (source !== "all")
66
193
  items = items.filter((c) => c.source === source);
194
+ // W1.18 (F-32) — filter BEFORE slice so limit applies to the matched
195
+ // subset, not the haystack. See `filterByNameQuery` JSDoc for the
196
+ // case-folding + undefined-skip contract shared with flow_list.
197
+ items = filterByNameQuery(items, name_query);
67
198
  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
- ],
199
+ // 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.
203
+ return tableResult(toRows(items), {
204
+ columns: CONNECTION_TABLE_COLUMNS,
205
+ compactColumns: CONNECTION_COMPACT_COLUMNS,
206
+ map: mapConnectionRow,
207
+ compactMap: compactConnectionRow,
90
208
  header: (n) => `${n} connections`,
91
- footer: "Use apimapper_connection_get <id> for full details.",
209
+ footer: CONNECTION_LIST_NEXT_STEPS,
92
210
  });
93
211
  });
94
212
  // ── apimapper_connection_get ───────────────────────────────────────
@@ -99,20 +217,33 @@ export function registerConnectionTools(server) {
99
217
  inputSchema: {
100
218
  id: z.string().describe('Connection ID (e.g., "conn_Mz33OVPF1z3ap8fbbQtpx"). Use apimapper_connection_list.'),
101
219
  },
102
- annotations: readOnly(),
220
+ annotations: readOnly({ title: "Get Connection", openWorld: true }),
103
221
  }, async ({ id }) => {
104
222
  // PHP wraps via fromControllerResponse($response, 'connection') →
105
223
  // `{success:true, connection:{…}}`. Unwrap defensively so a future
106
224
  // flatten on the PHP side doesn't break us. Audit: F-A1-01.
107
225
  const r = await request(`/connections/${encodeURIComponent(id)}`);
108
226
  if (!r.success) {
109
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
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
+ });
110
233
  }
111
234
  const conn = unwrapEntity(r.data, "connection");
112
235
  if (!conn || Object.keys(conn).length === 0) {
113
- return formatResult({ error: "connection not found", status: r.status, context: { id }, hint: hintFor("not_found") }, true);
236
+ return errorResult({
237
+ message: "connection not found",
238
+ code: r.status ? String(r.status) : "not_found",
239
+ suggestion: hintFor("not_found"),
240
+ details: { id },
241
+ });
114
242
  }
115
- return formatResult(conn, false, { maxChars: 4000 });
243
+ // W3.1 detailResult: grouped key-value detail for the Rich Card.
244
+ // IA-7: opaque IDs are copyable code entries. IA-10: a dedicated
245
+ // "Next steps" group carries the follow-up calls.
246
+ return buildConnectionDetail(id, conn);
116
247
  });
117
248
  // ── apimapper_connection_create ────────────────────────────────────
118
249
  server.registerTool("apimapper_connection_create", {
@@ -129,29 +260,92 @@ export function registerConnectionTools(server) {
129
260
  cache_ttl: z.number().int().min(0).default(3600).describe("Cache TTL in seconds (snake_case wire key)"),
130
261
  description: z.string().optional().describe("Free-text description"),
131
262
  },
132
- annotations: creating(),
263
+ annotations: creating({ title: "Create Connection", openWorld: true }),
133
264
  }, async (input) => {
265
+ // W3.6 — when the connection's auth_type needs a credential and none is
266
+ // given: exactly 1 stored credential → auto-pick; >1 → elicitChoice;
267
+ // elicitChoice null (unsupported client / declined) → structured
268
+ // candidate-list error. auth_type "none" never needs a credential.
269
+ let resolvedInput = { ...input };
270
+ if (input.auth_type !== "none" && !input.credential_id) {
271
+ const lr = await request("/credentials", {}, { sanitize: true });
272
+ 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
+ });
279
+ }
280
+ const creds = Array.isArray(lr.data?.credentials) ? lr.data.credentials : [];
281
+ if (creds.length === 0) {
282
+ return errorResult({
283
+ message: `auth_type "${input.auth_type}" needs a credential, but none are stored.`,
284
+ code: "credential_not_found",
285
+ suggestion: "Create one with apimapper_credential_create, then retry with its credential_id.",
286
+ details: { name: input.name, auth_type: input.auth_type },
287
+ });
288
+ }
289
+ if (creds.length === 1) {
290
+ resolvedInput = { ...input, credential_id: creds[0].id };
291
+ }
292
+ else {
293
+ const candidates = creds.map((c) => ({
294
+ id: c.id,
295
+ label: c.name,
296
+ }));
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 };
311
+ }
312
+ }
134
313
  // PHP fromControllerResponse(_, 'connection', 201) → {success, connection:{…}}.
135
314
  // Audit: F-A1-02.
136
315
  const r = await request("/connections", {
137
316
  method: "POST",
138
- body: JSON.stringify(input),
317
+ body: JSON.stringify(resolvedInput),
139
318
  });
140
319
  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);
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
+ });
148
326
  }
149
327
  const conn = unwrapEntity(r.data, "connection");
328
+ // F-3.2 (Pass-1 consolidation) — mutating-tool payload-shape rationale.
329
+ //
330
+ // connection_create + connection_update stay on the flat
331
+ // `formatResult({ ... })` shape rather than migrating to the
332
+ // structured detailResult / actionResult builders. The mutating-tool
333
+ // payload here is intentionally minimal (the wire echo confirms the
334
+ // op + identity), and the detail builders are tuned for fully
335
+ // populated entities (badge/group/entry layouts) — wrapping a 3-field
336
+ // confirmation in a Rich Card would add noise without surfacing more
337
+ // information. This mirrors the connection_data passthrough rationale:
338
+ // the LLM benefits from the bare structured echo; UI hosts that want
339
+ // a richer view re-fetch via connection_get. IA-10 uniformity is
340
+ // restored via `next_steps[]` (array, single-element) below.
150
341
  return formatResult({
151
342
  created: true,
152
343
  id: conn?.id,
153
344
  name: conn?.name,
154
345
  source: conn?.source,
346
+ next_steps: [
347
+ "Use apimapper_connection_test to verify reachability, then apimapper_flow_create to wire it into a flow.",
348
+ ],
155
349
  }, false, { maxChars: 2000 });
156
350
  });
157
351
  // ── apimapper_connection_update ────────────────────────────────────
@@ -167,7 +361,7 @@ export function registerConnectionTools(server) {
167
361
  'Examples: {"name":"Renamed"}, {"cache_ttl":7200}, {"endpoint":"/v2/posts"}, ' +
168
362
  '{"credential_id":"cred_xxx"}'),
169
363
  },
170
- annotations: mutating(),
364
+ annotations: mutating({ title: "Update Connection", openWorld: true }),
171
365
  }, async ({ id, patch }) => {
172
366
  // PHP fromControllerResponse(_, 'connection') → {success, connection:{…}}.
173
367
  // Audit: F-A1-03.
@@ -176,10 +370,25 @@ export function registerConnectionTools(server) {
176
370
  body: JSON.stringify(patch),
177
371
  });
178
372
  if (!r.success) {
179
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
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
+ });
180
379
  }
181
380
  const conn = unwrapEntity(r.data, "connection");
182
- return formatResult({ updated: true, id: conn?.id, name: conn?.name }, false, { maxChars: 2000 });
381
+ // F-3.2 (Pass-1 consolidation) see connection_create for the
382
+ // mutating-tool payload-shape rationale. next_steps[] restored for
383
+ // IA-10 uniformity (single-element array, not flat `next:`).
384
+ return formatResult({
385
+ updated: true,
386
+ id: conn?.id,
387
+ name: conn?.name,
388
+ next_steps: [
389
+ "Re-run apimapper_connection_test if endpoint/auth changed; flows referencing this connection may need to be republished.",
390
+ ],
391
+ }, false, { maxChars: 2000 });
183
392
  });
184
393
  // ── apimapper_connection_delete ────────────────────────────────────
185
394
  server.registerTool("apimapper_connection_delete", {
@@ -194,7 +403,7 @@ export function registerConnectionTools(server) {
194
403
  .default(false)
195
404
  .describe("Must be true to execute. On confirm:false, returns a preview."),
196
405
  },
197
- annotations: destructive(),
406
+ annotations: destructive({ title: "Delete Connection", openWorld: true }),
198
407
  }, async ({ id, confirm }) => {
199
408
  if (!confirm) {
200
409
  // PHP fromControllerResponse(_, 'connection') wraps as
@@ -218,7 +427,12 @@ export function registerConnectionTools(server) {
218
427
  }
219
428
  const r = await request(`/connections/${encodeURIComponent(id)}`, { method: "DELETE" });
220
429
  if (!r.success) {
221
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
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
+ });
222
436
  }
223
437
  return formatResult({ deleted: true, id }, false, { maxChars: 1500 });
224
438
  });
@@ -231,33 +445,30 @@ export function registerConnectionTools(server) {
231
445
  inputSchema: {
232
446
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
233
447
  },
234
- annotations: readOnly(),
448
+ annotations: readOnly({ title: "Test Connection", openWorld: true }),
235
449
  }, async ({ id }) => {
450
+ // PHP wraps probe fields one level deeper:
451
+ // {success:true, data:{actionType, http_code, duration_ms, body_preview, …}, items_count}
452
+ // unwrapInnerSuccess sees outer success:true and lets it through, but
453
+ // probe fields still live under `data.data.*`. Audit: F-A1-05.
236
454
  const r = await request("/connections/test", { method: "POST", body: JSON.stringify({ connection_id: id }) }, { unwrapInnerSuccess: true });
237
455
  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
456
+ return errorResult({
457
+ message: r.error ?? "connection test failed",
458
+ code: r.errorCode ?? (r.status ? String(r.status) : undefined),
459
+ suggestion: r.payloadFailed
245
460
  ? "Probe ran but reported failure — check the upstream API status, credentials, or connection endpoint."
246
461
  : hintFor(r.errorCode),
247
- }, true);
462
+ details: { id, payloadFailed: r.payloadFailed },
463
+ });
248
464
  }
249
465
  // Defensive unwrap: prefer `r.data.data` if present, fall back to `r.data` itself
250
466
  // so a future PHP flatten doesn't break this tool.
251
467
  const outer = (r.data && typeof r.data === "object" ? r.data : {});
252
468
  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 });
469
+ // W3.1 — statsResult: the probe outcome is a small flat scalar set.
470
+ // IA-10: the follow-up guidance rides in the stats description.
471
+ return buildConnectionTestStats(id, probe);
261
472
  });
262
473
  // ── apimapper_connection_health_check ──────────────────────────────
263
474
  server.registerTool("apimapper_connection_health_check", {
@@ -271,20 +482,24 @@ export function registerConnectionTools(server) {
271
482
  .default([])
272
483
  .describe('Connection IDs to probe (empty = all). REST wire-key: connection_ids.'),
273
484
  },
274
- annotations: readOnly(),
275
- }, async ({ connection_ids }) => {
485
+ annotations: readOnly({ title: "Connection Health Check", openWorld: true }),
486
+ }, async ({ connection_ids }, extra) => {
487
+ // W3.5 — coarse progress side-channel. `null` when the caller sent no
488
+ // progressToken; `progress?.report(...)` then no-ops. The batch probe
489
+ // can take 10-30s, so a start/done signal keeps the caller informed.
490
+ const progress = extra ? createProgressReporter(extra) : null;
491
+ await progress?.report(0, 1, "Probing connections…");
276
492
  const r = await request("/connections/health-check", {
277
493
  method: "POST",
278
494
  body: JSON.stringify({ connection_ids }),
279
495
  });
280
496
  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);
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
+ });
288
503
  }
289
504
  const data = (r.data && typeof r.data === "object") ? r.data : {};
290
505
  // PHP returns map keyed by connection id, NOT under a 'results' key —
@@ -297,71 +512,143 @@ export function registerConnectionTools(server) {
297
512
  const o = x;
298
513
  return o.success === true || o.actionType === "ok";
299
514
  }).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 });
515
+ await progress?.report(1, 1, `Probed ${results.length} connection(s)`);
516
+ // W3.1 — statsResult: the batch outcome is a counts dashboard.
517
+ // IA-10: the description routes the AI to connection_test for the
518
+ // per-connection probe detail (the full result map is too large for
519
+ // a stat grid).
520
+ return buildHealthCheckStats(okCount, results.length - okCount, results.length);
306
521
  });
307
522
  // ── apimapper_connection_data ──────────────────────────────────────
523
+ // rc.10.1 C (2026-05-19) — exposes `limit` + `offset` to AI agents so
524
+ // they can constrain large API responses without injecting template
525
+ // hacks like `template_fields.per_page`. Trim happens IN-MEMORY on
526
+ // the TS side after the response is unwrapped — this is single-shot
527
+ // upstream, no multi-page fetch is triggered. Real cursor / page-loop
528
+ // pagination is a separate roadmap item (see plan Section E1).
308
529
  server.registerTool("apimapper_connection_data", {
309
530
  title: "Fetch Connection Sample Data",
310
531
  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 } })",
532
+ "query params as source-context for template substitution. Use `limit`/`offset` to trim " +
533
+ "the response items in-memory this does NOT trigger multi-page fetch upstream (the " +
534
+ "request is single-shot). Use `fields` to project each item down to a whitelist of leaf " +
535
+ "keys, cutting response size on sparse queries." +
536
+ "\n\nExample:\n" +
537
+ " apimapper_connection_data({ id: 'con_abc123', limit: 10 }) // first 10 items\n" +
538
+ " apimapper_connection_data({ id: 'con_abc123', limit: 5, offset: 10 }) // skip 10, take 5\n" +
539
+ " apimapper_connection_data({ id: 'con_abc123', fields: ['title', 'image.url'] }) // 2 leaf keys per item\n" +
540
+ " apimapper_connection_data({ id: 'con_abc123', template_fields: { query: 'nature' } })",
313
541
  inputSchema: {
314
542
  id: z.string().describe("Connection ID. Use apimapper_connection_list to find."),
315
- endpoint: z.string().optional().describe('Override endpoint (e.g., "Scheduled Events")'),
543
+ endpoint: z
544
+ .string()
545
+ .trim()
546
+ .min(1)
547
+ .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.'),
316
549
  template_fields: z
317
- .record(z.string(), z.string())
550
+ .record(z.string(), z.union([z.string(), z.number().finite(), z.boolean()]))
318
551
  .optional()
319
- .describe('Template field values flattened into query string (e.g., {"spreadsheet_id":"...","user_uri":"..."})'),
552
+ .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'),
553
+ limit: z
554
+ .number()
555
+ .int()
556
+ .min(1)
557
+ .max(500)
558
+ .optional()
559
+ .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."),
560
+ offset: z
561
+ .number()
562
+ .int()
563
+ .min(0)
564
+ .optional()
565
+ .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`."),
566
+ // rc.13 W3.3 (2026-05-20) — optional per-item field whitelist.
567
+ // When the data payload is an array of objects each item is mapped
568
+ // through pickFields() so only the named leaf keys survive.
569
+ fields: z
570
+ .array(z.string().min(1))
571
+ .max(40)
572
+ .optional()
573
+ .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
574
  },
321
- annotations: readOnly(),
322
- }, async ({ id, endpoint, template_fields }) => {
575
+ annotations: readOnly({ title: "Get Connection Data", openWorld: true }),
576
+ }, async ({ id, endpoint, template_fields, limit, offset, fields }) => {
323
577
  const params = new URLSearchParams();
324
578
  if (endpoint)
325
579
  params.set("endpoint", endpoint);
326
580
  // Flatten template_fields directly as query params — PHP handler does
327
581
  // array_filter() across get_query_params() and uses each key as a
328
- // template variable.
582
+ // template variable. Non-string primitives (number/boolean — F-10/W1.5)
583
+ // are explicitly stringified via String(): the schema admits
584
+ // string|number|boolean unions, so the handler must canonicalise
585
+ // numbers to their decimal repr ("25") and booleans to "true"/"false"
586
+ // before they hit URLSearchParams. URLSearchParams.set would coerce
587
+ // implicitly, but the explicit guard documents the contract and keeps
588
+ // TypeScript happy under the wider schema.
329
589
  if (template_fields) {
330
590
  for (const [k, v] of Object.entries(template_fields)) {
331
- params.set(k, v);
591
+ params.set(k, typeof v === "string" ? v : String(v));
332
592
  }
333
593
  }
334
594
  const qs = params.toString();
335
595
  const r = await request(`/connections/${encodeURIComponent(id)}/data${qs ? `?${qs}` : ""}`);
336
596
  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);
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
+ });
344
603
  }
345
604
  // PHP success body: {data:[…items…], connection, body?, content_type?, status?}.
346
605
  // Operate on the inner `data` payload, not on the envelope. Surface
347
606
  // `body`/`content_type` for the non-JSON path. Audit: F-A1-06.
348
607
  const envelope = (r.data && typeof r.data === "object" ? r.data : {});
349
- const inner = "data" in envelope ? envelope.data : envelope;
608
+ const innerRaw = "data" in envelope ? envelope.data : envelope;
350
609
  const body = typeof envelope.body === "string" ? envelope.body : undefined;
351
610
  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;
611
+ // rc.10.1 C + rc.13 W1.2 (F-01+F-38) — apply in-memory trim via
612
+ // extracted pure function. See the `applyTrim` JSDoc in
613
+ // connections-trim.ts for the shape-detection contract and
614
+ // shallow-clone guarantees.
615
+ const trim = applyTrim(innerRaw, limit, offset);
616
+ // rc.13 W3.3 — optional field-projection. Runs AFTER trim: the
617
+ // trimmed slice is the smallest correct surface to project, and
618
+ // projection composes cleanly on top of it (project-of-trimmed ≡
619
+ // narrowing a smaller list). Projection is applied ONLY when the
620
+ // (trimmed) payload is an array of plain objects — each item is
621
+ // mapped through pickFields(), which flattens nested paths to leaf
622
+ // keys and silently drops missing fields. For every other shape
623
+ // (single object, scalar, nested-items envelope, array of
624
+ // primitives) projection is skipped and the payload passes through
625
+ // unchanged — there is no per-record surface to whitelist there, so
626
+ // narrowing would be lossy or meaningless. `projected_fields` is
627
+ // still echoed back in the envelope whenever `fields` was supplied,
628
+ // so the AI client always knows projection was requested even when
629
+ // the shape made it a no-op.
630
+ let projectedPayload = trim.payload;
631
+ if (fields && Array.isArray(trim.payload)) {
632
+ projectedPayload = trim.payload.map((item) => item !== null && typeof item === "object" && !Array.isArray(item)
633
+ ? pickFields(item, fields)
634
+ : item);
635
+ }
358
636
  return formatResult({
359
637
  ok: true,
360
- shape: isArray ? "array" : typeof inner,
361
- item_count: items ? items.length : undefined,
638
+ // F-38 — surface a stable, narrow shape enum instead of typeof.
639
+ // "object" is preserved for the legacy non-iterable case so
640
+ // existing wire-shape pins keep working.
641
+ shape: trim.detectedShape === "other" ? typeof trim.payload : trim.detectedShape,
642
+ item_count: trim.itemCount,
643
+ ...(trim.trimmed && trim.totalBeforeTrim !== undefined
644
+ ? { total_before_trim: trim.totalBeforeTrim, trimmed: true }
645
+ : {}),
646
+ // W3.3 — echo the requested whitelist so the AI knows the
647
+ // per-item shape was (or was meant to be) reduced.
648
+ ...(fields ? { projected_fields: fields } : {}),
362
649
  body,
363
650
  content_type: contentType,
364
- payload: inner,
651
+ payload: projectedPayload,
365
652
  }, false, { maxChars: 6000 });
366
653
  });
367
654
  // ── apimapper_connection_resources ─────────────────────────────────
@@ -375,9 +662,15 @@ export function registerConnectionTools(server) {
375
662
  field: z
376
663
  .string()
377
664
  .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"),
665
+ // rc.10 A4 (2026-05-19): min(3) blocks the single-char "p", "j", "u"
666
+ // probing pattern observed in Maria-walkthrough logs.
667
+ // rc.13 W1.17 (F-29): Master-Doc-Drift — Master requested min(2), kept
668
+ // min(3) (stricter is OK, less regression-risk). Pin-test in
669
+ // connections.test.ts "F-29 connection_resources query min length pin"
670
+ // guards against loosening.
671
+ 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
672
  },
380
- annotations: readOnly(),
673
+ annotations: readOnly({ title: "List Connection Resources", openWorld: true }),
381
674
  }, async ({ id, field, query }) => {
382
675
  const params = new URLSearchParams();
383
676
  params.set("field", field);
@@ -385,29 +678,35 @@ export function registerConnectionTools(server) {
385
678
  params.set("query", query);
386
679
  const r = await request(`/connections/${encodeURIComponent(id)}/resources?${params.toString()}`);
387
680
  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);
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
+ });
395
687
  }
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 });
688
+ // A1-P3-1: pure helper handles the three wire-shapes (bare array,
689
+ // `{ resources }`, `{ items }`) + fallback to `[]` for unknown
690
+ // shapes. Pinned per-branch in connections.test.ts.
691
+ const resources = extractResourceList(r.data);
692
+ // W3.1 — tableResult: resources are a flat row list. IA-7: opaque id
693
+ // stays llmOnly. IA-10: the footer carries the next-step guidance and
694
+ // the truncation note when more than 100 resources are returned.
695
+ const truncated = resources.length > 100;
696
+ const footerLines = [
697
+ `Picker field: ${field}${query ? ` · filter: "${query}"` : ""}.`,
698
+ ];
699
+ if (truncated) {
700
+ footerLines.push(`Showing first 100 of ${resources.length} resources — narrow with the query filter.`);
701
+ }
702
+ footerLines.push("Next: pass a chosen resource ID into apimapper_connection_update " +
703
+ "({ id, patch }) or the connection's pipeline default_params.");
704
+ return tableResult(resources.slice(0, 100), {
705
+ columns: RESOURCE_TABLE_COLUMNS,
706
+ map: mapResourceRow,
707
+ header: (n) => `${n} resources on ${id}`,
708
+ footer: footerLines.join("\n"),
709
+ });
411
710
  });
412
711
  // ── apimapper_connection_pipeline_update ───────────────────────────
413
712
  server.registerTool("apimapper_connection_pipeline_update", {
@@ -420,7 +719,7 @@ export function registerConnectionTools(server) {
420
719
  .record(z.string(), z.unknown())
421
720
  .describe('Pipeline JSON (e.g., {"endpoints":[...], "items_path":"data", "items_shape":"flat"})'),
422
721
  },
423
- annotations: mutating(),
722
+ annotations: mutating({ title: "Update Connection Pipeline", openWorld: true }),
424
723
  }, async ({ id, pipeline }) => {
425
724
  // PHP ConnectionPipelineHandler uses fromControllerResponse(_, 'connection')
426
725
  // → {success, connection:{…}}. Unwrap defensively. Audit: F-A1-07.
@@ -429,7 +728,12 @@ export function registerConnectionTools(server) {
429
728
  body: JSON.stringify(pipeline),
430
729
  });
431
730
  if (!r.success) {
432
- return formatResult({ error: r.error, status: r.status, errorCode: r.errorCode, context: { id }, hint: hintFor(r.errorCode) }, true);
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
+ });
433
737
  }
434
738
  const conn = unwrapEntity(r.data, "connection");
435
739
  return formatResult({ updated: true, id: conn?.id, name: conn?.name }, false, { maxChars: 2000 });