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.
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. For PUT/PATCH requests, automatically creates a pre-write backup (returns `backupDataKey`). Supports `bodyFile` for large payloads and blocks requests with detected placeholder values.
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,17 @@ 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
+ - **`bodyFile`** — absolute path to a JSON file to use as request body (mutually exclusive with `body`). Use for large payloads that can't be sent inline.
124
+ - **`skipBackup`** — skip the automatic pre-write backup for PUT/PATCH requests (default: `false`).
125
+
119
126
  ### `explain_api` — Read the docs
120
127
 
121
128
  Returns spec documentation for an endpoint (parameters, request body schema, response codes) **without making an HTTP request**.
122
129
 
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
130
  ### `auth` — OAuth authentication
128
131
 
129
132
  Only available when `--oauth-*` flags are configured. Manages the OAuth flow:
@@ -140,11 +143,11 @@ list_api → discover what's available
140
143
 
141
144
  explain_api → read the docs for an endpoint
142
145
 
143
- call_api → inspect the response schema
146
+ call_api → inspect the response schema (returns dataKey)
144
147
 
145
- query_api → fetch exactly the fields you need
148
+ query_api → fetch exactly the fields you need (pass dataKey for zero HTTP calls)
146
149
 
147
- batch_query fetch from multiple endpoints at once
150
+ query_api re-query with different fields using the same dataKey
148
151
  ```
149
152
 
150
153
  ## How it works
@@ -153,21 +156,20 @@ batch_query → fetch from multiple endpoints at once
153
156
  OpenAPI/Postman spec
154
157
 
155
158
 
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
159
+ ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐
160
+ │list_api │ │ explain_api │ │ call_api │ │ query_api │
161
+ │(browse) │ │ (docs) │ │ (schema) │ │ (data) │
162
+ └─────────┘ └─────────────┘ └──────────┘ └───────────┘
163
+ │ │ no HTTP │ │
164
+ ▼ ▼ request ▼ ▼
165
+ Spec index Spec index REST API call dataKey cache
166
+ (tags, (params, (with retry) hit no HTTP
167
+ paths) responses, │ miss fetch
168
+ body schema)
169
+ Infer schema +
170
+ return dataKey Execute GraphQL
171
+ + token budget
172
+ truncation
171
173
  ```
172
174
 
173
175
  ## Features
@@ -179,10 +181,16 @@ OpenAPI/Postman spec
179
181
  - **Multi-sample merging** — samples up to 10 array elements for richer schemas
180
182
  - **Mutation support** — write operations get typed GraphQL mutations from OpenAPI body schemas
181
183
  - **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`
184
+ - **Response caching** — filesystem-based cache with 5-min TTL; `dataKey` tokens let `query_api` reuse data with zero HTTP calls
185
+ - **Token budget** — `query_api` accepts `maxTokens` (default 4000) and truncates array results to fit via binary search
186
+ - **Per-field token costs** — `call_api` returns a `fieldTokenCosts` tree so the LLM can make informed field selections
187
+ - **Rate limit tracking** — parses `X-RateLimit-*` headers and warns when limits are nearly exhausted
188
+ - **Pagination detection** — auto-detects cursor, next-page-token, and link-based pagination patterns in responses
189
+ - **JSON filter** — `query_api` accepts a `jsonFilter` dot-path for post-query extraction (e.g. `"data[].name"`)
183
190
  - **Retry with backoff** — automatic retries for 429/5xx with exponential backoff and `Retry-After` support
184
191
  - **Multi-format** — parses JSON, XML, CSV, and plain text responses
185
- - **Pagination** — API-level via `params`, client-side slicing via `limit`/`offset`
192
+ - **Safe writes** — PUT/PATCH requests automatically snapshot the resource before writing (`backupDataKey`); placeholder values (e.g. `PLACEHOLDER`, `TODO`, `file://`) are detected and blocked before sending
193
+ - **File-based body** — `bodyFile` parameter accepts an absolute path to a JSON file, enabling large payloads that can't be sent inline
186
194
  - **Rich errors** — structured error messages with status-specific suggestions and spec context for self-correction
187
195
  - **OAuth 2.0** — Authorization Code (with PKCE) and Client Credentials flows with automatic token refresh
188
196
  - **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,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, isAbsolute } from "node:path";
3
+ import { platform } from "node:process";
4
+ /**
5
+ * Resolve request body from either an inline `body` object or a `bodyFile` path.
6
+ * Throws if both are provided, if the path is relative, or if the file is unreadable/invalid JSON.
7
+ */
8
+ export function resolveBody(body, bodyFile) {
9
+ if (body && bodyFile) {
10
+ throw new Error("Cannot specify both 'body' and 'bodyFile'. Use one or the other.");
11
+ }
12
+ if (!bodyFile)
13
+ return body;
14
+ // Validate absolute path
15
+ const isAbsolutePath = isAbsolute(bodyFile) ||
16
+ (platform === "win32" && /^[A-Za-z]:[\\/]/.test(bodyFile));
17
+ if (!isAbsolutePath) {
18
+ throw new Error(`bodyFile must be an absolute path, got: ${bodyFile}`);
19
+ }
20
+ const fullPath = resolve(bodyFile);
21
+ let content;
22
+ try {
23
+ content = readFileSync(fullPath, "utf-8");
24
+ }
25
+ catch (err) {
26
+ const msg = err instanceof Error ? err.message : String(err);
27
+ throw new Error(`Failed to read bodyFile '${fullPath}': ${msg}`);
28
+ }
29
+ try {
30
+ const parsed = JSON.parse(content);
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
32
+ throw new Error("bodyFile must contain a JSON object (not an array or primitive)");
33
+ }
34
+ return parsed;
35
+ }
36
+ catch (err) {
37
+ if (err instanceof SyntaxError) {
38
+ throw new Error(`bodyFile '${fullPath}' contains invalid JSON: ${err.message}`);
39
+ }
40
+ throw err;
41
+ }
42
+ }
@@ -0,0 +1,102 @@
1
+ const CONTENT_FIELD_NAMES = new Set([
2
+ "html_content",
3
+ "html",
4
+ "content",
5
+ "body",
6
+ "template",
7
+ "description",
8
+ "text",
9
+ "message",
10
+ "markup",
11
+ "source",
12
+ "html_body",
13
+ "plain_content",
14
+ "rich_content",
15
+ ]);
16
+ const EXACT_KEYWORDS = new Set(["placeholder", "todo", "tbd", "fixme", "xxx"]);
17
+ const PATTERN_REGEXES = [
18
+ /^file:\/\//,
19
+ /^<[^>]+>$/,
20
+ /^\[[^\]]+\]$/,
21
+ /^content of /i,
22
+ /^see (above|below|file)/i,
23
+ ];
24
+ function isContentField(name) {
25
+ return CONTENT_FIELD_NAMES.has(name) || name.includes("html");
26
+ }
27
+ function checkKeywordPatterns(value) {
28
+ const lower = value.trim().toLowerCase();
29
+ if (EXACT_KEYWORDS.has(lower)) {
30
+ return `Suspicious keyword: "${value.trim()}"`;
31
+ }
32
+ for (const re of PATTERN_REGEXES) {
33
+ if (re.test(value.trim())) {
34
+ return `Suspicious pattern: "${value.trim()}"`;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ function hasHtmlSchemaHint(fieldName, schema) {
40
+ if (!schema?.properties)
41
+ return false;
42
+ const prop = schema.properties[fieldName];
43
+ if (!prop?.description)
44
+ return false;
45
+ const desc = prop.description.toLowerCase();
46
+ return /html|content|template|body|markup/.test(desc);
47
+ }
48
+ /**
49
+ * Detect placeholder values in a request body that likely indicate
50
+ * the LLM failed to emit real content.
51
+ */
52
+ export function detectPlaceholders(body, schema) {
53
+ if (!body || typeof body !== "object")
54
+ return [];
55
+ const warnings = [];
56
+ const keys = Object.keys(body);
57
+ // Pass 1: keyword patterns on string fields (shallow + 1 level nested)
58
+ for (const [key, value] of Object.entries(body)) {
59
+ if (typeof value === "string") {
60
+ const reason = checkKeywordPatterns(value);
61
+ if (reason && (isContentField(key) || keys.length <= 2)) {
62
+ warnings.push({ field: key, value, reason });
63
+ }
64
+ }
65
+ else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
66
+ // One level nested
67
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
68
+ if (typeof nestedValue === "string") {
69
+ const reason = checkKeywordPatterns(nestedValue);
70
+ if (reason && (isContentField(nestedKey) || keys.length <= 2)) {
71
+ warnings.push({
72
+ field: `${key}.${nestedKey}`,
73
+ value: nestedValue,
74
+ reason,
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ // Pass 2: short value for known content fields
82
+ for (const [key, value] of Object.entries(body)) {
83
+ if (typeof value !== "string")
84
+ continue;
85
+ if (value.length >= 50)
86
+ continue;
87
+ const fieldIsContent = isContentField(key);
88
+ const schemaHasHtml = hasHtmlSchemaHint(key, schema);
89
+ if ((fieldIsContent || schemaHasHtml) && (schemaHasHtml || key.includes("html"))) {
90
+ // Don't duplicate warnings already found in pass 1
91
+ const alreadyWarned = warnings.some((w) => w.field === key);
92
+ if (!alreadyWarned) {
93
+ warnings.push({
94
+ field: key,
95
+ value,
96
+ reason: `Suspiciously short value (${value.length} chars) for a content/HTML field`,
97
+ });
98
+ }
99
+ }
100
+ }
101
+ return warnings;
102
+ }
@@ -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
+ }