anyapi-mcp-server 1.7.0 → 1.8.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/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/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
- }, async ({ method, path, params, body, headers }) => {
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
- const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, body, headers);
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) && body ? computeShapeHash(body) : undefined;
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
- }, async ({ method, path, params, body, query, dataKey, headers, jsonFilter, maxTokens }) => {
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
- const result = await callApi(config, method, path, params, body, headers);
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) && body ? computeShapeHash(body) : undefined;
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 { status, result: budgetResult } = buildStatusMessage(queryResult, budget);
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({ _status: status, _dataKey: newDataKey, data: budgetResult }, null, 2) },
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.7.0",
3
+ "version": "1.8.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",