anyapi-mcp-server 1.6.1 → 1.8.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dot-path patterns to search for pagination info in response data.
3
+ * Each entry: [dotPath, target field in PaginationHint, label for hint text, candidate query param names].
4
+ */
5
+ const CURSOR_PATTERNS = [
6
+ [["meta", "page", "after"], "cursor", "meta.page.after", ["page[cursor]", "page[after]", "cursor", "after"]],
7
+ [["meta", "page", "cursor"], "cursor", "meta.page.cursor", ["page[cursor]", "cursor"]],
8
+ [["paging", "cursors", "after"], "cursor", "paging.cursors.after", ["after", "cursor"]],
9
+ [["pagination", "next_cursor"], "cursor", "pagination.next_cursor", ["cursor", "next_cursor"]],
10
+ [["next_cursor"], "cursor", "next_cursor", ["cursor", "next_cursor"]],
11
+ [["cursor"], "cursor", "cursor", ["cursor"]],
12
+ [["nextPageToken"], "nextPageToken", "nextPageToken", ["pageToken", "page_token"]],
13
+ [["next_page_token"], "nextPageToken", "next_page_token", ["page_token", "pageToken"]],
14
+ [["links", "next"], "nextUrl", "links.next", []],
15
+ [["_links", "next", "href"], "nextUrl", "_links.next.href", []],
16
+ [["has_more"], "hasMore", "has_more", []],
17
+ [["hasMore"], "hasMore", "hasMore", []],
18
+ ];
19
+ function walkPath(data, path) {
20
+ let current = data;
21
+ for (const key of path) {
22
+ if (typeof current !== "object" || current === null || Array.isArray(current))
23
+ return undefined;
24
+ current = current[key];
25
+ if (current === undefined)
26
+ return undefined;
27
+ }
28
+ return current;
29
+ }
30
+ /**
31
+ * Known pagination-related query parameter names (used to flag params in call_api).
32
+ */
33
+ export const PAGINATION_PARAM_NAMES = new Set([
34
+ "page", "cursor", "after", "before", "limit", "offset", "per_page",
35
+ "page_size", "pageSize", "page_token", "pageToken", "next_page_token",
36
+ "page[cursor]", "page[after]", "page[before]", "page[size]", "page[number]",
37
+ "page[offset]", "page[limit]", "starting_after", "ending_before",
38
+ "start_cursor", "next_cursor",
39
+ ]);
40
+ /**
41
+ * Pick the best query parameter name from candidates by cross-referencing
42
+ * against the endpoint's actual spec parameters. Falls back to the first candidate.
43
+ */
44
+ export function resolveParamName(candidates, specParams) {
45
+ if (candidates.length === 0)
46
+ return null;
47
+ if (specParams && specParams.length > 0) {
48
+ const queryParams = new Set(specParams.filter((p) => p.in === "query").map((p) => p.name));
49
+ for (const c of candidates) {
50
+ if (queryParams.has(c))
51
+ return c;
52
+ }
53
+ }
54
+ return candidates[0];
55
+ }
56
+ /**
57
+ * Parse query parameters from a next-page URL.
58
+ */
59
+ function parseNextUrlParams(nextUrl) {
60
+ try {
61
+ const url = new URL(nextUrl);
62
+ const params = {};
63
+ url.searchParams.forEach((value, key) => {
64
+ params[key] = value;
65
+ });
66
+ return params;
67
+ }
68
+ catch {
69
+ return {};
70
+ }
71
+ }
72
+ /**
73
+ * Scan raw response data for common pagination patterns.
74
+ * Returns a PaginationHint with matched values and usage guidance, or null if not paginated.
75
+ * When specParams are provided, resolves cursor param names against the endpoint's actual parameters.
76
+ */
77
+ export function detectPagination(data, specParams) {
78
+ if (typeof data !== "object" || data === null || Array.isArray(data))
79
+ return null;
80
+ const found = { _hint: "" };
81
+ const hints = [];
82
+ const nextParams = {};
83
+ for (const [path, field, label, candidates] of CURSOR_PATTERNS) {
84
+ if (found[field] !== undefined)
85
+ continue; // first match wins per field
86
+ const value = walkPath(data, path);
87
+ if (value === undefined || value === null)
88
+ continue;
89
+ if (field === "hasMore") {
90
+ if (typeof value === "boolean") {
91
+ found.hasMore = value;
92
+ hints.push(`'${label}' = ${value}`);
93
+ }
94
+ }
95
+ else if (typeof value === "string" && value.length > 0) {
96
+ found[field] = value;
97
+ if (field === "cursor" || field === "nextPageToken") {
98
+ const paramName = resolveParamName(candidates, specParams);
99
+ if (paramName) {
100
+ nextParams[paramName] = value;
101
+ }
102
+ hints.push(`Use '${label}' value as cursor parameter for the next page`);
103
+ }
104
+ else if (field === "nextUrl") {
105
+ const urlParams = parseNextUrlParams(value);
106
+ Object.assign(nextParams, urlParams);
107
+ hints.push(`'${label}' contains the full URL for the next page`);
108
+ }
109
+ }
110
+ }
111
+ if (hints.length === 0)
112
+ return null;
113
+ if (Object.keys(nextParams).length > 0) {
114
+ found.nextParams = nextParams;
115
+ found._hint =
116
+ `Pagination detected. To fetch the next page, call query_api with the same method, path, and query, ` +
117
+ `but set params to include: ${JSON.stringify(nextParams)}`;
118
+ }
119
+ else {
120
+ found._hint = `Pagination detected: ${hints.join("; ")}.`;
121
+ }
122
+ return found;
123
+ }
@@ -0,0 +1,20 @@
1
+ import { callApi } from "./api-client.js";
2
+ import { storeResponse } from "./data-cache.js";
3
+ /**
4
+ * Create a pre-write backup by fetching the current state of a resource via GET.
5
+ * Returns a dataKey for the cached snapshot, or undefined on failure.
6
+ * Failure is non-fatal — errors are logged to stderr.
7
+ */
8
+ export async function createBackup(config, method, path, params, headers) {
9
+ if (method !== "PATCH" && method !== "PUT")
10
+ return undefined;
11
+ try {
12
+ const { data, responseHeaders } = await callApi(config, "GET", path, params, undefined, headers);
13
+ return storeResponse("GET", path, data, responseHeaders);
14
+ }
15
+ catch (err) {
16
+ const msg = err instanceof Error ? err.message : String(err);
17
+ process.stderr.write(`pre-write-backup: GET ${path} failed: ${msg}\n`);
18
+ return undefined;
19
+ }
20
+ }
@@ -6,11 +6,19 @@ function unwrapType(type) {
6
6
  return type.ofType;
7
7
  return type;
8
8
  }
9
+ function isJsonScalar(type) {
10
+ const unwrapped = unwrapType(type);
11
+ return isScalarType(unwrapped) && unwrapped.name === "JSON";
12
+ }
13
+ function getJsonFieldNames(type) {
14
+ const fields = type.getFields();
15
+ return Object.keys(fields).filter((name) => isJsonScalar(fields[name].type));
16
+ }
9
17
  function getScalarFieldNames(type) {
10
18
  const fields = type.getFields();
11
19
  return Object.keys(fields).filter((name) => {
12
20
  const fieldType = unwrapType(fields[name].type);
13
- return isScalarType(fieldType);
21
+ return isScalarType(fieldType) && fieldType.name !== "JSON";
14
22
  });
15
23
  }
16
24
  function buildDepthLimitedQuery(type, maxDepth, depth = 0) {
@@ -37,13 +45,19 @@ function buildDepthLimitedQuery(type, maxDepth, depth = 0) {
37
45
  else if (isListType(fieldType)) {
38
46
  const elementType = unwrapType(fieldType.ofType);
39
47
  if (isScalarType(elementType)) {
40
- parts.push(name);
48
+ // JSON scalar lists: include without limit args
49
+ if (elementType.name === "JSON") {
50
+ parts.push(name);
51
+ }
52
+ else {
53
+ parts.push(`${name}(limit: 10)`);
54
+ }
41
55
  count++;
42
56
  }
43
57
  else if (isObjectType(elementType) && depth < maxDepth) {
44
58
  const nested = buildDepthLimitedQuery(elementType, maxDepth, depth + 1);
45
59
  if (nested) {
46
- parts.push(`${name} ${nested}`);
60
+ parts.push(`${name}(limit: 10) ${nested}`);
47
61
  count++;
48
62
  }
49
63
  }
@@ -58,10 +72,10 @@ export function generateSuggestions(schema) {
58
72
  return suggestions;
59
73
  const fields = queryType.getFields();
60
74
  const fieldNames = Object.keys(fields);
61
- // Suggestion 1: All scalar fields at root
75
+ // Suggestion 1: All scalar fields at root (excluding JSON scalars)
62
76
  const scalarFields = fieldNames.filter((name) => {
63
77
  const type = unwrapType(fields[name].type);
64
- return isScalarType(type);
78
+ return isScalarType(type) && type.name !== "JSON";
65
79
  });
66
80
  if (scalarFields.length > 0) {
67
81
  const selected = scalarFields.slice(0, MAX_FIELDS_PER_LEVEL);
@@ -71,6 +85,17 @@ export function generateSuggestions(schema) {
71
85
  description: `Returns ${selected.join(", ")}`,
72
86
  });
73
87
  }
88
+ // Suggestion: Dynamic JSON fields
89
+ const jsonFields = getJsonFieldNames(queryType);
90
+ if (jsonFields.length > 0) {
91
+ const selected = jsonFields.slice(0, MAX_FIELDS_PER_LEVEL);
92
+ suggestions.push({
93
+ name: "Dynamic JSON fields",
94
+ query: `{ ${selected.join(" ")} }`,
95
+ description: `${selected.join(", ")} contain dynamic JSON — returned as-is. ` +
96
+ "Use jsonFilter param to extract nested values.",
97
+ });
98
+ }
74
99
  // Suggestion 2: For each list field, suggest with basic subfields
75
100
  for (const [name, field] of Object.entries(fields)) {
76
101
  const type = unwrapType(field.type);
@@ -81,8 +106,8 @@ export function generateSuggestions(schema) {
81
106
  if (subfields.length > 0) {
82
107
  suggestions.push({
83
108
  name: `List ${name} with basic fields`,
84
- query: `{ ${name} { ${subfields.join(" ")} } }`,
85
- description: `Fetches ${subfields.join(", ")} for each item in ${name}`,
109
+ query: `{ ${name}(limit: 10) { ${subfields.join(" ")} } }`,
110
+ description: `Fetches ${subfields.join(", ")} for each item in ${name} (paginated — use limit/offset args)`,
86
111
  });
87
112
  }
88
113
  }
@@ -98,8 +123,8 @@ export function generateSuggestions(schema) {
98
123
  if (subfields.length > 0) {
99
124
  suggestions.push({
100
125
  name: "Items with count",
101
- query: `{ items { ${subfields.join(" ")} } _count }`,
102
- description: `Array response: fetches ${subfields.join(", ")} with total count`,
126
+ query: `{ items(limit: 10) { ${subfields.join(" ")} } _count }`,
127
+ description: `Array response: fetches ${subfields.join(", ")} with total count (paginated — use limit/offset args)`,
103
128
  });
104
129
  }
105
130
  }
@@ -0,0 +1,59 @@
1
+ const MAX_WAIT_MS = 30_000;
2
+ let lastInfo = null;
3
+ /**
4
+ * Store the latest rate limit info from response headers.
5
+ */
6
+ export function trackRateLimit(info) {
7
+ if (info)
8
+ lastInfo = info;
9
+ }
10
+ /**
11
+ * Parse a reset time value into an epoch millisecond timestamp.
12
+ * Supports ISO 8601 strings and "Ns" (seconds-from-now) format.
13
+ */
14
+ function parseResetEpoch(resetAt) {
15
+ // "Ns" format (e.g. "30s")
16
+ const secMatch = resetAt.match(/^(\d+)s$/);
17
+ if (secMatch) {
18
+ return Date.now() + parseInt(secMatch[1], 10) * 1000;
19
+ }
20
+ // ISO 8601 timestamp
21
+ const ts = Date.parse(resetAt);
22
+ if (!isNaN(ts))
23
+ return ts;
24
+ return null;
25
+ }
26
+ /**
27
+ * If rate limit is nearly exhausted (remaining <= 1), delay until the reset time.
28
+ * Capped at MAX_WAIT_MS to avoid indefinite blocking.
29
+ * Clears tracked state after waiting.
30
+ */
31
+ export async function waitIfNeeded() {
32
+ if (!lastInfo)
33
+ return;
34
+ if (lastInfo.remaining === null || lastInfo.remaining > 1)
35
+ return;
36
+ if (!lastInfo.resetAt) {
37
+ lastInfo = null;
38
+ return;
39
+ }
40
+ const resetEpoch = parseResetEpoch(lastInfo.resetAt);
41
+ if (!resetEpoch) {
42
+ lastInfo = null;
43
+ return;
44
+ }
45
+ const delayMs = resetEpoch - Date.now();
46
+ if (delayMs <= 0) {
47
+ lastInfo = null;
48
+ return;
49
+ }
50
+ const waitMs = Math.min(delayMs, MAX_WAIT_MS);
51
+ lastInfo = null;
52
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
53
+ }
54
+ /**
55
+ * Reset tracker state (for testing).
56
+ */
57
+ export function resetTracker() {
58
+ lastInfo = null;
59
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Token budget system for controlling response size.
3
+ * Truncates array results to fit within a token budget,
4
+ * leveraging GraphQL field selection for size control.
5
+ */
6
+ const DEFAULT_BUDGET = 4000;
7
+ /**
8
+ * Find the primary array in a query result.
9
+ * Checks `items` first (raw array responses), then falls back to
10
+ * the first non-`_`-prefixed array field.
11
+ */
12
+ export function findPrimaryArray(obj) {
13
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj))
14
+ return null;
15
+ const rec = obj;
16
+ // Check `items` first (raw array responses wrapped by GraphQL schema)
17
+ if (Array.isArray(rec.items))
18
+ return rec.items;
19
+ // Fall back to first non-_-prefixed array field
20
+ for (const [key, value] of Object.entries(rec)) {
21
+ if (!key.startsWith("_") && Array.isArray(value)) {
22
+ return value;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Returns the length of the primary array, or null if no array found.
29
+ */
30
+ export function findPrimaryArrayLength(obj) {
31
+ const arr = findPrimaryArray(obj);
32
+ return arr ? arr.length : null;
33
+ }
34
+ /**
35
+ * Estimate token count from a JSON value.
36
+ * Uses JSON.stringify length / 4 as approximation (1 token ~ 4 chars).
37
+ */
38
+ function estimateTokens(value) {
39
+ try {
40
+ return Math.max(1, Math.ceil(JSON.stringify(value).length / 4));
41
+ }
42
+ catch {
43
+ return 1;
44
+ }
45
+ }
46
+ /**
47
+ * Truncate the primary array in an object to fit within a token budget.
48
+ * Uses binary search to find the max number of items that fit.
49
+ * Returns the truncated object and the original/truncated counts.
50
+ */
51
+ export function truncateToTokenBudget(obj, budget) {
52
+ const arr = findPrimaryArray(obj);
53
+ if (!arr || arr.length === 0) {
54
+ return { result: obj, originalCount: 0, keptCount: 0 };
55
+ }
56
+ const originalCount = arr.length;
57
+ // Compute overhead tokens (non-array fields)
58
+ const overhead = {};
59
+ const arrayKey = findPrimaryArrayKey(obj);
60
+ if (!arrayKey)
61
+ return { result: obj, originalCount, keptCount: originalCount };
62
+ for (const [key, value] of Object.entries(obj)) {
63
+ if (key !== arrayKey) {
64
+ overhead[key] = value;
65
+ }
66
+ }
67
+ const overheadTokens = estimateTokens(overhead);
68
+ const arrayBudget = Math.max(1, budget - overheadTokens);
69
+ // Check if full array fits
70
+ const fullTokens = estimateTokens(arr);
71
+ if (fullTokens <= arrayBudget) {
72
+ return { result: obj, originalCount, keptCount: originalCount };
73
+ }
74
+ // Binary search for max items that fit
75
+ let lo = 1;
76
+ let hi = originalCount;
77
+ let bestCount = 1; // Always keep at least 1
78
+ while (lo <= hi) {
79
+ const mid = Math.floor((lo + hi) / 2);
80
+ const sliceTokens = estimateTokens(arr.slice(0, mid));
81
+ if (sliceTokens <= arrayBudget) {
82
+ bestCount = mid;
83
+ lo = mid + 1;
84
+ }
85
+ else {
86
+ hi = mid - 1;
87
+ }
88
+ }
89
+ const truncated = { ...obj, [arrayKey]: arr.slice(0, bestCount) };
90
+ return { result: truncated, originalCount, keptCount: bestCount };
91
+ }
92
+ /**
93
+ * Find the key name of the primary array in an object.
94
+ */
95
+ function findPrimaryArrayKey(obj) {
96
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj))
97
+ return null;
98
+ const rec = obj;
99
+ if (Array.isArray(rec.items))
100
+ return "items";
101
+ for (const [key, value] of Object.entries(rec)) {
102
+ if (!key.startsWith("_") && Array.isArray(value)) {
103
+ return key;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * Build a status message for the response.
110
+ * If estimated tokens exceed the budget, truncates and returns TRUNCATED status.
111
+ * Otherwise returns COMPLETE status.
112
+ */
113
+ export function buildStatusMessage(queryResult, budget = DEFAULT_BUDGET) {
114
+ if (typeof queryResult !== "object" || queryResult === null || Array.isArray(queryResult)) {
115
+ return { status: "COMPLETE", result: queryResult };
116
+ }
117
+ const qr = queryResult;
118
+ const totalTokens = estimateTokens(qr);
119
+ if (totalTokens <= budget) {
120
+ const arrayLen = findPrimaryArrayLength(qr);
121
+ const status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
122
+ return { status, result: qr };
123
+ }
124
+ // Need truncation
125
+ const { result, originalCount, keptCount } = truncateToTokenBudget(qr, budget);
126
+ if (originalCount === 0) {
127
+ // No array found but response is over budget
128
+ return {
129
+ status: `COMPLETE (response exceeds token budget ${budget} — select fewer fields)`,
130
+ result: qr,
131
+ };
132
+ }
133
+ const status = `TRUNCATED — ${keptCount} of ${originalCount} items (token budget ${budget}). Select fewer fields to fit more items.`;
134
+ return { status, result };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyapi-mcp-server",
3
- "version": "1.6.1",
3
+ "version": "1.8.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",