anyapi-mcp-server 1.6.1 → 1.7.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 CHANGED
@@ -94,7 +94,7 @@ See the [Google Workspace guide](docs/google-workspace.md) for a complete OAuth
94
94
 
95
95
  ## Tools
96
96
 
97
- The server exposes five tools (plus `auth` when OAuth is configured):
97
+ The server exposes four tools (plus `auth` when OAuth is configured):
98
98
 
99
99
  ### `list_api` — Browse endpoints
100
100
 
@@ -102,11 +102,11 @@ Discover what the API offers. Call with no arguments to see all categories, prov
102
102
 
103
103
  ### `call_api` — Inspect an endpoint
104
104
 
105
- Makes a real HTTP request and returns the **inferred GraphQL schema** (SDL) — not the data itself. Use this to discover the response shape and get `suggestedQueries` you can copy into `query_api`.
105
+ Makes a real HTTP request and returns the **inferred GraphQL schema** (SDL) — not the data itself. Use this to discover the response shape and get `suggestedQueries` you can copy into `query_api`. Also returns per-field token costs (`fieldTokenCosts`) and a `dataKey` for cache reuse.
106
106
 
107
107
  ### `query_api` — Fetch data
108
108
 
109
- Fetches data and returns **only the fields you select** via a GraphQL query. Supports both reads and writes (mutations for POST/PUT/DELETE/PATCH).
109
+ Fetches data and returns **only the fields you select** via a GraphQL query. Supports both reads and writes (mutations for POST/PUT/DELETE/PATCH). Pass a `dataKey` from `call_api` to reuse cached data with zero HTTP calls.
110
110
 
111
111
  ```graphql
112
112
  # Read
@@ -116,14 +116,15 @@ Fetches data and returns **only the fields you select** via a GraphQL query. Sup
116
116
  mutation { post_endpoint(input: { name: "example" }) { id } }
117
117
  ```
118
118
 
119
+ Key parameters:
120
+ - **`maxTokens`** — token budget for the response (default 4000). Arrays are truncated to fit.
121
+ - **`dataKey`** — reuse cached data from a previous `call_api` or `query_api` response.
122
+ - **`jsonFilter`** — dot-path to extract nested values after the GraphQL query (e.g. `"data[].attributes.name"`).
123
+
119
124
  ### `explain_api` — Read the docs
120
125
 
121
126
  Returns spec documentation for an endpoint (parameters, request body schema, response codes) **without making an HTTP request**.
122
127
 
123
- ### `batch_query` — Parallel requests
124
-
125
- Fetches data from up to 10 endpoints concurrently in a single tool call. Each request follows the `query_api` flow.
126
-
127
128
  ### `auth` — OAuth authentication
128
129
 
129
130
  Only available when `--oauth-*` flags are configured. Manages the OAuth flow:
@@ -140,11 +141,11 @@ list_api → discover what's available
140
141
 
141
142
  explain_api → read the docs for an endpoint
142
143
 
143
- call_api → inspect the response schema
144
+ call_api → inspect the response schema (returns dataKey)
144
145
 
145
- query_api → fetch exactly the fields you need
146
+ query_api → fetch exactly the fields you need (pass dataKey for zero HTTP calls)
146
147
 
147
- batch_query fetch from multiple endpoints at once
148
+ query_api re-query with different fields using the same dataKey
148
149
  ```
149
150
 
150
151
  ## How it works
@@ -153,21 +154,20 @@ batch_query → fetch from multiple endpoints at once
153
154
  OpenAPI/Postman spec
154
155
 
155
156
 
156
- ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────────────┐
157
- │list_api │ │ explain_api │ │ call_api │ │ query_api │ │ batch_query │
158
- │(browse) │ │ (docs) │ │ (schema) │ │ (data) │ │ (parallel) │
159
- └─────────┘ └─────────────┘ └──────────┘ └───────────┘ └─────────────┘
160
- │ │ no HTTP │ │
161
- ▼ ▼ request ▼ ▼
162
- Spec index Spec index REST API call REST API call N concurrent
163
- (tags, (params, (with retry (cached if REST API calls
164
- paths) responses, + caching) same as + GraphQL
165
- body schema) │ call_api) execution
166
-
167
- Infer GraphQL
168
- schema from Execute GraphQL
169
- JSON response query against
170
- response data
157
+ ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐
158
+ │list_api │ │ explain_api │ │ call_api │ │ query_api │
159
+ │(browse) │ │ (docs) │ │ (schema) │ │ (data) │
160
+ └─────────┘ └─────────────┘ └──────────┘ └───────────┘
161
+ │ │ no HTTP │ │
162
+ ▼ ▼ request ▼ ▼
163
+ Spec index Spec index REST API call dataKey cache
164
+ (tags, (params, (with retry) hit no HTTP
165
+ paths) responses, │ miss fetch
166
+ body schema)
167
+ Infer schema +
168
+ return dataKey Execute GraphQL
169
+ + token budget
170
+ truncation
171
171
  ```
172
172
 
173
173
  ## Features
@@ -179,10 +179,14 @@ OpenAPI/Postman spec
179
179
  - **Multi-sample merging** — samples up to 10 array elements for richer schemas
180
180
  - **Mutation support** — write operations get typed GraphQL mutations from OpenAPI body schemas
181
181
  - **Smart suggestions** — `call_api` returns ready-to-use queries based on the inferred schema
182
- - **Response caching** — 30s TTL prevents duplicate calls across `call_api` `query_api`
182
+ - **Response caching** — filesystem-based cache with 5-min TTL; `dataKey` tokens let `query_api` reuse data with zero HTTP calls
183
+ - **Token budget** — `query_api` accepts `maxTokens` (default 4000) and truncates array results to fit via binary search
184
+ - **Per-field token costs** — `call_api` returns a `fieldTokenCosts` tree so the LLM can make informed field selections
185
+ - **Rate limit tracking** — parses `X-RateLimit-*` headers and warns when limits are nearly exhausted
186
+ - **Pagination detection** — auto-detects cursor, next-page-token, and link-based pagination patterns in responses
187
+ - **JSON filter** — `query_api` accepts a `jsonFilter` dot-path for post-query extraction (e.g. `"data[].name"`)
183
188
  - **Retry with backoff** — automatic retries for 429/5xx with exponential backoff and `Retry-After` support
184
189
  - **Multi-format** — parses JSON, XML, CSV, and plain text responses
185
- - **Pagination** — API-level via `params`, client-side slicing via `limit`/`offset`
186
190
  - **Rich errors** — structured error messages with status-specific suggestions and spec context for self-correction
187
191
  - **OAuth 2.0** — Authorization Code (with PKCE) and Client Credentials flows with automatic token refresh
188
192
  - **Env var interpolation** — `${ENV_VAR}` in base URLs, headers, and spec paths
@@ -1,9 +1,45 @@
1
1
  import { withRetry, RetryableError, isRetryableStatus } from "./retry.js";
2
- import { buildCacheKey, consumeCached, setCache } from "./response-cache.js";
3
2
  import { logEntry, isLoggingEnabled } from "./logger.js";
4
3
  import { parseResponse } from "./response-parser.js";
5
4
  import { ApiError } from "./error-context.js";
6
5
  import { getValidAccessToken, refreshTokens } from "./oauth.js";
6
+ import { waitIfNeeded, trackRateLimit } from "./rate-limit-tracker.js";
7
+ function tryParseInt(val) {
8
+ if (!val)
9
+ return null;
10
+ const n = parseInt(val, 10);
11
+ return isNaN(n) ? null : n;
12
+ }
13
+ export function parseRateLimits(headers) {
14
+ const lower = {};
15
+ for (const [k, v] of Object.entries(headers)) {
16
+ lower[k.toLowerCase()] = v;
17
+ }
18
+ const remaining = tryParseInt(lower["x-ratelimit-remaining"]) ??
19
+ tryParseInt(lower["ratelimit-remaining"]) ??
20
+ tryParseInt(lower["x-rate-limit-remaining"]);
21
+ const limit = tryParseInt(lower["x-ratelimit-limit"]) ??
22
+ tryParseInt(lower["ratelimit-limit"]) ??
23
+ tryParseInt(lower["x-rate-limit-limit"]);
24
+ const resetRaw = lower["x-ratelimit-reset"] ??
25
+ lower["ratelimit-reset"] ??
26
+ lower["x-rate-limit-reset"];
27
+ if (remaining === null && limit === null && !resetRaw)
28
+ return null;
29
+ let resetAt = null;
30
+ if (resetRaw) {
31
+ const asNumber = parseInt(resetRaw, 10);
32
+ if (!isNaN(asNumber)) {
33
+ resetAt = asNumber > 1_000_000_000
34
+ ? new Date(asNumber * 1000).toISOString()
35
+ : `${asNumber}s`;
36
+ }
37
+ else {
38
+ resetAt = resetRaw;
39
+ }
40
+ }
41
+ return { remaining, limit, resetAt };
42
+ }
7
43
  const TIMEOUT_MS = 30_000;
8
44
  function interpolatePath(pathTemplate, params) {
9
45
  const remaining = { ...params };
@@ -17,22 +53,7 @@ function interpolatePath(pathTemplate, params) {
17
53
  });
18
54
  return { url, remainingParams: remaining };
19
55
  }
20
- /**
21
- * @param cacheMode
22
- * - "populate" — skip cache read, always fetch, store result (used by call_api)
23
- * - "consume" — read-and-evict cache, fetch on miss, do NOT re-store (used by query_api)
24
- * - "none" — no caching at all (default)
25
- */
26
- export async function callApi(config, method, pathTemplate, params, body, extraHeaders, cacheMode = "none") {
27
- // --- Cache check (consume mode only) ---
28
- const cacheKey = cacheMode !== "none"
29
- ? buildCacheKey(method, pathTemplate, params, body, extraHeaders)
30
- : "";
31
- if (cacheMode === "consume") {
32
- const cached = consumeCached(cacheKey);
33
- if (cached !== undefined)
34
- return cached;
35
- }
56
+ export async function callApi(config, method, pathTemplate, params, body, extraHeaders) {
36
57
  // --- URL construction ---
37
58
  const { url: interpolatedPath, remainingParams } = interpolatePath(pathTemplate, params ?? {});
38
59
  let fullUrl = `${config.baseUrl}${interpolatedPath}`;
@@ -97,6 +118,8 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
97
118
  durationMs,
98
119
  });
99
120
  }
121
+ // Track rate limit headers from every response
122
+ trackRateLimit(parseRateLimits(responseHeaders));
100
123
  if (!response.ok) {
101
124
  if (isRetryableStatus(response.status)) {
102
125
  const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
@@ -125,6 +148,8 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
125
148
  }
126
149
  });
127
150
  };
151
+ // --- Rate limit pre-check ---
152
+ await waitIfNeeded();
128
153
  // --- Execute with 401 refresh-and-retry ---
129
154
  let result;
130
155
  try {
@@ -148,9 +173,5 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
148
173
  throw error;
149
174
  }
150
175
  }
151
- // --- Cache store (populate mode only) ---
152
- if (cacheMode === "populate") {
153
- setCache(cacheKey, result);
154
- }
155
176
  return result;
156
177
  }
@@ -1,6 +1,82 @@
1
1
  import yaml from "js-yaml";
2
2
  const PAGE_SIZE = 20;
3
3
  const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
4
+ const MAX_BODY_SCHEMA_DEPTH = 6;
5
+ function extractProperties(schema, spec, depth, visited) {
6
+ const properties = schema.properties;
7
+ if (!properties)
8
+ return undefined;
9
+ const requiredFields = new Set(Array.isArray(schema.required) ? schema.required : []);
10
+ const result = {};
11
+ for (const [propName, propDef] of Object.entries(properties)) {
12
+ let def = propDef;
13
+ let refPath;
14
+ // Resolve property-level $ref
15
+ if (def["$ref"] && typeof def["$ref"] === "string") {
16
+ refPath = def["$ref"];
17
+ if (visited.has(refPath)) {
18
+ result[propName] = { type: "object", required: requiredFields.has(propName) };
19
+ continue;
20
+ }
21
+ const resolved = resolveRef(refPath, spec);
22
+ if (resolved)
23
+ def = resolved;
24
+ }
25
+ const prop = {
26
+ type: def.type ?? "string",
27
+ required: requiredFields.has(propName),
28
+ };
29
+ if (def.description)
30
+ prop.description = def.description;
31
+ // Recurse into nested objects
32
+ if (prop.type === "object" && def.properties && depth < MAX_BODY_SCHEMA_DEPTH) {
33
+ const branch = new Set(visited);
34
+ if (refPath)
35
+ branch.add(refPath);
36
+ const nested = extractProperties(def, spec, depth + 1, branch);
37
+ if (nested) {
38
+ prop.properties = nested;
39
+ if (Array.isArray(def.required) && def.required.length > 0) {
40
+ prop.required_fields = def.required;
41
+ }
42
+ }
43
+ }
44
+ if (def.items && typeof def.items === "object") {
45
+ let itemsDef = def.items;
46
+ let itemsRefPath;
47
+ if (itemsDef["$ref"] && typeof itemsDef["$ref"] === "string") {
48
+ itemsRefPath = itemsDef["$ref"];
49
+ if (!visited.has(itemsRefPath)) {
50
+ const resolved = resolveRef(itemsRefPath, spec);
51
+ if (resolved)
52
+ itemsDef = resolved;
53
+ }
54
+ }
55
+ const itemType = itemsDef.type ?? "string";
56
+ if (itemType === "object" && itemsDef.properties && depth < MAX_BODY_SCHEMA_DEPTH) {
57
+ const branch = new Set(visited);
58
+ if (itemsRefPath)
59
+ branch.add(itemsRefPath);
60
+ const nestedItems = extractProperties(itemsDef, spec, depth + 1, branch);
61
+ if (nestedItems) {
62
+ prop.items = {
63
+ type: itemType,
64
+ properties: nestedItems,
65
+ required: Array.isArray(itemsDef.required) ? itemsDef.required : undefined,
66
+ };
67
+ }
68
+ else {
69
+ prop.items = { type: itemType };
70
+ }
71
+ }
72
+ else {
73
+ prop.items = { type: itemType };
74
+ }
75
+ }
76
+ result[propName] = prop;
77
+ }
78
+ return Object.keys(result).length > 0 ? result : undefined;
79
+ }
4
80
  function extractRequestBodySchema(requestBody, spec) {
5
81
  if (!requestBody || typeof requestBody !== "object")
6
82
  return undefined;
@@ -26,34 +102,10 @@ function extractRequestBodySchema(requestBody, spec) {
26
102
  return undefined;
27
103
  schema = resolved;
28
104
  }
29
- const properties = schema.properties;
30
- if (!properties)
105
+ const result = extractProperties(schema, spec, 0, new Set());
106
+ if (!result)
31
107
  return undefined;
32
- const requiredFields = new Set(Array.isArray(schema.required) ? schema.required : []);
33
- const result = {};
34
- for (const [propName, propDef] of Object.entries(properties)) {
35
- let def = propDef;
36
- // Resolve property-level $ref
37
- if (def["$ref"] && typeof def["$ref"] === "string") {
38
- const resolved = resolveRef(def["$ref"], spec);
39
- if (resolved)
40
- def = resolved;
41
- }
42
- const prop = {
43
- type: def.type ?? "string",
44
- required: requiredFields.has(propName),
45
- };
46
- if (def.description)
47
- prop.description = def.description;
48
- if (def.items && typeof def.items === "object") {
49
- const items = def.items;
50
- prop.items = { type: items.type ?? "string" };
51
- }
52
- result[propName] = prop;
53
- }
54
- return Object.keys(result).length > 0
55
- ? { contentType: "application/json", properties: result }
56
- : undefined;
108
+ return { contentType: "application/json", properties: result };
57
109
  }
58
110
  function resolveRef(ref, spec) {
59
111
  // Handle "#/components/schemas/Foo" style refs
@@ -0,0 +1,96 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { platform, env } from "node:process";
5
+ const TTL_MS = 5 * 60 * 1000; // 5 minutes
6
+ function defaultResponseDir() {
7
+ if (platform === "win32") {
8
+ const base = env.LOCALAPPDATA ?? join(env.USERPROFILE ?? "", "AppData", "Local");
9
+ return join(base, "anyapi-mcp", "responses");
10
+ }
11
+ return join(env.HOME ?? "/tmp", ".cache", "anyapi-mcp", "responses");
12
+ }
13
+ let responseDir = defaultResponseDir();
14
+ export function _setResponseDirForTests(dir) {
15
+ responseDir = dir;
16
+ }
17
+ export function _clearAllForTests() {
18
+ try {
19
+ for (const file of readdirSync(responseDir)) {
20
+ if (file.endsWith(".json")) {
21
+ try {
22
+ unlinkSync(join(responseDir, file));
23
+ }
24
+ catch { /* ignore */ }
25
+ }
26
+ }
27
+ }
28
+ catch { /* dir may not exist */ }
29
+ }
30
+ function ensureDir() {
31
+ mkdirSync(responseDir, { recursive: true });
32
+ }
33
+ export function cleanupExpired() {
34
+ try {
35
+ const now = Date.now();
36
+ for (const file of readdirSync(responseDir)) {
37
+ if (!file.endsWith(".json"))
38
+ continue;
39
+ try {
40
+ const content = readFileSync(join(responseDir, file), "utf-8");
41
+ const entry = JSON.parse(content);
42
+ if (entry.expiresAt < now) {
43
+ unlinkSync(join(responseDir, file));
44
+ }
45
+ }
46
+ catch {
47
+ /* ignore individual file errors */
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ /* dir may not exist yet */
53
+ }
54
+ }
55
+ export function storeResponse(method, path, data, responseHeaders) {
56
+ const dataKey = randomBytes(4).toString("hex");
57
+ const entry = {
58
+ method,
59
+ path,
60
+ data,
61
+ responseHeaders,
62
+ expiresAt: Date.now() + TTL_MS,
63
+ };
64
+ try {
65
+ ensureDir();
66
+ writeFileSync(join(responseDir, `${dataKey}.json`), JSON.stringify(entry));
67
+ cleanupExpired();
68
+ }
69
+ catch (err) {
70
+ process.stderr.write(`data-cache: failed to store ${dataKey}: ${err}\n`);
71
+ }
72
+ return dataKey;
73
+ }
74
+ export function loadResponse(dataKey) {
75
+ try {
76
+ const filePath = join(responseDir, `${dataKey}.json`);
77
+ const content = readFileSync(filePath, "utf-8");
78
+ const entry = JSON.parse(content);
79
+ if (entry.expiresAt < Date.now()) {
80
+ try {
81
+ unlinkSync(filePath);
82
+ }
83
+ catch { /* ignore */ }
84
+ return null;
85
+ }
86
+ return {
87
+ method: entry.method,
88
+ path: entry.path,
89
+ data: entry.data,
90
+ responseHeaders: entry.responseHeaders,
91
+ };
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }