anyapi-mcp-server 1.2.0 → 1.3.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 +43 -18
- package/build/graphql-schema.js +105 -24
- package/build/index.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,9 @@ Instead of building a custom MCP server for every API, `anyapi-mcp-server` reads
|
|
|
16
16
|
- **Retry with backoff** — automatic retries with exponential backoff and jitter for 429/5xx errors, honoring `Retry-After` headers
|
|
17
17
|
- **Multi-format responses** — parses JSON, XML, CSV, and plain text responses automatically
|
|
18
18
|
- **Built-in pagination** — API-level pagination via `params`; client-side slicing with top-level `limit`/`offset`
|
|
19
|
-
- **
|
|
19
|
+
- **Spec documentation lookup** — `explain_api` returns rich endpoint docs (parameters, response codes, deprecation, request body schema) without making HTTP requests
|
|
20
|
+
- **Concurrent batch queries** — `batch_query` fetches data from up to 10 endpoints in parallel, returning all results in one tool call
|
|
21
|
+
- **Per-request headers** — override default headers on individual `call_api`/`query_api`/`batch_query` calls
|
|
20
22
|
- **Environment variable interpolation** — use `${ENV_VAR}` in base URLs and headers
|
|
21
23
|
- **Request logging** — optional NDJSON request/response log with sensitive header masking
|
|
22
24
|
|
|
@@ -126,7 +128,7 @@ Add to your MCP configuration (e.g. `~/.cursor/mcp.json` or Claude Desktop confi
|
|
|
126
128
|
|
|
127
129
|
## Tools
|
|
128
130
|
|
|
129
|
-
The server exposes
|
|
131
|
+
The server exposes five MCP tools:
|
|
130
132
|
|
|
131
133
|
### `list_api`
|
|
132
134
|
|
|
@@ -206,11 +208,33 @@ Fetch data from an API endpoint, returning only the fields you select via GraphQ
|
|
|
206
208
|
- For API-level pagination, pass limit/offset inside `params` instead
|
|
207
209
|
- Accepts optional `headers` to override defaults for this request
|
|
208
210
|
|
|
211
|
+
### `explain_api`
|
|
212
|
+
|
|
213
|
+
Get detailed documentation for an endpoint directly from the spec — no HTTP request is made.
|
|
214
|
+
|
|
215
|
+
- Returns summary, description, operationId, deprecation status, tag
|
|
216
|
+
- Lists all parameters with name, location (`path`/`query`/`header`), required flag, and description
|
|
217
|
+
- Shows request body schema with property types, required fields, and descriptions
|
|
218
|
+
- Lists response status codes with descriptions (e.g. `200 OK`, `404 Not Found`)
|
|
219
|
+
- Includes external docs link when available
|
|
220
|
+
|
|
221
|
+
### `batch_query`
|
|
222
|
+
|
|
223
|
+
Fetch data from multiple endpoints concurrently in a single tool call.
|
|
224
|
+
|
|
225
|
+
- Accepts an array of 1–10 requests, each with `method`, `path`, `params`, `body`, `query`, and optional `headers`
|
|
226
|
+
- All requests execute in parallel via `Promise.allSettled` — one failure does not affect the others
|
|
227
|
+
- Each request follows the `query_api` flow: HTTP fetch → schema inference → GraphQL field selection
|
|
228
|
+
- Returns an array of results: `{ method, path, data }` on success or `{ method, path, error }` on failure
|
|
229
|
+
- Run `call_api` first on each endpoint to discover the schema field names
|
|
230
|
+
|
|
209
231
|
## Workflow
|
|
210
232
|
|
|
211
233
|
1. **Discover** endpoints with `list_api`
|
|
212
|
-
2. **
|
|
213
|
-
3. **
|
|
234
|
+
2. **Understand** an endpoint with `explain_api` to see its parameters, request body, and response codes
|
|
235
|
+
3. **Inspect** a specific endpoint with `call_api` to see the inferred response schema and suggested queries
|
|
236
|
+
4. **Query** the endpoint with `query_api` to fetch exactly the fields you need
|
|
237
|
+
5. **Batch** multiple queries with `batch_query` when you need data from several endpoints at once
|
|
214
238
|
|
|
215
239
|
## How It Works
|
|
216
240
|
|
|
@@ -218,20 +242,21 @@ Fetch data from an API endpoint, returning only the fields you select via GraphQ
|
|
|
218
242
|
OpenAPI/Postman spec
|
|
219
243
|
│
|
|
220
244
|
▼
|
|
221
|
-
┌─────────┐
|
|
222
|
-
│
|
|
223
|
-
│
|
|
224
|
-
└─────────┘
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
245
|
+
┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────────────┐
|
|
246
|
+
│list_api │ │ explain_api │ │ call_api │ │ query_api │ │ batch_query │
|
|
247
|
+
│(browse) │ │ (docs) │ │ (schema) │ │ (data) │ │ (parallel) │
|
|
248
|
+
└─────────┘ └─────────────┘ └──────────┘ └───────────┘ └─────────────┘
|
|
249
|
+
│ │ no HTTP │ │ │
|
|
250
|
+
▼ ▼ request ▼ ▼ ▼
|
|
251
|
+
Spec index Spec index REST API call REST API call N concurrent
|
|
252
|
+
(tags, (params, (with retry (cached if REST API calls
|
|
253
|
+
paths) responses, + caching) same as + GraphQL
|
|
254
|
+
body schema) │ call_api) execution
|
|
255
|
+
▼ │
|
|
256
|
+
Infer GraphQL ▼
|
|
257
|
+
schema from Execute GraphQL
|
|
258
|
+
JSON response query against
|
|
259
|
+
response data
|
|
235
260
|
```
|
|
236
261
|
|
|
237
262
|
1. The spec file is parsed at startup into an endpoint index with tags, paths, parameters, and request body schemas
|
package/build/graphql-schema.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { GraphQLSchema, GraphQLObjectType, GraphQLInputObjectType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, graphql as executeGraphQL, printSchema, } from "graphql";
|
|
1
|
+
import { GraphQLSchema, GraphQLObjectType, GraphQLInputObjectType, GraphQLScalarType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, graphql as executeGraphQL, printSchema, } from "graphql";
|
|
2
2
|
const DEFAULT_ARRAY_LIMIT = 50;
|
|
3
|
-
const MAX_SAMPLE_SIZE =
|
|
3
|
+
const MAX_SAMPLE_SIZE = 30;
|
|
4
|
+
const MAX_INFER_DEPTH = 4;
|
|
5
|
+
/**
|
|
6
|
+
* Custom scalar that passes arbitrary JSON values through as-is.
|
|
7
|
+
* Used for mixed-type arrays, type-conflicting fields, and deeply nested structures.
|
|
8
|
+
*/
|
|
9
|
+
const GraphQLJSON = new GraphQLScalarType({
|
|
10
|
+
name: "JSON",
|
|
11
|
+
description: "Arbitrary JSON value (mixed types, deep nesting, or heterogeneous structures)",
|
|
12
|
+
serialize: (value) => value,
|
|
13
|
+
parseValue: (value) => value,
|
|
14
|
+
});
|
|
4
15
|
// Schema cache keyed by "METHOD:/path/template"
|
|
5
16
|
const schemaCache = new Map();
|
|
6
17
|
/**
|
|
@@ -49,6 +60,30 @@ function deriveTypeName(method, pathTemplate) {
|
|
|
49
60
|
name = name.slice(1);
|
|
50
61
|
return name || "Unknown";
|
|
51
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Return the base type category of a value for mixed-type detection.
|
|
65
|
+
*/
|
|
66
|
+
function baseTypeOf(value) {
|
|
67
|
+
if (value === null || value === undefined)
|
|
68
|
+
return "null";
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return "array";
|
|
71
|
+
return typeof value; // "string" | "number" | "boolean" | "object"
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Check if an array has mixed base types (e.g. string + number, or scalar + object).
|
|
75
|
+
* Returns true if the array should be treated as JSON scalar.
|
|
76
|
+
*/
|
|
77
|
+
function hasMixedTypes(arr) {
|
|
78
|
+
const types = new Set();
|
|
79
|
+
const sampleSize = Math.min(arr.length, MAX_SAMPLE_SIZE);
|
|
80
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
81
|
+
const t = baseTypeOf(arr[i]);
|
|
82
|
+
if (t !== "null")
|
|
83
|
+
types.add(t);
|
|
84
|
+
}
|
|
85
|
+
return types.size > 1;
|
|
86
|
+
}
|
|
52
87
|
function inferScalarType(value) {
|
|
53
88
|
switch (typeof value) {
|
|
54
89
|
case "string":
|
|
@@ -65,32 +100,51 @@ function inferScalarType(value) {
|
|
|
65
100
|
* Merge multiple sample objects into a single "super-object" that contains
|
|
66
101
|
* every key seen across all samples. First non-null value wins for each key.
|
|
67
102
|
* Nested objects are merged recursively.
|
|
103
|
+
* Tracks fields where different samples have conflicting base types.
|
|
68
104
|
*/
|
|
69
105
|
function mergeSamples(items) {
|
|
70
106
|
const merged = {};
|
|
107
|
+
const conflicts = new Set();
|
|
108
|
+
const seenTypes = new Map(); // key → first base type
|
|
71
109
|
for (const item of items) {
|
|
72
110
|
for (const [key, value] of Object.entries(item)) {
|
|
111
|
+
if (value === null || value === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
const valueType = baseTypeOf(value);
|
|
73
114
|
if (!(key in merged) || merged[key] === null || merged[key] === undefined) {
|
|
74
115
|
merged[key] = value;
|
|
116
|
+
if (!seenTypes.has(key))
|
|
117
|
+
seenTypes.set(key, valueType);
|
|
75
118
|
}
|
|
76
|
-
else
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
else {
|
|
120
|
+
const prevType = seenTypes.get(key);
|
|
121
|
+
if (prevType && prevType !== valueType) {
|
|
122
|
+
conflicts.add(key);
|
|
123
|
+
}
|
|
124
|
+
if (!conflicts.has(key) &&
|
|
125
|
+
typeof value === "object" && value !== null && !Array.isArray(value) &&
|
|
126
|
+
typeof merged[key] === "object" && merged[key] !== null && !Array.isArray(merged[key])) {
|
|
127
|
+
const sub = mergeSamples([
|
|
128
|
+
merged[key],
|
|
129
|
+
value,
|
|
130
|
+
]);
|
|
131
|
+
merged[key] = sub.merged;
|
|
132
|
+
for (const c of sub.conflicts)
|
|
133
|
+
conflicts.add(`${key}.${c}`);
|
|
134
|
+
}
|
|
135
|
+
else if (Array.isArray(value) && Array.isArray(merged[key])) {
|
|
136
|
+
if (merged[key].length === 0 && value.length > 0) {
|
|
137
|
+
merged[key] = value;
|
|
138
|
+
}
|
|
86
139
|
}
|
|
87
140
|
}
|
|
88
141
|
}
|
|
89
142
|
}
|
|
90
|
-
return merged;
|
|
143
|
+
return { merged, conflicts };
|
|
91
144
|
}
|
|
92
145
|
/**
|
|
93
146
|
* Sample and merge multiple array elements for richer type inference.
|
|
147
|
+
* Returns the merged object + conflict set, or null if no objects found.
|
|
94
148
|
*/
|
|
95
149
|
function mergeArraySamples(arr) {
|
|
96
150
|
const sampleSize = Math.min(arr.length, MAX_SAMPLE_SIZE);
|
|
@@ -105,22 +159,35 @@ function mergeArraySamples(arr) {
|
|
|
105
159
|
* Recursively infer a GraphQL type from a JSON value.
|
|
106
160
|
* For objects, creates a named GraphQLObjectType with explicit resolvers
|
|
107
161
|
* that map sanitized field names back to original JSON keys.
|
|
162
|
+
*
|
|
163
|
+
* Falls back to GraphQLJSON for:
|
|
164
|
+
* - Arrays with mixed element types (string + number + object)
|
|
165
|
+
* - Fields with conflicting types across samples
|
|
166
|
+
* - Values nested deeper than MAX_INFER_DEPTH
|
|
108
167
|
*/
|
|
109
|
-
function inferType(value, typeName, typeRegistry) {
|
|
168
|
+
function inferType(value, typeName, typeRegistry, conflicts, depth = 0) {
|
|
110
169
|
if (value === null || value === undefined) {
|
|
111
170
|
return GraphQLString;
|
|
112
171
|
}
|
|
172
|
+
// Beyond max depth, treat as opaque JSON
|
|
173
|
+
if (depth >= MAX_INFER_DEPTH) {
|
|
174
|
+
return GraphQLJSON;
|
|
175
|
+
}
|
|
113
176
|
if (Array.isArray(value)) {
|
|
114
177
|
if (value.length === 0) {
|
|
115
178
|
return new GraphQLList(GraphQLString);
|
|
116
179
|
}
|
|
180
|
+
// Mixed-type arrays → JSON scalar (e.g. ["field", 4296, { "temporal-unit": "day" }])
|
|
181
|
+
if (hasMixedTypes(value)) {
|
|
182
|
+
return GraphQLJSON;
|
|
183
|
+
}
|
|
117
184
|
// Sample multiple elements for richer type inference
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
const elementType = inferType(merged, `${typeName}_Item`, typeRegistry);
|
|
185
|
+
const mergeResult = mergeArraySamples(value);
|
|
186
|
+
if (mergeResult) {
|
|
187
|
+
const elementType = inferType(mergeResult.merged, `${typeName}_Item`, typeRegistry, mergeResult.conflicts, depth + 1);
|
|
121
188
|
return new GraphQLList(elementType);
|
|
122
189
|
}
|
|
123
|
-
const elementType = inferType(value[0], `${typeName}_Item`, typeRegistry);
|
|
190
|
+
const elementType = inferType(value[0], `${typeName}_Item`, typeRegistry, conflicts, depth + 1);
|
|
124
191
|
return new GraphQLList(elementType);
|
|
125
192
|
}
|
|
126
193
|
if (typeof value === "object") {
|
|
@@ -154,9 +221,17 @@ function inferType(value, typeName, typeRegistry) {
|
|
|
154
221
|
sanitized = `${sanitized}_${counter}`;
|
|
155
222
|
}
|
|
156
223
|
usedNames.add(sanitized);
|
|
157
|
-
const childTypeName = `${typeName}_${sanitized}`;
|
|
158
|
-
const fieldType = inferType(fieldValue, childTypeName, typeRegistry);
|
|
159
224
|
const key = originalKey;
|
|
225
|
+
// Use JSON scalar for fields with type conflicts across samples
|
|
226
|
+
if (conflicts?.has(originalKey)) {
|
|
227
|
+
fieldConfigs[sanitized] = {
|
|
228
|
+
type: GraphQLJSON,
|
|
229
|
+
resolve: (source) => source[key],
|
|
230
|
+
};
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const childTypeName = `${typeName}_${sanitized}`;
|
|
234
|
+
const fieldType = inferType(fieldValue, childTypeName, typeRegistry, conflicts, depth + 1);
|
|
160
235
|
fieldConfigs[sanitized] = {
|
|
161
236
|
type: fieldType,
|
|
162
237
|
resolve: (source) => source[key],
|
|
@@ -205,12 +280,18 @@ export function buildSchemaFromData(data, method, pathTemplate, requestBodySchem
|
|
|
205
280
|
if (Array.isArray(data)) {
|
|
206
281
|
let itemType = GraphQLString;
|
|
207
282
|
if (data.length > 0) {
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
itemType =
|
|
283
|
+
// Mixed-type top-level array → items are JSON scalars
|
|
284
|
+
if (hasMixedTypes(data)) {
|
|
285
|
+
itemType = GraphQLJSON;
|
|
211
286
|
}
|
|
212
287
|
else {
|
|
213
|
-
|
|
288
|
+
const mergeResult = mergeArraySamples(data);
|
|
289
|
+
if (mergeResult) {
|
|
290
|
+
itemType = inferType(mergeResult.merged, `${baseName}_Item`, typeRegistry, mergeResult.conflicts, 0);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
itemType = inferScalarType(data[0]);
|
|
294
|
+
}
|
|
214
295
|
}
|
|
215
296
|
}
|
|
216
297
|
queryType = new GraphQLObjectType({
|
package/build/index.js
CHANGED
|
@@ -13,7 +13,7 @@ initLogger(config.logPath ?? null);
|
|
|
13
13
|
const apiIndex = new ApiIndex(config.spec);
|
|
14
14
|
const server = new McpServer({
|
|
15
15
|
name: config.name,
|
|
16
|
-
version: "1.2.
|
|
16
|
+
version: "1.2.1",
|
|
17
17
|
});
|
|
18
18
|
// --- Tool 1: list_api ---
|
|
19
19
|
server.tool("list_api", `List available ${config.name} API endpoints. ` +
|
|
@@ -62,6 +62,14 @@ server.tool("list_api", `List available ${config.name} API endpoints. ` +
|
|
|
62
62
|
else {
|
|
63
63
|
data = apiIndex.listAllCategories();
|
|
64
64
|
}
|
|
65
|
+
// Empty results — return directly to avoid GraphQL schema errors on empty arrays
|
|
66
|
+
if (data.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{ type: "text", text: JSON.stringify({ items: [], _count: 0 }, null, 2) },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
65
73
|
const defaultQuery = isEndpointMode
|
|
66
74
|
? "{ items { method path summary tag parameters { name in required description } } _count }"
|
|
67
75
|
: "{ items { tag endpointCount } _count }";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyapi-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|