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 +35 -27
- package/build/api-client.js +42 -21
- package/build/api-index.js +79 -27
- package/build/body-file.js +42 -0
- package/build/body-validation.js +102 -0
- package/build/data-cache.js +96 -0
- package/build/graphql-schema.js +342 -40
- package/build/index.js +252 -131
- package/build/json-filter.js +62 -0
- package/build/pagination.js +123 -0
- package/build/pre-write-backup.js +20 -0
- package/build/query-suggestions.js +34 -9
- package/build/rate-limit-tracker.js +59 -0
- package/build/token-budget.js +135 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 │
|
|
158
|
-
│(browse) │ │ (docs) │ │ (schema) │ │ (data) │
|
|
159
|
-
└─────────┘ └─────────────┘ └──────────┘ └───────────┘
|
|
160
|
-
│ │ no HTTP │ │
|
|
161
|
-
▼ ▼ request ▼ ▼
|
|
162
|
-
Spec index Spec index REST API call
|
|
163
|
-
(tags, (params, (with retry
|
|
164
|
-
paths) responses,
|
|
165
|
-
body schema) │
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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** —
|
|
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
|
-
- **
|
|
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
|
package/build/api-client.js
CHANGED
|
@@ -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
|
}
|
package/build/api-index.js
CHANGED
|
@@ -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
|
|
30
|
-
if (!
|
|
105
|
+
const result = extractProperties(schema, spec, 0, new Set());
|
|
106
|
+
if (!result)
|
|
31
107
|
return undefined;
|
|
32
|
-
|
|
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
|
+
}
|