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 +3 -1
- package/build/api-client.js +7 -6
- package/build/error-context.js +179 -0
- package/build/index.js +25 -17
- package/package.json +1 -1
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
|
[](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
|
|
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
|
|
package/build/api-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
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",
|