anyapi-mcp-server 1.8.1 → 2.0.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.
@@ -0,0 +1,222 @@
1
+ import { z } from "zod";
2
+ import { formatToolError, attachRateLimit, shrinkageError, placeholderError, } from "./shared.js";
3
+ import { callApi } from "../api-client.js";
4
+ import { getOrBuildSchema, schemaToSDL, executeQuery, computeShapeHash, } from "../graphql-schema.js";
5
+ import { generateSuggestions } from "../query-suggestions.js";
6
+ import { isNonJsonResult } from "../response-parser.js";
7
+ import { storeResponse, loadResponse } from "../data-cache.js";
8
+ import { resolveBody } from "../body-file.js";
9
+ import { detectPlaceholders } from "../body-validation.js";
10
+ import { createBackup } from "../pre-write-backup.js";
11
+ import { extractPathParamNames } from "../pre-write-backup.js";
12
+ import { detectArrayShrinkage } from "../write-safety.js";
13
+ import { applyPatch } from "../json-patch.js";
14
+ export function registerMutateApi({ server, config, apiIndex }) {
15
+ server.tool("mutate_api", `Write data to a ${config.name} API endpoint (POST/PUT/PATCH/DELETE). ` +
16
+ "Two modes:\n" +
17
+ "1. Direct: provide 'body' or 'bodyFile' with the full request payload.\n" +
18
+ "2. Patch: provide 'patch' with JSON Patch operations (add/remove/replace). " +
19
+ "The tool automatically fetches the current resource state, applies your patches, " +
20
+ "and sends the complete result — you never need to hold the full data.\n" +
21
+ "Use inspect_api first to understand the endpoint's parameters and body schema. " +
22
+ "Optionally pass 'query' to select specific fields from the response via GraphQL.", {
23
+ method: z
24
+ .enum(["POST", "PUT", "DELETE", "PATCH"])
25
+ .describe("HTTP method"),
26
+ path: z
27
+ .string()
28
+ .describe("API path template (e.g. '/api/card/{id}')"),
29
+ params: z
30
+ .record(z.unknown())
31
+ .optional()
32
+ .describe("Path and query parameters. Path params like {id} are interpolated; " +
33
+ "remaining become query string."),
34
+ body: z
35
+ .record(z.unknown())
36
+ .optional()
37
+ .describe("Request body (direct mode). Mutually exclusive with 'patch' and 'bodyFile'."),
38
+ bodyFile: z
39
+ .string()
40
+ .optional()
41
+ .describe("Absolute path to a JSON file for the request body (direct mode). " +
42
+ "Mutually exclusive with 'body' and 'patch'."),
43
+ patch: z
44
+ .array(z.object({
45
+ op: z.enum(["add", "remove", "replace"]),
46
+ path: z.string().describe("JSON Pointer path (e.g. '/title', '/panels/3/title', '/tags/-' for append)"),
47
+ value: z.unknown().optional().describe("Value for add/replace operations"),
48
+ }))
49
+ .optional()
50
+ .describe("JSON Patch operations (RFC 6902 subset). The tool GETs the current resource, " +
51
+ "applies these patches, and sends the result. " +
52
+ "Mutually exclusive with 'body' and 'bodyFile'."),
53
+ headers: z
54
+ .record(z.string())
55
+ .optional()
56
+ .describe("Additional HTTP headers. Overrides default --header values."),
57
+ query: z
58
+ .string()
59
+ .optional()
60
+ .describe("Optional GraphQL selection query on the response " +
61
+ "(e.g. '{ id name status }' to select specific fields)."),
62
+ skipBackup: z
63
+ .boolean()
64
+ .optional()
65
+ .describe("Skip the automatic pre-write backup for PUT/PATCH (direct mode only). " +
66
+ "Default: false."),
67
+ }, async ({ method, path, params, body, bodyFile, patch, headers, query, skipBackup }) => {
68
+ try {
69
+ // Validate mutual exclusivity
70
+ const modeCount = [body, bodyFile, patch].filter((x) => x !== undefined).length;
71
+ if (modeCount > 1) {
72
+ return formatToolError(new Error("Only one of 'body', 'bodyFile', or 'patch' can be provided."), apiIndex);
73
+ }
74
+ // --- Patch mode ---
75
+ if (patch !== undefined) {
76
+ return await handlePatchMode({ config, apiIndex }, method, path, params, patch, headers, query);
77
+ }
78
+ // --- Direct mode ---
79
+ let resolvedBody;
80
+ try {
81
+ resolvedBody = resolveBody(body, bodyFile);
82
+ }
83
+ catch (err) {
84
+ return formatToolError(err, apiIndex);
85
+ }
86
+ // Placeholder detection (except DELETE)
87
+ if (resolvedBody && method !== "DELETE") {
88
+ const endpoint = apiIndex.getEndpoint(method, path);
89
+ const warnings = detectPlaceholders(resolvedBody, endpoint?.requestBodySchema);
90
+ if (warnings.length > 0)
91
+ return placeholderError(warnings);
92
+ }
93
+ // Pre-write backup for PUT/PATCH
94
+ let backupDataKey;
95
+ if ((method === "PATCH" || method === "PUT") && !skipBackup) {
96
+ backupDataKey = await createBackup(config, method, path, params, headers);
97
+ }
98
+ // Array shrinkage detection against backup
99
+ if (backupDataKey && resolvedBody) {
100
+ const backupEntry = loadResponse(backupDataKey);
101
+ if (backupEntry) {
102
+ const shrinkWarnings = detectArrayShrinkage(resolvedBody, backupEntry.data);
103
+ if (shrinkWarnings.length > 0)
104
+ return shrinkageError(shrinkWarnings, { backupDataKey });
105
+ }
106
+ }
107
+ // Execute the request
108
+ const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, resolvedBody, headers);
109
+ return await buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, resolvedBody, query, backupDataKey);
110
+ }
111
+ catch (error) {
112
+ return formatToolError(error, apiIndex, method, path);
113
+ }
114
+ });
115
+ }
116
+ /**
117
+ * Patch mode: GET current state, apply patches, send the result.
118
+ */
119
+ async function handlePatchMode({ config, apiIndex }, method, path, params, operations, headers, query) {
120
+ if (operations.length === 0) {
121
+ return formatToolError(new Error("Patch operations array is empty."), apiIndex);
122
+ }
123
+ if (method === "DELETE") {
124
+ return formatToolError(new Error("Patch mode is not supported for DELETE — use direct mode (no body) instead."), apiIndex);
125
+ }
126
+ // Fetch current state (path params only, same as createBackup)
127
+ const pathParamNames = extractPathParamNames(path);
128
+ const pathOnlyParams = params
129
+ ? Object.fromEntries(Object.entries(params).filter(([k]) => pathParamNames.has(k)))
130
+ : undefined;
131
+ let currentData;
132
+ let currentHeaders;
133
+ try {
134
+ const result = await callApi(config, "GET", path, pathOnlyParams && Object.keys(pathOnlyParams).length > 0 ? pathOnlyParams : undefined, undefined, headers);
135
+ currentData = result.data;
136
+ currentHeaders = result.responseHeaders;
137
+ }
138
+ catch (err) {
139
+ return formatToolError(new Error(`Patch mode: failed to GET current state of ${path}: ` +
140
+ (err instanceof Error ? err.message : String(err))), apiIndex);
141
+ }
142
+ // Validate current data is a plain object
143
+ if (typeof currentData !== "object" || currentData === null || Array.isArray(currentData)) {
144
+ return formatToolError(new Error(`Patch mode requires the GET response to be a JSON object, ` +
145
+ `but got ${Array.isArray(currentData) ? "array" : typeof currentData}. ` +
146
+ `Use direct mode (body/bodyFile) instead.`), apiIndex);
147
+ }
148
+ // Store pre-patch state as backup
149
+ const backupDataKey = storeResponse("GET", path, currentData, currentHeaders);
150
+ // Apply patches
151
+ let patchedBody;
152
+ try {
153
+ patchedBody = applyPatch(currentData, operations);
154
+ }
155
+ catch (err) {
156
+ return formatToolError(new Error(`Patch failed: ${err instanceof Error ? err.message : String(err)}`), apiIndex);
157
+ }
158
+ // Placeholder detection on patched result
159
+ const endpoint = apiIndex.getEndpoint(method, path);
160
+ const warnings = detectPlaceholders(patchedBody, endpoint?.requestBodySchema);
161
+ if (warnings.length > 0)
162
+ return placeholderError(warnings);
163
+ // Execute the write with the complete patched body
164
+ const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, patchedBody, headers);
165
+ return await buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, patchedBody, query, backupDataKey, operations.length);
166
+ }
167
+ /**
168
+ * Build the response for mutate_api (shared between direct and patch modes).
169
+ */
170
+ async function buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, resolvedBody, query, backupDataKey, patchCount) {
171
+ const newDataKey = storeResponse(method, path, rawData, respHeaders);
172
+ // Non-JSON response
173
+ if (isNonJsonResult(rawData)) {
174
+ return {
175
+ content: [
176
+ { type: "text", text: JSON.stringify({
177
+ rawResponse: rawData,
178
+ responseHeaders: respHeaders,
179
+ ...(newDataKey ? { _dataKey: newDataKey } : {}),
180
+ hint: "This endpoint returned a non-JSON response. The raw content is shown above.",
181
+ }, null, 2) },
182
+ ],
183
+ };
184
+ }
185
+ const endpoint = apiIndex.getEndpoint(method, path);
186
+ const bodyHash = resolvedBody ? computeShapeHash(resolvedBody) : undefined;
187
+ const { schema, fromCache } = getOrBuildSchema(rawData, method, path, endpoint?.requestBodySchema, bodyHash);
188
+ // If query provided, apply GraphQL field selection
189
+ let resultData = rawData;
190
+ if (query) {
191
+ resultData = await executeQuery(schema, rawData, query);
192
+ }
193
+ const output = { _status: "COMPLETE" };
194
+ if (query && typeof resultData === "object" && resultData !== null && !Array.isArray(resultData)) {
195
+ Object.assign(output, resultData);
196
+ }
197
+ else {
198
+ output.data = resultData;
199
+ }
200
+ attachRateLimit(output, respHeaders);
201
+ if (!fromCache) {
202
+ output._schema = schemaToSDL(schema);
203
+ const suggestions = generateSuggestions(schema);
204
+ if (suggestions.length > 0) {
205
+ output._suggestedQueries = suggestions;
206
+ }
207
+ }
208
+ if (newDataKey)
209
+ output._dataKey = newDataKey;
210
+ if (backupDataKey) {
211
+ output._backupDataKey = backupDataKey;
212
+ output._backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
213
+ }
214
+ if (patchCount !== undefined) {
215
+ output._patchApplied = `${patchCount} operation(s) applied successfully`;
216
+ }
217
+ return {
218
+ content: [
219
+ { type: "text", text: JSON.stringify(output, null, 2) },
220
+ ],
221
+ };
222
+ }
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ import { formatToolError, attachRateLimit } from "./shared.js";
3
+ import { callApi } from "../api-client.js";
4
+ import { getOrBuildSchema, schemaToSDL, executeQuery, } from "../graphql-schema.js";
5
+ import { generateSuggestions } from "../query-suggestions.js";
6
+ import { buildStatusMessage, estimateResultTokens, findPrimaryArrayLength } from "../token-budget.js";
7
+ import { isNonJsonResult } from "../response-parser.js";
8
+ import { detectPagination } from "../pagination.js";
9
+ import { storeResponse, loadResponse } from "../data-cache.js";
10
+ import { applyJsonFilter } from "../json-filter.js";
11
+ export function registerQueryApi({ server, config, apiIndex }) {
12
+ server.tool("query_api", `Fetch data from a ${config.name} API endpoint (GET only), returning only the fields you select via GraphQL. ` +
13
+ "TIP: Pass the dataKey from inspect_api to reuse the cached response — zero HTTP calls. " +
14
+ "If you know the field names, call query_api directly — on first hit the schema SDL " +
15
+ "will be included in the response. If unsure, use inspect_api first for schema discovery.\n" +
16
+ "Use the exact root field names from the schema — do NOT assume generic names.\n" +
17
+ "- Raw array response ([...]): '{ items { id name } _count }'\n" +
18
+ "- Object response ({products: [...]}): '{ products { id name } }' (use actual field names from schema)\n" +
19
+ "Field names with dashes are converted to underscores (e.g. created-at → created_at). " +
20
+ "PAGINATION: To paginate the API itself, pass limit/offset inside 'params' (they become query string parameters). " +
21
+ "TOKEN BUDGET (three modes):\n" +
22
+ "1. No maxTokens/unlimited: responses over ~10k tokens return an error — use maxTokens or unlimited to proceed.\n" +
23
+ "2. maxTokens: truncates the deepest largest array to fit the budget.\n" +
24
+ "3. unlimited: true: returns the full response with no truncation.\n" +
25
+ "Check _status in the response: 'COMPLETE' means all data returned, 'TRUNCATED' means array was cut to fit budget. " +
26
+ "Every response includes a _dataKey for subsequent re-queries with different field selections. " +
27
+ "For write operations (POST/PUT/PATCH/DELETE), use mutate_api instead.", {
28
+ path: z
29
+ .string()
30
+ .describe("API path template (e.g. '/api/card/{id}')"),
31
+ params: z
32
+ .record(z.unknown())
33
+ .optional()
34
+ .describe("Path and query parameters. Path params like {id} are interpolated; " +
35
+ "remaining become query string. " +
36
+ "For API pagination, pass limit/offset here (e.g. { limit: 20, offset: 40 })."),
37
+ query: z
38
+ .string()
39
+ .describe("GraphQL selection query using field names from inspect_api schema " +
40
+ "(e.g. '{ products { id name } }' — NOT '{ items { ... } }' unless the API returns a raw array)"),
41
+ dataKey: z
42
+ .string()
43
+ .optional()
44
+ .describe("dataKey from a previous inspect_api or query_api response. " +
45
+ "If valid, reuses cached data — zero HTTP calls. Falls back to HTTP on miss/expiry."),
46
+ headers: z
47
+ .record(z.string())
48
+ .optional()
49
+ .describe("Additional HTTP headers for this request. Overrides default --header values."),
50
+ jsonFilter: z
51
+ .string()
52
+ .optional()
53
+ .describe("Dot-path to extract from the result after GraphQL query executes. " +
54
+ "Use \".\" for nested access, \"[]\" to traverse arrays. " +
55
+ "Example: \"data[].attributes.message\" extracts the message field from each element of the data array."),
56
+ maxTokens: z
57
+ .number()
58
+ .min(100)
59
+ .optional()
60
+ .describe("Token budget for the response. If exceeded, the deepest largest array is truncated to fit. " +
61
+ "Select fewer fields to fit more items. Without this or unlimited, responses over ~10k tokens are rejected."),
62
+ unlimited: z
63
+ .boolean()
64
+ .optional()
65
+ .describe("Set to true to return the full response with no token budget enforcement. " +
66
+ "Use when you know exactly what fields you need and want all data."),
67
+ }, async ({ path, params, query, dataKey, headers, jsonFilter, maxTokens, unlimited }) => {
68
+ try {
69
+ let rawData;
70
+ let respHeaders;
71
+ // Try dataKey cache first
72
+ const cached = dataKey ? loadResponse(dataKey) : null;
73
+ let cachedDataAge;
74
+ if (cached) {
75
+ rawData = cached.data;
76
+ respHeaders = cached.responseHeaders;
77
+ cachedDataAge = Math.round((Date.now() - cached.storedAt) / 1000);
78
+ }
79
+ else {
80
+ const result = await callApi(config, "GET", path, params, undefined, headers);
81
+ rawData = result.data;
82
+ respHeaders = result.responseHeaders;
83
+ }
84
+ // Store response for future re-queries
85
+ const newDataKey = storeResponse("GET", path, rawData, respHeaders);
86
+ // Non-JSON response — skip GraphQL layer
87
+ if (isNonJsonResult(rawData)) {
88
+ return {
89
+ content: [
90
+ { type: "text", text: JSON.stringify({
91
+ rawResponse: rawData,
92
+ responseHeaders: respHeaders,
93
+ ...(newDataKey ? { _dataKey: newDataKey } : {}),
94
+ hint: "This endpoint returned a non-JSON response. GraphQL querying is not available. " +
95
+ "The raw parsed content is shown above.",
96
+ }, null, 2) },
97
+ ],
98
+ };
99
+ }
100
+ const endpoint = apiIndex.getEndpoint("GET", path);
101
+ const { schema, fromCache } = getOrBuildSchema(rawData, "GET", path, endpoint?.requestBodySchema);
102
+ let queryResult = await executeQuery(schema, rawData, query, unlimited ? { unlimited: true } : undefined);
103
+ if (jsonFilter) {
104
+ queryResult = applyJsonFilter(queryResult, jsonFilter);
105
+ }
106
+ // Token budget: three-mode dispatch
107
+ let status;
108
+ let budgetResult;
109
+ if (unlimited) {
110
+ const arrayLen = typeof queryResult === "object" && queryResult !== null && !Array.isArray(queryResult)
111
+ ? findPrimaryArrayLength(queryResult)
112
+ : null;
113
+ status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
114
+ budgetResult = queryResult;
115
+ }
116
+ else if (maxTokens !== undefined) {
117
+ ({ status, result: budgetResult } = buildStatusMessage(queryResult, maxTokens));
118
+ }
119
+ else {
120
+ // No budget, no unlimited → enforce 10k safety limit
121
+ const estimatedTokens = estimateResultTokens(queryResult);
122
+ if (estimatedTokens > 10000) {
123
+ return {
124
+ content: [{
125
+ type: "text",
126
+ text: JSON.stringify({
127
+ error: `Response too large (~${estimatedTokens} tokens). To proceed, either:\n` +
128
+ `1. Use inspect_api to inspect the schema and select fewer fields\n` +
129
+ `2. Set maxTokens (e.g. maxTokens: 4000) to truncate automatically\n` +
130
+ `3. Set unlimited: true if you need the full response`,
131
+ estimatedTokens,
132
+ }, null, 2),
133
+ }],
134
+ isError: true,
135
+ };
136
+ }
137
+ const arrayLen = typeof queryResult === "object" && queryResult !== null && !Array.isArray(queryResult)
138
+ ? findPrimaryArrayLength(queryResult)
139
+ : null;
140
+ status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
141
+ budgetResult = queryResult;
142
+ }
143
+ if (typeof budgetResult === "object" && budgetResult !== null && !Array.isArray(budgetResult)) {
144
+ const qr = budgetResult;
145
+ attachRateLimit(qr, respHeaders);
146
+ if (!fromCache) {
147
+ qr._schema = schemaToSDL(schema);
148
+ const suggestions = generateSuggestions(schema);
149
+ if (suggestions.length > 0) {
150
+ qr._suggestedQueries = suggestions;
151
+ }
152
+ }
153
+ const pagination = detectPagination(rawData, endpoint?.parameters);
154
+ if (pagination) {
155
+ qr._pagination = pagination;
156
+ }
157
+ const output = { _status: status, ...qr };
158
+ if (newDataKey)
159
+ output._dataKey = newDataKey;
160
+ if (cachedDataAge !== undefined)
161
+ output._dataAge = `${cachedDataAge}s ago (from cache)`;
162
+ return {
163
+ content: [
164
+ { type: "text", text: JSON.stringify(output, null, 2) },
165
+ ],
166
+ };
167
+ }
168
+ const output = { _status: status, data: budgetResult };
169
+ if (newDataKey)
170
+ output._dataKey = newDataKey;
171
+ if (cachedDataAge !== undefined)
172
+ output._dataAge = `${cachedDataAge}s ago (from cache)`;
173
+ return {
174
+ content: [
175
+ { type: "text", text: JSON.stringify(output, null, 2) },
176
+ ],
177
+ };
178
+ }
179
+ catch (error) {
180
+ return formatToolError(error, apiIndex, "GET", path);
181
+ }
182
+ });
183
+ }
@@ -0,0 +1,65 @@
1
+ import { ApiError, buildErrorContext } from "../error-context.js";
2
+ import { RetryableError } from "../retry.js";
3
+ import { parseRateLimits } from "../api-client.js";
4
+ export const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
5
+ export function formatToolError(error, apiIndex, method, path) {
6
+ if ((error instanceof ApiError || error instanceof RetryableError) && method && path) {
7
+ const endpoint = apiIndex.getEndpoint(method, path);
8
+ const context = buildErrorContext(error, method, path, endpoint);
9
+ return {
10
+ content: [{ type: "text", text: JSON.stringify(context, null, 2) }],
11
+ isError: true,
12
+ };
13
+ }
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ return {
16
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
17
+ isError: true,
18
+ };
19
+ }
20
+ export function attachRateLimit(result, respHeaders) {
21
+ const rl = parseRateLimits(respHeaders);
22
+ if (!rl)
23
+ return;
24
+ result._rateLimit = rl;
25
+ if (rl.remaining !== null &&
26
+ (rl.remaining <= 5 || (rl.limit !== null && rl.remaining / rl.limit <= 0.1))) {
27
+ result._rateLimitWarning =
28
+ `Rate limit nearly exhausted (${rl.remaining}${rl.limit !== null ? `/${rl.limit}` : ""} remaining` +
29
+ `${rl.resetAt ? `, resets ${rl.resetAt}` : ""}). Consider reducing request frequency.`;
30
+ }
31
+ }
32
+ export function shrinkageError(warnings, keyInfo) {
33
+ const keyLabel = keyInfo.backupDataKey ? "backupDataKey" : "dataKey";
34
+ const keyValue = keyInfo.backupDataKey ?? keyInfo.dataKey;
35
+ return {
36
+ content: [{
37
+ type: "text",
38
+ text: JSON.stringify({
39
+ error: "Array shrinkage detected: request body has significantly fewer array items than current state",
40
+ warnings,
41
+ ...(keyValue ? { [keyLabel]: keyValue } : {}),
42
+ hint: "This often happens when truncated query results are sent back as the full payload. " +
43
+ "Consider using mutate_api with 'patch' mode to apply targeted changes without needing the full data. " +
44
+ "Or use query_api with unlimited: true and the " + keyLabel + " to retrieve the full current data. " +
45
+ "If this is intentional, use skipBackup: true to bypass this check.",
46
+ }, null, 2),
47
+ }],
48
+ isError: true,
49
+ };
50
+ }
51
+ export function placeholderError(warnings) {
52
+ return {
53
+ content: [{
54
+ type: "text",
55
+ text: JSON.stringify({
56
+ error: "Potential placeholder values detected in request body",
57
+ warnings,
58
+ hint: "The request was blocked to prevent sending placeholder data. " +
59
+ "If the body is too large to send inline, use the 'bodyFile' parameter " +
60
+ "with an absolute path to a JSON file containing the real content.",
61
+ }, null, 2),
62
+ }],
63
+ isError: true,
64
+ };
65
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Compare array field sizes in a PUT/PATCH body against the backup (current state).
3
+ * Returns warnings when the body has significantly fewer items than the backup,
4
+ * which usually means truncated query results leaked into the write payload.
5
+ *
6
+ * @param body - The request body being sent
7
+ * @param backupData - The full data from the pre-write backup
8
+ * @param options.threshold - Ratio below which to warn (default 0.5 = body < 50% of backup)
9
+ */
10
+ export function detectArrayShrinkage(body, backupData, options) {
11
+ if (typeof backupData !== "object" || backupData === null || Array.isArray(backupData)) {
12
+ return [];
13
+ }
14
+ const threshold = options?.threshold ?? 0.5;
15
+ const minBackupLength = 5;
16
+ const warnings = [];
17
+ const backup = backupData;
18
+ // Level 1: direct fields
19
+ for (const [key, bodyVal] of Object.entries(body)) {
20
+ const backupVal = backup[key];
21
+ if (Array.isArray(bodyVal) && Array.isArray(backupVal)) {
22
+ if (backupVal.length >= minBackupLength &&
23
+ bodyVal.length / backupVal.length < threshold) {
24
+ warnings.push({
25
+ field: key,
26
+ bodyLength: bodyVal.length,
27
+ backupLength: backupVal.length,
28
+ reason: `Request body has ${bodyVal.length} items in '${key}' but the current resource has ${backupVal.length}. ` +
29
+ `This PUT/PATCH will replace the entire array — ${backupVal.length - bodyVal.length} items will be lost.`,
30
+ });
31
+ }
32
+ }
33
+ // Level 2: one level of nesting
34
+ if (typeof bodyVal === "object" && bodyVal !== null && !Array.isArray(bodyVal) &&
35
+ typeof backupVal === "object" && backupVal !== null && !Array.isArray(backupVal)) {
36
+ const bodyObj = bodyVal;
37
+ const backupObj = backupVal;
38
+ for (const [nestedKey, nestedBodyVal] of Object.entries(bodyObj)) {
39
+ const nestedBackupVal = backupObj[nestedKey];
40
+ if (Array.isArray(nestedBodyVal) && Array.isArray(nestedBackupVal)) {
41
+ if (nestedBackupVal.length >= minBackupLength &&
42
+ nestedBodyVal.length / nestedBackupVal.length < threshold) {
43
+ warnings.push({
44
+ field: `${key}.${nestedKey}`,
45
+ bodyLength: nestedBodyVal.length,
46
+ backupLength: nestedBackupVal.length,
47
+ reason: `Request body has ${nestedBodyVal.length} items in '${key}.${nestedKey}' but the current resource has ${nestedBackupVal.length}. ` +
48
+ `This PUT/PATCH will replace the entire array — ${nestedBackupVal.length - nestedBodyVal.length} items will be lost.`,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return warnings;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyapi-mcp-server",
3
- "version": "1.8.1",
3
+ "version": "2.0.0",
4
4
  "description": "A universal MCP server that connects any REST API (via OpenAPI spec) to AI assistants, with GraphQL-style field selection and automatic schema inference.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",