anyapi-mcp-server 1.5.1 → 1.6.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
@@ -28,6 +28,7 @@ Works with services like **Datadog**, **PostHog**, **Metabase**, **Cloudflare**,
28
28
  - **Concurrent batch queries** — `batch_query` fetches data from up to 10 endpoints in parallel, returning all results in one tool call
29
29
  - **Per-request headers** — override default headers on individual `call_api`/`query_api`/`batch_query` calls
30
30
  - **Environment variable interpolation** — use `${ENV_VAR}` in base URLs and headers
31
+ - **Rich error context** — API errors return structured messages (parses RFC 7807, `{ error: { message, code } }`, `{ errors: [...] }`, and more), status-specific suggestions (e.g. "Authentication required" for 401), and relevant spec info (required parameters, request body schema) for 400/422 errors so the LLM can self-correct
31
32
  - **Request logging** — optional NDJSON request/response log with sensitive header masking
32
33
 
33
34
  [![npm](https://img.shields.io/npm/v/anyapi-mcp-server)](https://www.npmjs.com/package/anyapi-mcp-server)
@@ -229,7 +230,7 @@ Fetch data from multiple endpoints concurrently in a single tool call.
229
230
  - Accepts an array of 1–10 requests, each with `method`, `path`, `params`, `body`, `query`, and optional `headers`
230
231
  - All requests execute in parallel via `Promise.allSettled` — one failure does not affect the others
231
232
  - Each request follows the `query_api` flow: HTTP fetch → schema inference → GraphQL field selection
232
- - Returns an array of results: `{ method, path, data, shapeHash }` on success or `{ method, path, error }` on failure
233
+ - Returns an array of results: `{ method, path, data, shapeHash }` on success or a structured error with status, suggestion, and spec context on failure
233
234
  - Run `call_api` first on each endpoint to discover the schema field names
234
235
 
235
236
  ## Workflow
@@ -267,6 +268,7 @@ OpenAPI/Postman spec
267
268
  2. `call_api` makes a real HTTP request, infers a GraphQL schema from the JSON response, and caches both the response (30s TTL) and the schema. Schemas are keyed by endpoint + response shape, so the same path returning different structures gets distinct schemas
268
269
  3. `query_api` re-uses the cached response if called within 30s, executes your GraphQL field selection against the data, and returns only the fields you asked for. Includes `_shapeHash` in the response for tracking schema identity
269
270
  4. Write operations (POST/PUT/DELETE/PATCH) with OpenAPI request body schemas get a Mutation type with typed `GraphQLInputObjectType` inputs
271
+ 5. When an API call fails, the error response includes a parsed error message (extracted from common formats like RFC 7807 Problem Details, `{ error: { message } }`, GraphQL-style `{ errors: [...] }`), the HTTP status code, a status-specific suggestion for what to try next, and — for validation errors (400/422) — the full parameter list and request body schema from the spec
270
272
 
271
273
  ## Supported Spec Formats
272
274
 
@@ -2,6 +2,7 @@ import { withRetry, RetryableError, isRetryableStatus } from "./retry.js";
2
2
  import { buildCacheKey, consumeCached, setCache } from "./response-cache.js";
3
3
  import { logEntry, isLoggingEnabled } from "./logger.js";
4
4
  import { parseResponse } from "./response-parser.js";
5
+ import { ApiError } from "./error-context.js";
5
6
  const TIMEOUT_MS = 30_000;
6
7
  function interpolatePath(pathTemplate, params) {
7
8
  const remaining = { ...params };
@@ -65,12 +66,12 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
65
66
  const response = await fetch(fullUrl, fetchOptions);
66
67
  const durationMs = Date.now() - startTime;
67
68
  const bodyText = await response.text();
69
+ const responseHeaders = {};
70
+ response.headers.forEach((v, k) => {
71
+ responseHeaders[k] = v;
72
+ });
68
73
  // Log request/response
69
74
  if (isLoggingEnabled()) {
70
- const responseHeaders = {};
71
- response.headers.forEach((v, k) => {
72
- responseHeaders[k] = v;
73
- });
74
75
  await logEntry({
75
76
  timestamp: new Date().toISOString(),
76
77
  method,
@@ -84,8 +85,8 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
84
85
  });
85
86
  }
86
87
  if (!response.ok) {
87
- const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
88
88
  if (isRetryableStatus(response.status)) {
89
+ const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
89
90
  let retryAfterMs;
90
91
  const retryAfter = response.headers.get("retry-after");
91
92
  if (retryAfter) {
@@ -95,7 +96,7 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
95
96
  }
96
97
  throw new RetryableError(msg, response.status, retryAfterMs);
97
98
  }
98
- throw new Error(msg);
99
+ throw new ApiError(`API error ${response.status} ${response.statusText}`, response.status, response.statusText, bodyText, responseHeaders);
99
100
  }
100
101
  return parseResponse(response.headers.get("content-type"), bodyText);
101
102
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Rich API error carrying status, raw body, and response headers
3
+ * for structured error reporting.
4
+ */
5
+ export class ApiError extends Error {
6
+ status;
7
+ statusText;
8
+ bodyText;
9
+ responseHeaders;
10
+ constructor(message, status, statusText, bodyText, responseHeaders = {}) {
11
+ super(message);
12
+ this.status = status;
13
+ this.statusText = statusText;
14
+ this.bodyText = bodyText;
15
+ this.responseHeaders = responseHeaders;
16
+ this.name = "ApiError";
17
+ }
18
+ }
19
+ /**
20
+ * Extract a human-readable error message from common API error response formats:
21
+ * - RFC 7807 Problem Details: { type, title, status, detail }
22
+ * - Stripe/generic: { error: { message, code } }
23
+ * - Simple: { error: "message" } or { message: "..." }
24
+ * - GraphQL-style: { errors: [{ message }] }
25
+ * - SOAP/Apigee: { fault: { faultstring } }
26
+ */
27
+ export function extractErrorMessage(bodyText) {
28
+ if (!bodyText.trim())
29
+ return undefined;
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(bodyText);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ if (typeof parsed !== "object" || parsed === null)
38
+ return undefined;
39
+ const obj = parsed;
40
+ // RFC 7807 Problem Details
41
+ if (typeof obj.detail === "string") {
42
+ const title = typeof obj.title === "string" ? obj.title : undefined;
43
+ return title ? `${title}: ${obj.detail}` : obj.detail;
44
+ }
45
+ // { error: { message, code? } }
46
+ if (obj.error && typeof obj.error === "object") {
47
+ const errObj = obj.error;
48
+ if (typeof errObj.message === "string") {
49
+ const code = typeof errObj.code === "string" ? ` (${errObj.code})` : "";
50
+ return `${errObj.message}${code}`;
51
+ }
52
+ }
53
+ // { error: "message" }
54
+ if (typeof obj.error === "string") {
55
+ return obj.error;
56
+ }
57
+ // { message: "..." }
58
+ if (typeof obj.message === "string") {
59
+ return obj.message;
60
+ }
61
+ // { errors: [{ message }] }
62
+ if (Array.isArray(obj.errors)) {
63
+ const messages = obj.errors
64
+ .filter((e) => typeof e === "object" &&
65
+ e !== null &&
66
+ typeof e.message === "string")
67
+ .map((e) => e.message);
68
+ if (messages.length > 0)
69
+ return messages.join("; ");
70
+ }
71
+ // { fault: { faultstring } }
72
+ if (obj.fault && typeof obj.fault === "object") {
73
+ const fault = obj.fault;
74
+ if (typeof fault.faultstring === "string")
75
+ return fault.faultstring;
76
+ }
77
+ return undefined;
78
+ }
79
+ function getSuggestion(status, endpoint) {
80
+ switch (status) {
81
+ case 400: {
82
+ let msg = "Bad request. Check that all required parameters are provided and the request body matches the expected schema.";
83
+ if (endpoint) {
84
+ const required = endpoint.parameters.filter((p) => p.required);
85
+ if (required.length > 0) {
86
+ msg += ` Required parameters: ${required.map((p) => `${p.name} (${p.in})`).join(", ")}.`;
87
+ }
88
+ if (endpoint.hasRequestBody && endpoint.requestBodySchema) {
89
+ const requiredFields = Object.entries(endpoint.requestBodySchema.properties)
90
+ .filter(([, p]) => p.required)
91
+ .map(([name]) => name);
92
+ if (requiredFields.length > 0) {
93
+ msg += ` Required body fields: ${requiredFields.join(", ")}.`;
94
+ }
95
+ }
96
+ }
97
+ return msg;
98
+ }
99
+ case 401:
100
+ return "Authentication required. Provide credentials via --header (e.g. --header \"Authorization: Bearer <token>\") or per-request headers parameter.";
101
+ case 403:
102
+ return "Forbidden. Your credentials don't have permission for this operation. Verify your API key or token has the required scopes.";
103
+ case 404: {
104
+ let msg = "Resource not found. Verify the path and parameter values are correct.";
105
+ if (endpoint) {
106
+ const pathParams = endpoint.parameters.filter((p) => p.in === "path");
107
+ if (pathParams.length > 0) {
108
+ msg += ` Check path parameters: ${pathParams.map((p) => p.name).join(", ")}.`;
109
+ }
110
+ }
111
+ msg += " Use list_api to confirm available endpoints.";
112
+ return msg;
113
+ }
114
+ case 405:
115
+ return "Method not allowed for this endpoint. Use list_api to check which HTTP methods are supported.";
116
+ case 409:
117
+ return "Conflict. The resource may already exist or be in a state that prevents this operation.";
118
+ case 415:
119
+ return "Unsupported media type. The API may expect a different Content-Type header.";
120
+ case 422: {
121
+ let msg = "Validation failed. Check that request body fields match the expected types and formats.";
122
+ if (endpoint?.requestBodySchema) {
123
+ const props = Object.entries(endpoint.requestBodySchema.properties)
124
+ .map(([name, p]) => `${name}: ${p.type}${p.required ? " (required)" : ""}`)
125
+ .join(", ");
126
+ if (props)
127
+ msg += ` Expected fields: ${props}.`;
128
+ }
129
+ return msg;
130
+ }
131
+ case 429:
132
+ return "Rate limit exceeded (retries exhausted). Wait before trying again or reduce request frequency.";
133
+ default:
134
+ if (status >= 500) {
135
+ return "Server error. This is likely a temporary issue with the API. Try again later.";
136
+ }
137
+ return "Check the API documentation for this endpoint using explain_api.";
138
+ }
139
+ }
140
+ const MAX_RAW_BODY = 500;
141
+ /**
142
+ * Build a rich error response object from an API error or exhausted retry error.
143
+ */
144
+ export function buildErrorContext(error, method, path, endpoint) {
145
+ const isApiError = error instanceof ApiError;
146
+ const status = isApiError ? error.status : error.status;
147
+ const statusText = isApiError ? error.statusText : "";
148
+ const bodyText = isApiError ? error.bodyText : "";
149
+ const extracted = bodyText ? extractErrorMessage(bodyText) : undefined;
150
+ const result = {
151
+ error: extracted ?? error.message,
152
+ status,
153
+ ...(statusText ? { statusText } : {}),
154
+ endpoint: `${method} ${path}`,
155
+ suggestion: getSuggestion(status, endpoint),
156
+ };
157
+ // Include raw body snippet when structured extraction failed
158
+ if (!extracted && bodyText.length > 0) {
159
+ result.rawBody =
160
+ bodyText.length > MAX_RAW_BODY
161
+ ? bodyText.slice(0, MAX_RAW_BODY) + "..."
162
+ : bodyText;
163
+ }
164
+ // Include spec info for validation-related errors
165
+ if ((status === 400 || status === 422) && endpoint) {
166
+ if (endpoint.parameters.length > 0) {
167
+ result.specParameters = endpoint.parameters.map((p) => ({
168
+ name: p.name,
169
+ in: p.in,
170
+ required: p.required,
171
+ ...(p.description ? { description: p.description } : {}),
172
+ }));
173
+ }
174
+ if (endpoint.hasRequestBody && endpoint.requestBodySchema) {
175
+ result.specRequestBody = endpoint.requestBodySchema;
176
+ }
177
+ }
178
+ return result;
179
+ }
package/build/index.js CHANGED
@@ -8,10 +8,27 @@ import { callApi } from "./api-client.js";
8
8
  import { initLogger } from "./logger.js";
9
9
  import { generateSuggestions } from "./query-suggestions.js";
10
10
  import { getOrBuildSchema, executeQuery, schemaToSDL, truncateIfArray, computeShapeHash, } from "./graphql-schema.js";
11
+ import { ApiError, buildErrorContext } from "./error-context.js";
12
+ import { RetryableError } from "./retry.js";
11
13
  const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
12
14
  const config = await loadConfig();
13
15
  initLogger(config.logPath ?? null);
14
16
  const apiIndex = new ApiIndex(config.specs);
17
+ function formatToolError(error, method, path) {
18
+ if ((error instanceof ApiError || error instanceof RetryableError) && method && path) {
19
+ const endpoint = apiIndex.getEndpoint(method, path);
20
+ const context = buildErrorContext(error, method, path, endpoint);
21
+ return {
22
+ content: [{ type: "text", text: JSON.stringify(context, null, 2) }],
23
+ isError: true,
24
+ };
25
+ }
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ return {
28
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
29
+ isError: true,
30
+ };
31
+ }
15
32
  const server = new McpServer({
16
33
  name: config.name,
17
34
  version: "1.2.1",
@@ -171,13 +188,7 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
171
188
  };
172
189
  }
173
190
  catch (error) {
174
- const message = error instanceof Error ? error.message : String(error);
175
- return {
176
- content: [
177
- { type: "text", text: JSON.stringify({ error: message }) },
178
- ],
179
- isError: true,
180
- };
191
+ return formatToolError(error, method, path);
181
192
  }
182
193
  });
183
194
  // --- Tool 3: query_api ---
@@ -255,13 +266,7 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
255
266
  };
256
267
  }
257
268
  catch (error) {
258
- const message = error instanceof Error ? error.message : String(error);
259
- return {
260
- content: [
261
- { type: "text", text: JSON.stringify({ error: message }) },
262
- ],
263
- isError: true,
264
- };
269
+ return formatToolError(error, method, path);
265
270
  }
266
271
  });
267
272
  // --- Tool 4: explain_api ---
@@ -401,9 +406,12 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
401
406
  if (outcome.status === "fulfilled") {
402
407
  return outcome.value;
403
408
  }
404
- const message = outcome.reason instanceof Error
405
- ? outcome.reason.message
406
- : String(outcome.reason);
409
+ const reason = outcome.reason;
410
+ if (reason instanceof ApiError || reason instanceof RetryableError) {
411
+ const endpoint = apiIndex.getEndpoint(requests[i].method, requests[i].path);
412
+ return buildErrorContext(reason, requests[i].method, requests[i].path, endpoint);
413
+ }
414
+ const message = reason instanceof Error ? reason.message : String(reason);
407
415
  return {
408
416
  method: requests[i].method,
409
417
  path: requests[i].path,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyapi-mcp-server",
3
- "version": "1.5.1",
3
+ "version": "1.6.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",