anyapi-mcp-server 1.8.1 → 2.0.1

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.
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ import { formatToolError, attachRateLimit } from "./shared.js";
3
+ import { callApi } from "../api-client.js";
4
+ import { getOrBuildSchema, schemaToSDL, executeQuery, } from "../graphql-schema.js";
5
+ import { generateSuggestions } from "../query-suggestions.js";
6
+ import { buildStatusMessage, estimateResultTokens, findPrimaryArrayLength } from "../token-budget.js";
7
+ import { isNonJsonResult } from "../response-parser.js";
8
+ import { detectPagination } from "../pagination.js";
9
+ import { storeResponse, loadResponse } from "../data-cache.js";
10
+ import { applyJsonFilter } from "../json-filter.js";
11
+ export function registerQueryApi({ server, config, apiIndex }) {
12
+ server.tool("query_api", `Fetch data from a ${config.name} API endpoint (GET only), returning only the fields you select via GraphQL. ` +
13
+ "TIP: Pass the dataKey from inspect_api to reuse the cached response — zero HTTP calls. " +
14
+ "If you know the field names, call query_api directly — on first hit the schema SDL " +
15
+ "will be included in the response. If unsure, use inspect_api first for schema discovery.\n" +
16
+ "Use the exact root field names from the schema — do NOT assume generic names.\n" +
17
+ "- Raw array response ([...]): '{ items { id name } _count }'\n" +
18
+ "- Object response ({products: [...]}): '{ products { id name } }' (use actual field names from schema)\n" +
19
+ "Field names with dashes are converted to underscores (e.g. created-at → created_at). " +
20
+ "PAGINATION: To paginate the API itself, pass limit/offset inside 'params' (they become query string parameters). " +
21
+ "TOKEN BUDGET (three modes):\n" +
22
+ "1. No maxTokens/unlimited: responses over ~10k tokens return an error — use maxTokens or unlimited to proceed.\n" +
23
+ "2. maxTokens: truncates the deepest largest array to fit the budget.\n" +
24
+ "3. unlimited: true: returns the full response with no truncation.\n" +
25
+ "Check _status in the response: 'COMPLETE' means all data returned, 'TRUNCATED' means array was cut to fit budget. " +
26
+ "Every response includes a _dataKey for subsequent re-queries with different field selections. " +
27
+ "For write operations (POST/PUT/PATCH/DELETE), use mutate_api instead.", {
28
+ path: z
29
+ .string()
30
+ .describe("API path template (e.g. '/api/card/{id}')"),
31
+ params: z
32
+ .record(z.unknown())
33
+ .optional()
34
+ .describe("Path and query parameters. Path params like {id} are interpolated; " +
35
+ "remaining become query string. " +
36
+ "For API pagination, pass limit/offset here (e.g. { limit: 20, offset: 40 })."),
37
+ query: z
38
+ .string()
39
+ .describe("GraphQL selection query using field names from inspect_api schema " +
40
+ "(e.g. '{ products { id name } }' — NOT '{ items { ... } }' unless the API returns a raw array)"),
41
+ dataKey: z
42
+ .string()
43
+ .optional()
44
+ .describe("dataKey from a previous inspect_api or query_api response. " +
45
+ "If valid, reuses cached data — zero HTTP calls. Falls back to HTTP on miss/expiry."),
46
+ headers: z
47
+ .record(z.string())
48
+ .optional()
49
+ .describe("Additional HTTP headers for this request. Overrides default --header values."),
50
+ jsonFilter: z
51
+ .string()
52
+ .optional()
53
+ .describe("Dot-path to extract from the result after GraphQL query executes. " +
54
+ "Use \".\" for nested access, \"[]\" to traverse arrays. " +
55
+ "Example: \"data[].attributes.message\" extracts the message field from each element of the data array."),
56
+ maxTokens: z
57
+ .number()
58
+ .min(100)
59
+ .optional()
60
+ .describe("Token budget for the response. If exceeded, the deepest largest array is truncated to fit. " +
61
+ "Select fewer fields to fit more items. Without this or unlimited, responses over ~10k tokens are rejected."),
62
+ unlimited: z
63
+ .boolean()
64
+ .optional()
65
+ .describe("Set to true to return the full response with no token budget enforcement. " +
66
+ "Use when you know exactly what fields you need and want all data."),
67
+ }, async ({ path, params, query, dataKey, headers, jsonFilter, maxTokens, unlimited }) => {
68
+ try {
69
+ let rawData;
70
+ let respHeaders;
71
+ // Try dataKey cache first
72
+ const cached = dataKey ? loadResponse(dataKey) : null;
73
+ let cachedDataAge;
74
+ if (cached) {
75
+ rawData = cached.data;
76
+ respHeaders = cached.responseHeaders;
77
+ cachedDataAge = Math.round((Date.now() - cached.storedAt) / 1000);
78
+ }
79
+ else {
80
+ const result = await callApi(config, "GET", path, params, undefined, headers);
81
+ rawData = result.data;
82
+ respHeaders = result.responseHeaders;
83
+ }
84
+ // Store response for future re-queries
85
+ const newDataKey = storeResponse("GET", path, rawData, respHeaders);
86
+ // Non-JSON response — skip GraphQL layer
87
+ if (isNonJsonResult(rawData)) {
88
+ return {
89
+ content: [
90
+ { type: "text", text: JSON.stringify({
91
+ rawResponse: rawData,
92
+ responseHeaders: respHeaders,
93
+ ...(newDataKey ? { _dataKey: newDataKey } : {}),
94
+ hint: "This endpoint returned a non-JSON response. GraphQL querying is not available. " +
95
+ "The raw parsed content is shown above.",
96
+ }, null, 2) },
97
+ ],
98
+ };
99
+ }
100
+ const endpoint = apiIndex.getEndpoint("GET", path);
101
+ const { schema, fromCache } = getOrBuildSchema(rawData, "GET", path, endpoint?.requestBodySchema);
102
+ let queryResult = await executeQuery(schema, rawData, query, unlimited ? { unlimited: true } : undefined);
103
+ if (jsonFilter) {
104
+ queryResult = applyJsonFilter(queryResult, jsonFilter);
105
+ }
106
+ // Token budget: three-mode dispatch
107
+ let status;
108
+ let budgetResult;
109
+ if (unlimited) {
110
+ const arrayLen = typeof queryResult === "object" && queryResult !== null && !Array.isArray(queryResult)
111
+ ? findPrimaryArrayLength(queryResult)
112
+ : null;
113
+ status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
114
+ budgetResult = queryResult;
115
+ }
116
+ else if (maxTokens !== undefined) {
117
+ ({ status, result: budgetResult } = buildStatusMessage(queryResult, maxTokens));
118
+ }
119
+ else {
120
+ // No budget, no unlimited → enforce 10k safety limit
121
+ const estimatedTokens = estimateResultTokens(queryResult);
122
+ if (estimatedTokens > 10000) {
123
+ return {
124
+ content: [{
125
+ type: "text",
126
+ text: JSON.stringify({
127
+ error: `Response too large (~${estimatedTokens} tokens). To proceed, either:\n` +
128
+ `1. Use inspect_api to inspect the schema and select fewer fields\n` +
129
+ `2. Set maxTokens (e.g. maxTokens: 4000) to truncate automatically\n` +
130
+ `3. Set unlimited: true if you need the full response`,
131
+ estimatedTokens,
132
+ }, null, 2),
133
+ }],
134
+ isError: true,
135
+ };
136
+ }
137
+ const arrayLen = typeof queryResult === "object" && queryResult !== null && !Array.isArray(queryResult)
138
+ ? findPrimaryArrayLength(queryResult)
139
+ : null;
140
+ status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
141
+ budgetResult = queryResult;
142
+ }
143
+ if (typeof budgetResult === "object" && budgetResult !== null && !Array.isArray(budgetResult)) {
144
+ const qr = budgetResult;
145
+ attachRateLimit(qr, respHeaders);
146
+ if (!fromCache) {
147
+ qr._schema = schemaToSDL(schema);
148
+ const suggestions = generateSuggestions(schema);
149
+ if (suggestions.length > 0) {
150
+ qr._suggestedQueries = suggestions;
151
+ }
152
+ }
153
+ const pagination = detectPagination(rawData, endpoint?.parameters);
154
+ if (pagination) {
155
+ qr._pagination = pagination;
156
+ }
157
+ const output = { _status: status, ...qr };
158
+ if (newDataKey)
159
+ output._dataKey = newDataKey;
160
+ if (cachedDataAge !== undefined)
161
+ output._dataAge = `${cachedDataAge}s ago (from cache)`;
162
+ return {
163
+ content: [
164
+ { type: "text", text: JSON.stringify(output, null, 2) },
165
+ ],
166
+ };
167
+ }
168
+ const output = { _status: status, data: budgetResult };
169
+ if (newDataKey)
170
+ output._dataKey = newDataKey;
171
+ if (cachedDataAge !== undefined)
172
+ output._dataAge = `${cachedDataAge}s ago (from cache)`;
173
+ return {
174
+ content: [
175
+ { type: "text", text: JSON.stringify(output, null, 2) },
176
+ ],
177
+ };
178
+ }
179
+ catch (error) {
180
+ return formatToolError(error, apiIndex, "GET", path);
181
+ }
182
+ });
183
+ }
@@ -0,0 +1,65 @@
1
+ import { ApiError, buildErrorContext } from "../error-context.js";
2
+ import { RetryableError } from "../retry.js";
3
+ import { parseRateLimits } from "../api-client.js";
4
+ export const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
5
+ export function formatToolError(error, apiIndex, method, path) {
6
+ if ((error instanceof ApiError || error instanceof RetryableError) && method && path) {
7
+ const endpoint = apiIndex.getEndpoint(method, path);
8
+ const context = buildErrorContext(error, method, path, endpoint);
9
+ return {
10
+ content: [{ type: "text", text: JSON.stringify(context, null, 2) }],
11
+ isError: true,
12
+ };
13
+ }
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ return {
16
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
17
+ isError: true,
18
+ };
19
+ }
20
+ export function attachRateLimit(result, respHeaders) {
21
+ const rl = parseRateLimits(respHeaders);
22
+ if (!rl)
23
+ return;
24
+ result._rateLimit = rl;
25
+ if (rl.remaining !== null &&
26
+ (rl.remaining <= 5 || (rl.limit !== null && rl.remaining / rl.limit <= 0.1))) {
27
+ result._rateLimitWarning =
28
+ `Rate limit nearly exhausted (${rl.remaining}${rl.limit !== null ? `/${rl.limit}` : ""} remaining` +
29
+ `${rl.resetAt ? `, resets ${rl.resetAt}` : ""}). Consider reducing request frequency.`;
30
+ }
31
+ }
32
+ export function shrinkageError(warnings, keyInfo) {
33
+ const keyLabel = keyInfo.backupDataKey ? "backupDataKey" : "dataKey";
34
+ const keyValue = keyInfo.backupDataKey ?? keyInfo.dataKey;
35
+ return {
36
+ content: [{
37
+ type: "text",
38
+ text: JSON.stringify({
39
+ error: "Array shrinkage detected: request body has significantly fewer array items than current state",
40
+ warnings,
41
+ ...(keyValue ? { [keyLabel]: keyValue } : {}),
42
+ hint: "This often happens when truncated query results are sent back as the full payload. " +
43
+ "Consider using mutate_api with 'patch' mode to apply targeted changes without needing the full data. " +
44
+ "Or use query_api with unlimited: true and the " + keyLabel + " to retrieve the full current data. " +
45
+ "If this is intentional, use skipBackup: true to bypass this check.",
46
+ }, null, 2),
47
+ }],
48
+ isError: true,
49
+ };
50
+ }
51
+ export function placeholderError(warnings) {
52
+ return {
53
+ content: [{
54
+ type: "text",
55
+ text: JSON.stringify({
56
+ error: "Potential placeholder values detected in request body",
57
+ warnings,
58
+ hint: "The request was blocked to prevent sending placeholder data. " +
59
+ "If the body is too large to send inline, use the 'bodyFile' parameter " +
60
+ "with an absolute path to a JSON file containing the real content.",
61
+ }, null, 2),
62
+ }],
63
+ isError: true,
64
+ };
65
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Compare array field sizes in a PUT/PATCH body against the backup (current state).
3
+ * Returns warnings when the body has significantly fewer items than the backup,
4
+ * which usually means truncated query results leaked into the write payload.
5
+ *
6
+ * @param body - The request body being sent
7
+ * @param backupData - The full data from the pre-write backup
8
+ * @param options.threshold - Ratio below which to warn (default 0.5 = body < 50% of backup)
9
+ */
10
+ export function detectArrayShrinkage(body, backupData, options) {
11
+ if (typeof backupData !== "object" || backupData === null || Array.isArray(backupData)) {
12
+ return [];
13
+ }
14
+ const threshold = options?.threshold ?? 0.5;
15
+ const minBackupLength = 5;
16
+ const warnings = [];
17
+ const backup = backupData;
18
+ // Level 1: direct fields
19
+ for (const [key, bodyVal] of Object.entries(body)) {
20
+ const backupVal = backup[key];
21
+ if (Array.isArray(bodyVal) && Array.isArray(backupVal)) {
22
+ if (backupVal.length >= minBackupLength &&
23
+ bodyVal.length / backupVal.length < threshold) {
24
+ warnings.push({
25
+ field: key,
26
+ bodyLength: bodyVal.length,
27
+ backupLength: backupVal.length,
28
+ reason: `Request body has ${bodyVal.length} items in '${key}' but the current resource has ${backupVal.length}. ` +
29
+ `This PUT/PATCH will replace the entire array — ${backupVal.length - bodyVal.length} items will be lost.`,
30
+ });
31
+ }
32
+ }
33
+ // Level 2: one level of nesting
34
+ if (typeof bodyVal === "object" && bodyVal !== null && !Array.isArray(bodyVal) &&
35
+ typeof backupVal === "object" && backupVal !== null && !Array.isArray(backupVal)) {
36
+ const bodyObj = bodyVal;
37
+ const backupObj = backupVal;
38
+ for (const [nestedKey, nestedBodyVal] of Object.entries(bodyObj)) {
39
+ const nestedBackupVal = backupObj[nestedKey];
40
+ if (Array.isArray(nestedBodyVal) && Array.isArray(nestedBackupVal)) {
41
+ if (nestedBackupVal.length >= minBackupLength &&
42
+ nestedBodyVal.length / nestedBackupVal.length < threshold) {
43
+ warnings.push({
44
+ field: `${key}.${nestedKey}`,
45
+ bodyLength: nestedBodyVal.length,
46
+ backupLength: nestedBackupVal.length,
47
+ reason: `Request body has ${nestedBodyVal.length} items in '${key}.${nestedKey}' but the current resource has ${nestedBackupVal.length}. ` +
48
+ `This PUT/PATCH will replace the entire array — ${nestedBackupVal.length - nestedBodyVal.length} items will be lost.`,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return warnings;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyapi-mcp-server",
3
- "version": "1.8.1",
3
+ "version": "2.0.1",
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",
@@ -1,49 +0,0 @@
1
- const DEFAULT_TTL_MS = 30_000;
2
- const cache = new Map();
3
- export function buildCacheKey(method, pathTemplate, params, body, extraHeaders) {
4
- const parts = [method, pathTemplate];
5
- if (params && Object.keys(params).length > 0) {
6
- parts.push(JSON.stringify(params, Object.keys(params).sort()));
7
- }
8
- if (body && Object.keys(body).length > 0) {
9
- parts.push(JSON.stringify(body, Object.keys(body).sort()));
10
- }
11
- if (extraHeaders && Object.keys(extraHeaders).length > 0) {
12
- parts.push(JSON.stringify(extraHeaders, Object.keys(extraHeaders).sort()));
13
- }
14
- return parts.join("|");
15
- }
16
- export function getCached(key) {
17
- const entry = cache.get(key);
18
- if (!entry)
19
- return undefined;
20
- if (Date.now() > entry.expiresAt) {
21
- cache.delete(key);
22
- return undefined;
23
- }
24
- return entry.data;
25
- }
26
- /** Read and immediately evict a cache entry (one-shot consumption). */
27
- export function consumeCached(key) {
28
- const entry = cache.get(key);
29
- if (!entry)
30
- return undefined;
31
- cache.delete(key);
32
- if (Date.now() > entry.expiresAt)
33
- return undefined;
34
- return entry.data;
35
- }
36
- export function setCache(key, data, ttlMs = DEFAULT_TTL_MS) {
37
- cache.set(key, { data, expiresAt: Date.now() + ttlMs });
38
- }
39
- export function evictExpired() {
40
- const now = Date.now();
41
- for (const [key, entry] of cache) {
42
- if (now > entry.expiresAt) {
43
- cache.delete(key);
44
- }
45
- }
46
- }
47
- export function clearCache() {
48
- cache.clear();
49
- }