anyapi-mcp-server 1.2.1 → 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.
@@ -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 = 10;
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 if (typeof value === "object" && value !== null && !Array.isArray(value) &&
77
- typeof merged[key] === "object" && merged[key] !== null && !Array.isArray(merged[key])) {
78
- merged[key] = mergeSamples([
79
- merged[key],
80
- value,
81
- ]);
82
- }
83
- else if (Array.isArray(value) && Array.isArray(merged[key])) {
84
- if (merged[key].length === 0 && value.length > 0) {
85
- merged[key] = value;
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 merged = mergeArraySamples(value);
119
- if (merged) {
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
- const merged = mergeArraySamples(data);
209
- if (merged) {
210
- itemType = inferType(merged, `${baseName}_Item`, typeRegistry);
283
+ // Mixed-type top-level array → items are JSON scalars
284
+ if (hasMixedTypes(data)) {
285
+ itemType = GraphQLJSON;
211
286
  }
212
287
  else {
213
- itemType = inferScalarType(data[0]);
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
@@ -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.2.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",