anyapi-mcp-server 1.7.0 → 1.8.1
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/README.md +5 -1
- package/build/body-file.js +42 -0
- package/build/body-validation.js +102 -0
- package/build/graphql-schema.js +14 -6
- package/build/index.js +114 -9
- package/build/pre-write-backup.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ Discover what the API offers. Call with no arguments to see all categories, prov
|
|
|
102
102
|
|
|
103
103
|
### `call_api` — Inspect an endpoint
|
|
104
104
|
|
|
105
|
-
Makes a real HTTP request and returns the **inferred GraphQL schema** (SDL) — not the data itself. Use this to discover the response shape and get `suggestedQueries` you can copy into `query_api`. Also returns per-field token costs (`fieldTokenCosts`) and a `dataKey` for cache reuse.
|
|
105
|
+
Makes a real HTTP request and returns the **inferred GraphQL schema** (SDL) — not the data itself. Use this to discover the response shape and get `suggestedQueries` you can copy into `query_api`. Also returns per-field token costs (`fieldTokenCosts`) and a `dataKey` for cache reuse. For PUT/PATCH requests, automatically creates a pre-write backup (returns `backupDataKey`). Supports `bodyFile` for large payloads and blocks requests with detected placeholder values.
|
|
106
106
|
|
|
107
107
|
### `query_api` — Fetch data
|
|
108
108
|
|
|
@@ -120,6 +120,8 @@ Key parameters:
|
|
|
120
120
|
- **`maxTokens`** — token budget for the response (default 4000). Arrays are truncated to fit.
|
|
121
121
|
- **`dataKey`** — reuse cached data from a previous `call_api` or `query_api` response.
|
|
122
122
|
- **`jsonFilter`** — dot-path to extract nested values after the GraphQL query (e.g. `"data[].attributes.name"`).
|
|
123
|
+
- **`bodyFile`** — absolute path to a JSON file to use as request body (mutually exclusive with `body`). Use for large payloads that can't be sent inline.
|
|
124
|
+
- **`skipBackup`** — skip the automatic pre-write backup for PUT/PATCH requests (default: `false`).
|
|
123
125
|
|
|
124
126
|
### `explain_api` — Read the docs
|
|
125
127
|
|
|
@@ -187,6 +189,8 @@ OpenAPI/Postman spec
|
|
|
187
189
|
- **JSON filter** — `query_api` accepts a `jsonFilter` dot-path for post-query extraction (e.g. `"data[].name"`)
|
|
188
190
|
- **Retry with backoff** — automatic retries for 429/5xx with exponential backoff and `Retry-After` support
|
|
189
191
|
- **Multi-format** — parses JSON, XML, CSV, and plain text responses
|
|
192
|
+
- **Safe writes** — PUT/PATCH requests automatically snapshot the resource before writing (`backupDataKey`); placeholder values (e.g. `PLACEHOLDER`, `TODO`, `file://`) are detected and blocked before sending
|
|
193
|
+
- **File-based body** — `bodyFile` parameter accepts an absolute path to a JSON file, enabling large payloads that can't be sent inline
|
|
190
194
|
- **Rich errors** — structured error messages with status-specific suggestions and spec context for self-correction
|
|
191
195
|
- **OAuth 2.0** — Authorization Code (with PKCE) and Client Credentials flows with automatic token refresh
|
|
192
196
|
- **Env var interpolation** — `${ENV_VAR}` in base URLs, headers, and spec paths
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, isAbsolute } from "node:path";
|
|
3
|
+
import { platform } from "node:process";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve request body from either an inline `body` object or a `bodyFile` path.
|
|
6
|
+
* Throws if both are provided, if the path is relative, or if the file is unreadable/invalid JSON.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveBody(body, bodyFile) {
|
|
9
|
+
if (body && bodyFile) {
|
|
10
|
+
throw new Error("Cannot specify both 'body' and 'bodyFile'. Use one or the other.");
|
|
11
|
+
}
|
|
12
|
+
if (!bodyFile)
|
|
13
|
+
return body;
|
|
14
|
+
// Validate absolute path
|
|
15
|
+
const isAbsolutePath = isAbsolute(bodyFile) ||
|
|
16
|
+
(platform === "win32" && /^[A-Za-z]:[\\/]/.test(bodyFile));
|
|
17
|
+
if (!isAbsolutePath) {
|
|
18
|
+
throw new Error(`bodyFile must be an absolute path, got: ${bodyFile}`);
|
|
19
|
+
}
|
|
20
|
+
const fullPath = resolve(bodyFile);
|
|
21
|
+
let content;
|
|
22
|
+
try {
|
|
23
|
+
content = readFileSync(fullPath, "utf-8");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
throw new Error(`Failed to read bodyFile '${fullPath}': ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(content);
|
|
31
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
32
|
+
throw new Error("bodyFile must contain a JSON object (not an array or primitive)");
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err instanceof SyntaxError) {
|
|
38
|
+
throw new Error(`bodyFile '${fullPath}' contains invalid JSON: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const CONTENT_FIELD_NAMES = new Set([
|
|
2
|
+
"html_content",
|
|
3
|
+
"html",
|
|
4
|
+
"content",
|
|
5
|
+
"body",
|
|
6
|
+
"template",
|
|
7
|
+
"description",
|
|
8
|
+
"text",
|
|
9
|
+
"message",
|
|
10
|
+
"markup",
|
|
11
|
+
"source",
|
|
12
|
+
"html_body",
|
|
13
|
+
"plain_content",
|
|
14
|
+
"rich_content",
|
|
15
|
+
]);
|
|
16
|
+
const EXACT_KEYWORDS = new Set(["placeholder", "todo", "tbd", "fixme", "xxx"]);
|
|
17
|
+
const PATTERN_REGEXES = [
|
|
18
|
+
/^file:\/\//,
|
|
19
|
+
/^<[^>]+>$/,
|
|
20
|
+
/^\[[^\]]+\]$/,
|
|
21
|
+
/^content of /i,
|
|
22
|
+
/^see (above|below|file)/i,
|
|
23
|
+
];
|
|
24
|
+
function isContentField(name) {
|
|
25
|
+
return CONTENT_FIELD_NAMES.has(name) || name.includes("html");
|
|
26
|
+
}
|
|
27
|
+
function checkKeywordPatterns(value) {
|
|
28
|
+
const lower = value.trim().toLowerCase();
|
|
29
|
+
if (EXACT_KEYWORDS.has(lower)) {
|
|
30
|
+
return `Suspicious keyword: "${value.trim()}"`;
|
|
31
|
+
}
|
|
32
|
+
for (const re of PATTERN_REGEXES) {
|
|
33
|
+
if (re.test(value.trim())) {
|
|
34
|
+
return `Suspicious pattern: "${value.trim()}"`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function hasHtmlSchemaHint(fieldName, schema) {
|
|
40
|
+
if (!schema?.properties)
|
|
41
|
+
return false;
|
|
42
|
+
const prop = schema.properties[fieldName];
|
|
43
|
+
if (!prop?.description)
|
|
44
|
+
return false;
|
|
45
|
+
const desc = prop.description.toLowerCase();
|
|
46
|
+
return /html|content|template|body|markup/.test(desc);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detect placeholder values in a request body that likely indicate
|
|
50
|
+
* the LLM failed to emit real content.
|
|
51
|
+
*/
|
|
52
|
+
export function detectPlaceholders(body, schema) {
|
|
53
|
+
if (!body || typeof body !== "object")
|
|
54
|
+
return [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
const keys = Object.keys(body);
|
|
57
|
+
// Pass 1: keyword patterns on string fields (shallow + 1 level nested)
|
|
58
|
+
for (const [key, value] of Object.entries(body)) {
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
const reason = checkKeywordPatterns(value);
|
|
61
|
+
if (reason && (isContentField(key) || keys.length <= 2)) {
|
|
62
|
+
warnings.push({ field: key, value, reason });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
66
|
+
// One level nested
|
|
67
|
+
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
|
68
|
+
if (typeof nestedValue === "string") {
|
|
69
|
+
const reason = checkKeywordPatterns(nestedValue);
|
|
70
|
+
if (reason && (isContentField(nestedKey) || keys.length <= 2)) {
|
|
71
|
+
warnings.push({
|
|
72
|
+
field: `${key}.${nestedKey}`,
|
|
73
|
+
value: nestedValue,
|
|
74
|
+
reason,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Pass 2: short value for known content fields
|
|
82
|
+
for (const [key, value] of Object.entries(body)) {
|
|
83
|
+
if (typeof value !== "string")
|
|
84
|
+
continue;
|
|
85
|
+
if (value.length >= 50)
|
|
86
|
+
continue;
|
|
87
|
+
const fieldIsContent = isContentField(key);
|
|
88
|
+
const schemaHasHtml = hasHtmlSchemaHint(key, schema);
|
|
89
|
+
if ((fieldIsContent || schemaHasHtml) && (schemaHasHtml || key.includes("html"))) {
|
|
90
|
+
// Don't duplicate warnings already found in pass 1
|
|
91
|
+
const alreadyWarned = warnings.some((w) => w.field === key);
|
|
92
|
+
if (!alreadyWarned) {
|
|
93
|
+
warnings.push({
|
|
94
|
+
field: key,
|
|
95
|
+
value,
|
|
96
|
+
reason: `Suspiciously short value (${value.length} chars) for a content/HTML field`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return warnings;
|
|
102
|
+
}
|
package/build/graphql-schema.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { GraphQLSchema, GraphQLObjectType, GraphQLInputObjectType, GraphQLScalarType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, isObjectType, isListType, isScalarType,
|
|
1
|
+
import { GraphQLSchema, GraphQLObjectType, GraphQLInputObjectType, GraphQLScalarType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, isObjectType, isListType, isScalarType, parse, validate, execute, specifiedRules, FieldsOnCorrectTypeRule, ScalarLeafsRule, printSchema, } from "graphql";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
const MAX_ARRAY_LIMIT = 50;
|
|
4
4
|
const MAX_SAMPLE_SIZE = 50;
|
|
5
5
|
const MAJORITY_THRESHOLD = 0.6;
|
|
6
|
+
/**
|
|
7
|
+
* Validation rules that skip FieldsOnCorrectTypeRule and ScalarLeafsRule.
|
|
8
|
+
* This allows queries to select subfields on empty arrays (inferred as [JSON])
|
|
9
|
+
* and unknown fields (which silently resolve to undefined/omitted).
|
|
10
|
+
*/
|
|
11
|
+
const lenientRules = specifiedRules.filter((rule) => rule !== FieldsOnCorrectTypeRule && rule !== ScalarLeafsRule);
|
|
6
12
|
/**
|
|
7
13
|
* Estimate token cost of a JSON value by walking its structure.
|
|
8
14
|
* For scalars, estimates based on string length (long strings cost more tokens).
|
|
@@ -757,11 +763,13 @@ export async function executeQuery(schema, data, query) {
|
|
|
757
763
|
const fullQuery = trimmed.startsWith("query") || trimmed.startsWith("mutation") || trimmed.startsWith("{")
|
|
758
764
|
? trimmed
|
|
759
765
|
: `{ ${trimmed} }`;
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
766
|
+
const document = parse(fullQuery);
|
|
767
|
+
const validationErrors = validate(schema, document, lenientRules);
|
|
768
|
+
if (validationErrors.length > 0) {
|
|
769
|
+
const messages = validationErrors.map((e) => e.message).join("; ");
|
|
770
|
+
throw new Error(`GraphQL query error: ${messages}`);
|
|
771
|
+
}
|
|
772
|
+
const result = await execute({ schema, document, rootValue: data });
|
|
765
773
|
if (result.errors && result.errors.length > 0) {
|
|
766
774
|
const messages = result.errors.map((e) => e.message).join("; ");
|
|
767
775
|
throw new Error(`GraphQL query error: ${messages}`);
|
package/build/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import { startAuth, exchangeCode, awaitCallback, storeTokens, getTokens, isToken
|
|
|
16
16
|
import { detectPagination, PAGINATION_PARAM_NAMES } from "./pagination.js";
|
|
17
17
|
import { applyJsonFilter } from "./json-filter.js";
|
|
18
18
|
import { storeResponse, loadResponse } from "./data-cache.js";
|
|
19
|
+
import { resolveBody } from "./body-file.js";
|
|
20
|
+
import { detectPlaceholders } from "./body-validation.js";
|
|
21
|
+
import { createBackup } from "./pre-write-backup.js";
|
|
19
22
|
const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
20
23
|
const config = await loadConfig();
|
|
21
24
|
initLogger(config.logPath ?? null);
|
|
@@ -171,14 +174,57 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
|
|
|
171
174
|
.record(z.unknown())
|
|
172
175
|
.optional()
|
|
173
176
|
.describe("Request body for POST/PUT/PATCH"),
|
|
177
|
+
bodyFile: z
|
|
178
|
+
.string()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("Absolute path to a JSON file to use as request body. " +
|
|
181
|
+
"Mutually exclusive with 'body'. Use for large payloads that cannot be inlined."),
|
|
174
182
|
headers: z
|
|
175
183
|
.record(z.string())
|
|
176
184
|
.optional()
|
|
177
185
|
.describe("Additional HTTP headers for this request (e.g. { \"Authorization\": \"Bearer <token>\" }). " +
|
|
178
186
|
"Overrides default --header values."),
|
|
179
|
-
|
|
187
|
+
skipBackup: z
|
|
188
|
+
.boolean()
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("Skip the automatic pre-write backup for PUT/PATCH requests. " +
|
|
191
|
+
"Default: false (backup is created automatically)."),
|
|
192
|
+
}, async ({ method, path, params, body, bodyFile, headers, skipBackup }) => {
|
|
180
193
|
try {
|
|
181
|
-
|
|
194
|
+
// Resolve body from inline or file
|
|
195
|
+
let resolvedBody;
|
|
196
|
+
try {
|
|
197
|
+
resolvedBody = resolveBody(body, bodyFile);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
return formatToolError(err);
|
|
201
|
+
}
|
|
202
|
+
// Placeholder detection for write methods (except DELETE)
|
|
203
|
+
if (resolvedBody && WRITE_METHODS.has(method) && method !== "DELETE") {
|
|
204
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
205
|
+
const warnings = detectPlaceholders(resolvedBody, endpoint?.requestBodySchema);
|
|
206
|
+
if (warnings.length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
content: [{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: JSON.stringify({
|
|
211
|
+
error: "Potential placeholder values detected in request body",
|
|
212
|
+
warnings,
|
|
213
|
+
hint: "The request was blocked to prevent sending placeholder data. " +
|
|
214
|
+
"If the body is too large to send inline, use the 'bodyFile' parameter " +
|
|
215
|
+
"with an absolute path to a JSON file containing the real content.",
|
|
216
|
+
}, null, 2),
|
|
217
|
+
}],
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Pre-write backup for PUT/PATCH
|
|
223
|
+
let backupDataKey;
|
|
224
|
+
if ((method === "PATCH" || method === "PUT") && !skipBackup) {
|
|
225
|
+
backupDataKey = await createBackup(config, method, path, params, headers);
|
|
226
|
+
}
|
|
227
|
+
const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, resolvedBody, headers);
|
|
182
228
|
const dataKey = storeResponse(method, path, data, respHeaders);
|
|
183
229
|
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
184
230
|
if (isNonJsonResult(data)) {
|
|
@@ -195,12 +241,16 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
|
|
|
195
241
|
};
|
|
196
242
|
}
|
|
197
243
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
198
|
-
const bodyHash = WRITE_METHODS.has(method) &&
|
|
244
|
+
const bodyHash = WRITE_METHODS.has(method) && resolvedBody ? computeShapeHash(resolvedBody) : undefined;
|
|
199
245
|
const { schema, shapeHash } = getOrBuildSchema(data, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
200
246
|
const sdl = schemaToSDL(schema);
|
|
201
247
|
const result = { graphqlSchema: sdl, shapeHash, dataKey, responseHeaders: respHeaders };
|
|
202
248
|
if (bodyHash)
|
|
203
249
|
result.bodyHash = bodyHash;
|
|
250
|
+
if (backupDataKey) {
|
|
251
|
+
result.backupDataKey = backupDataKey;
|
|
252
|
+
result.backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
|
|
253
|
+
}
|
|
204
254
|
attachRateLimit(result, respHeaders);
|
|
205
255
|
if (endpoint && endpoint.parameters.length > 0) {
|
|
206
256
|
result.parameters = endpoint.parameters.map((p) => ({
|
|
@@ -319,6 +369,11 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
319
369
|
.record(z.unknown())
|
|
320
370
|
.optional()
|
|
321
371
|
.describe("Request body for POST/PUT/PATCH"),
|
|
372
|
+
bodyFile: z
|
|
373
|
+
.string()
|
|
374
|
+
.optional()
|
|
375
|
+
.describe("Absolute path to a JSON file to use as request body. " +
|
|
376
|
+
"Mutually exclusive with 'body'. Use for large payloads that cannot be inlined."),
|
|
322
377
|
query: z
|
|
323
378
|
.string()
|
|
324
379
|
.describe("GraphQL selection query using field names from call_api schema " +
|
|
@@ -345,11 +400,45 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
345
400
|
.optional()
|
|
346
401
|
.describe("Token budget for the response (default: 4000). If exceeded, array results are truncated to fit. " +
|
|
347
402
|
"Select fewer fields to fit more items."),
|
|
348
|
-
|
|
403
|
+
skipBackup: z
|
|
404
|
+
.boolean()
|
|
405
|
+
.optional()
|
|
406
|
+
.describe("Skip the automatic pre-write backup for PUT/PATCH requests. " +
|
|
407
|
+
"Default: false (backup is created automatically)."),
|
|
408
|
+
}, async ({ method, path, params, body, bodyFile, query, dataKey, headers, jsonFilter, maxTokens, skipBackup }) => {
|
|
349
409
|
try {
|
|
350
410
|
const budget = maxTokens ?? 4000;
|
|
411
|
+
// Resolve body from inline or file
|
|
412
|
+
let resolvedBody;
|
|
413
|
+
try {
|
|
414
|
+
resolvedBody = resolveBody(body, bodyFile);
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
return formatToolError(err);
|
|
418
|
+
}
|
|
419
|
+
// Placeholder detection for write methods (except DELETE)
|
|
420
|
+
if (resolvedBody && WRITE_METHODS.has(method) && method !== "DELETE") {
|
|
421
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
422
|
+
const warnings = detectPlaceholders(resolvedBody, endpoint?.requestBodySchema);
|
|
423
|
+
if (warnings.length > 0) {
|
|
424
|
+
return {
|
|
425
|
+
content: [{
|
|
426
|
+
type: "text",
|
|
427
|
+
text: JSON.stringify({
|
|
428
|
+
error: "Potential placeholder values detected in request body",
|
|
429
|
+
warnings,
|
|
430
|
+
hint: "The request was blocked to prevent sending placeholder data. " +
|
|
431
|
+
"If the body is too large to send inline, use the 'bodyFile' parameter " +
|
|
432
|
+
"with an absolute path to a JSON file containing the real content.",
|
|
433
|
+
}, null, 2),
|
|
434
|
+
}],
|
|
435
|
+
isError: true,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
351
439
|
let rawData;
|
|
352
440
|
let respHeaders;
|
|
441
|
+
let backupDataKey;
|
|
353
442
|
// Try dataKey cache first
|
|
354
443
|
const cached = dataKey ? loadResponse(dataKey) : null;
|
|
355
444
|
if (cached) {
|
|
@@ -357,7 +446,11 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
357
446
|
respHeaders = cached.responseHeaders;
|
|
358
447
|
}
|
|
359
448
|
else {
|
|
360
|
-
|
|
449
|
+
// Pre-write backup for PUT/PATCH
|
|
450
|
+
if ((method === "PATCH" || method === "PUT") && !skipBackup) {
|
|
451
|
+
backupDataKey = await createBackup(config, method, path, params, headers);
|
|
452
|
+
}
|
|
453
|
+
const result = await callApi(config, method, path, params, resolvedBody, headers);
|
|
361
454
|
rawData = result.data;
|
|
362
455
|
respHeaders = result.responseHeaders;
|
|
363
456
|
}
|
|
@@ -378,14 +471,17 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
378
471
|
};
|
|
379
472
|
}
|
|
380
473
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
381
|
-
const bodyHash = WRITE_METHODS.has(method) &&
|
|
474
|
+
const bodyHash = WRITE_METHODS.has(method) && resolvedBody ? computeShapeHash(resolvedBody) : undefined;
|
|
382
475
|
const { schema, shapeHash, fromCache } = getOrBuildSchema(rawData, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
383
476
|
let queryResult = await executeQuery(schema, rawData, query);
|
|
384
477
|
if (jsonFilter) {
|
|
385
478
|
queryResult = applyJsonFilter(queryResult, jsonFilter);
|
|
386
479
|
}
|
|
387
|
-
// Apply token budget
|
|
388
|
-
const
|
|
480
|
+
// Apply token budget (skip for write operations — mutation responses shouldn't be truncated)
|
|
481
|
+
const isWrite = WRITE_METHODS.has(method);
|
|
482
|
+
const { status, result: budgetResult } = isWrite
|
|
483
|
+
? { status: "COMPLETE", result: queryResult }
|
|
484
|
+
: buildStatusMessage(queryResult, budget);
|
|
389
485
|
if (typeof budgetResult === "object" && budgetResult !== null && !Array.isArray(budgetResult)) {
|
|
390
486
|
const qr = budgetResult;
|
|
391
487
|
attachRateLimit(qr, respHeaders);
|
|
@@ -404,15 +500,24 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
404
500
|
}
|
|
405
501
|
// _status as first key
|
|
406
502
|
const output = { _status: status, _dataKey: newDataKey, ...qr };
|
|
503
|
+
if (backupDataKey) {
|
|
504
|
+
output._backupDataKey = backupDataKey;
|
|
505
|
+
output._backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
|
|
506
|
+
}
|
|
407
507
|
return {
|
|
408
508
|
content: [
|
|
409
509
|
{ type: "text", text: JSON.stringify(output, null, 2) },
|
|
410
510
|
],
|
|
411
511
|
};
|
|
412
512
|
}
|
|
513
|
+
const output = { _status: status, _dataKey: newDataKey, data: budgetResult };
|
|
514
|
+
if (backupDataKey) {
|
|
515
|
+
output._backupDataKey = backupDataKey;
|
|
516
|
+
output._backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
|
|
517
|
+
}
|
|
413
518
|
return {
|
|
414
519
|
content: [
|
|
415
|
-
{ type: "text", text: JSON.stringify(
|
|
520
|
+
{ type: "text", text: JSON.stringify(output, null, 2) },
|
|
416
521
|
],
|
|
417
522
|
};
|
|
418
523
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { callApi } from "./api-client.js";
|
|
2
|
+
import { storeResponse } from "./data-cache.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create a pre-write backup by fetching the current state of a resource via GET.
|
|
5
|
+
* Returns a dataKey for the cached snapshot, or undefined on failure.
|
|
6
|
+
* Failure is non-fatal — errors are logged to stderr.
|
|
7
|
+
*/
|
|
8
|
+
export async function createBackup(config, method, path, params, headers) {
|
|
9
|
+
if (method !== "PATCH" && method !== "PUT")
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const { data, responseHeaders } = await callApi(config, "GET", path, params, undefined, headers);
|
|
13
|
+
return storeResponse("GET", path, data, responseHeaders);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17
|
+
process.stderr.write(`pre-write-backup: GET ${path} failed: ${msg}\n`);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyapi-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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",
|