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