anyapi-mcp-server 1.6.0 → 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
@@ -8,276 +8,196 @@ Traditional MCP servers hand-pick a handful of endpoints and call it a day — l
8
8
 
9
9
  `anyapi-mcp-server` is a universal [MCP](https://modelcontextprotocol.io) server that connects **any REST API** to AI assistants like Claude, Cursor, and other LLM-powered tools — just point it at an OpenAPI spec or Postman collection. Every endpoint the API provides becomes available instantly, with **GraphQL-style field selection** and automatic schema inference. No custom server code, no artificial limits.
10
10
 
11
- Works with services like **Datadog**, **PostHog**, **Metabase**, **Cloudflare**, **Stripe**, **GitHub**, **Slack**, **Twilio**, **Shopify**, **HubSpot**, and anything else with a REST API — if it has an API, it just works.
12
-
13
- ## Features
14
-
15
- - **Works with any REST API** — provide an OpenAPI (JSON/YAML) or Postman Collection v2.x spec as a local file or HTTPS URL
16
- - **Remote spec caching** — HTTPS spec URLs are fetched once and cached locally in ``~/.cache/anyapi-mcp/` (Linux/macOS) or `%LOCALAPPDATA%\anyapi-mcp\` (Windows)`
17
- - **GraphQL-style queries** — select only the fields you need from API responses
18
- - **Automatic schema inference** — calls an endpoint once, infers the response schema, then lets you query specific fields
19
- - **Multi-sample merging** — samples up to 10 array elements to build richer schemas that capture fields missing from individual items
20
- - **Mutation support** — POST/PUT/DELETE/PATCH endpoints with OpenAPI request body schemas get GraphQL mutation types with typed inputs
21
- - **Smart query suggestions** — `call_api` returns ready-to-use GraphQL queries based on the inferred schema
22
- - **Shape-aware schema caching** — schemas are cached by response structure (not just endpoint), so the same path returning different shapes gets distinct schemas; `shapeHash` is returned for cache-aware workflows
23
- - **Response caching** — 30-second TTL cache prevents duplicate HTTP calls across consecutive `call_api` → `query_api` flows
24
- - **Retry with backoff** — automatic retries with exponential backoff and jitter for 429/5xx errors, honoring `Retry-After` headers
25
- - **Multi-format responses** — parses JSON, XML, CSV, and plain text responses automatically
26
- - **Built-in pagination** — API-level pagination via `params`; client-side slicing with top-level `limit`/`offset`
27
- - **Spec documentation lookup** — `explain_api` returns rich endpoint docs (parameters, response codes, deprecation, request body schema) without making HTTP requests
28
- - **Concurrent batch queries** — `batch_query` fetches data from up to 10 endpoints in parallel, returning all results in one tool call
29
- - **Per-request headers** — override default headers on individual `call_api`/`query_api`/`batch_query` calls
30
- - **Environment variable interpolation** — use `${ENV_VAR}` in base URLs and headers
31
- - **Rich error context** — API errors return structured messages (parses RFC 7807, `{ error: { message, code } }`, `{ errors: [...] }`, and more), status-specific suggestions (e.g. "Authentication required" for 401), and relevant spec info (required parameters, request body schema) for 400/422 errors so the LLM can self-correct
32
- - **Request logging** — optional NDJSON request/response log with sensitive header masking
33
-
34
11
  [![npm](https://img.shields.io/npm/v/anyapi-mcp-server)](https://www.npmjs.com/package/anyapi-mcp-server)
35
12
 
36
- ## Installation
13
+ ## Quick start
14
+
15
+ **1. Install**
37
16
 
38
17
  ```bash
39
18
  npm install -g anyapi-mcp-server
40
19
  ```
41
20
 
42
- ### Required arguments
43
-
44
- | Flag | Description |
45
- |------|-------------|
46
- | `--name` | Server name (e.g. `petstore`) |
47
- | `--spec` | Path or HTTPS URL to OpenAPI spec (JSON or YAML) or Postman Collection. HTTPS URLs are cached locally in ``~/.cache/anyapi-mcp/` (Linux/macOS) or `%LOCALAPPDATA%\anyapi-mcp\` (Windows)`. Supports `${ENV_VAR}` interpolation. |
48
- | `--base-url` | API base URL (e.g. `https://api.example.com`). Supports `${ENV_VAR}` interpolation. |
49
-
50
- ### Optional arguments
51
-
52
- | Flag | Description |
53
- |------|-------------|
54
- | `--header` | HTTP header as `"Key: Value"` (repeatable). Supports `${ENV_VAR}` interpolation in values. |
55
- | `--log` | Path to request/response log file (NDJSON format). Sensitive headers are masked automatically. |
56
-
57
- ### Example: Cursor / Claude Desktop configuration
58
-
59
- Add to your MCP configuration (e.g. `~/.cursor/mcp.json` or Claude Desktop config):
60
-
61
- #### Cloudflare API
21
+ **2. Add to your MCP client** (Cursor, Claude Desktop, etc.)
62
22
 
63
23
  ```json
64
24
  {
65
25
  "mcpServers": {
66
- "cloudflare": {
26
+ "your-api": {
67
27
  "command": "npx",
68
28
  "args": [
69
29
  "-y",
70
30
  "anyapi-mcp-server",
71
- "--name", "cloudflare",
72
- "--spec", "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json",
73
- "--base-url", "https://api.cloudflare.com/client/v4",
74
- "--header", "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}"
31
+ "--name", "your-api",
32
+ "--spec", "path/to/openapi.json",
33
+ "--base-url", "https://api.example.com",
34
+ "--header", "Authorization: Bearer ${API_KEY}"
75
35
  ],
76
36
  "env": {
77
- "CLOUDFLARE_API_TOKEN": "your-cloudflare-api-token"
37
+ "API_KEY": "your-api-key"
78
38
  }
79
39
  }
80
40
  }
81
41
  }
82
42
  ```
83
43
 
84
- #### Datadog API
44
+ **3. Use the tools** — discover endpoints with `list_api`, inspect schemas with `call_api`, fetch data with `query_api`.
85
45
 
86
- ```json
87
- {
88
- "mcpServers": {
89
- "datadog": {
90
- "command": "npx",
91
- "args": [
92
- "-y",
93
- "anyapi-mcp-server",
94
- "--name", "datadog",
95
- "--spec", "https://raw.githubusercontent.com/DataDog/datadog-api-client-typescript/master/.generator/schemas/v1/openapi.yaml",
96
- "--base-url", "https://api.datadoghq.com",
97
- "--header", "DD-API-KEY: ${DD_API_KEY}",
98
- "--header", "DD-APPLICATION-KEY: ${DD_APP_KEY}"
99
- ],
100
- "env": {
101
- "DD_API_KEY": "your-datadog-api-key",
102
- "DD_APP_KEY": "your-datadog-app-key"
103
- }
104
- }
105
- }
106
- }
107
- ```
46
+ ## Provider examples
108
47
 
109
- #### Metabase API
48
+ Ready-to-use configurations for popular APIs:
110
49
 
111
- ```json
112
- {
113
- "mcpServers": {
114
- "metabase": {
115
- "command": "npx",
116
- "args": [
117
- "-y",
118
- "anyapi-mcp-server",
119
- "--name", "metabase",
120
- "--base-url", "https://your-metabase-instance.com/api",
121
- "--header", "x-api-key: ${METABASE_API_KEY}"
122
- ],
123
- "env": {
124
- "METABASE_API_KEY": "your-metabase-api-key"
125
- }
126
- }
127
- }
128
- }
129
- ```
50
+ | Provider | Auth |
51
+ |----------|------|
52
+ | [Cloudflare](docs/cloudflare.md) | API Token or Key + Email |
53
+ | [Datadog](docs/datadog.md) | API Key + App Key |
54
+ | [GitHub](docs/github.md) | Personal Access Token |
55
+ | [Google Workspace](docs/google-workspace.md) | OAuth 2.0 |
56
+ | [Metabase](docs/metabase.md) | API Key |
57
+ | [PostHog](docs/posthog.md) | Personal API Key |
58
+ | [Slack](docs/slack.md) | Bot/User Token |
130
59
 
131
- ## Tools
60
+ These work with any API that has an OpenAPI or Postman spec — the above are just examples. Stripe, Twilio, Shopify, HubSpot, and anything else with a REST API will work the same way.
132
61
 
133
- The server exposes five MCP tools:
62
+ ## CLI reference
134
63
 
135
- ### `list_api`
64
+ ### Required flags
136
65
 
137
- Browse and search available API endpoints from the spec.
66
+ | Flag | Description |
67
+ |------|-------------|
68
+ | `--name` | Server name (e.g. `cloudflare`) |
69
+ | `--spec` | Path or HTTPS URL to an OpenAPI spec (JSON/YAML) or Postman Collection. Remote URLs are cached locally. Supports `${ENV_VAR}`. |
70
+ | `--base-url` | API base URL (e.g. `https://api.example.com`). Supports `${ENV_VAR}`. |
138
71
 
139
- - Call with no arguments to see all categories/tags
140
- - Provide `category` to list endpoints in a tag
141
- - Provide `search` to search across paths and descriptions
142
- - Results are paginated with `limit` (default 20) and `offset`
143
- - GraphQL selection for categories:
144
- ```graphql
145
- {
146
- items {
147
- tag
148
- endpointCount
149
- }
150
- _count
151
- }
152
- ```
153
- - GraphQL selection for endpoints:
154
- ```graphql
155
- {
156
- items {
157
- method
158
- path
159
- summary
160
- }
161
- _count
162
- }
163
- ```
72
+ ### Optional flags
164
73
 
165
- ### `call_api`
74
+ | Flag | Description |
75
+ |------|-------------|
76
+ | `--header` | HTTP header as `"Key: Value"` (repeatable). Supports `${ENV_VAR}` in values. |
77
+ | `--log` | Path to NDJSON request/response log. Sensitive headers are masked automatically. |
166
78
 
167
- Inspect an API endpoint by making a real request and returning the inferred GraphQL schema (SDL). No response data is returned — use `query_api` to fetch actual data.
79
+ ### OAuth flags
168
80
 
169
- - Returns the full schema SDL showing all available fields and types
170
- - Returns accepted parameters (name, location, required) from the API spec
171
- - Returns `suggestedQueries` — ready-to-use GraphQL queries generated from the schema
172
- - Returns `shapeHash` — a structural fingerprint of the response for cache-aware workflows
173
- - Returns `bodyHash` for write operations when a request body is provided
174
- - Accepts optional `headers` to override defaults for this request
175
- - For write operations (POST/PUT/DELETE/PATCH) with request body schemas, the schema includes a Mutation type
81
+ For APIs that use OAuth 2.0 instead of static tokens. If any of the three required flags is provided, all three are required. All flags support `${ENV_VAR}`.
176
82
 
177
- ### `query_api`
83
+ | Flag | Required | Description |
84
+ |------|----------|-------------|
85
+ | `--oauth-client-id` | Yes* | OAuth client ID |
86
+ | `--oauth-client-secret` | Yes* | OAuth client secret |
87
+ | `--oauth-token-url` | Yes* | Token endpoint URL |
88
+ | `--oauth-auth-url` | No | Authorization endpoint (auto-detected from spec if available) |
89
+ | `--oauth-scopes` | No | Comma-separated scopes |
90
+ | `--oauth-flow` | No | `authorization_code` (default) or `client_credentials` |
91
+ | `--oauth-param` | No | Extra token parameter as `key=value` (repeatable) |
178
92
 
179
- Fetch data from an API endpoint, returning only the fields you select via GraphQL.
93
+ See the [Google Workspace guide](docs/google-workspace.md) for a complete OAuth example.
180
94
 
181
- - Object responses:
182
- ```graphql
183
- {
184
- id
185
- name
186
- collection {
187
- id
188
- name
189
- }
190
- }
191
- ```
192
- - Array responses:
193
- ```graphql
194
- {
195
- items {
196
- id
197
- name
198
- }
199
- _count
200
- }
201
- ```
202
- - Mutation syntax for writes:
203
- ```graphql
204
- mutation {
205
- post_endpoint(input: { ... }) {
206
- id
207
- name
208
- }
209
- }
210
- ```
211
- - Supports `limit` and `offset` for client-side slicing of already-fetched data
212
- - For API-level pagination, pass limit/offset inside `params` instead
213
- - Accepts optional `headers` to override defaults for this request
214
- - Response includes `_shapeHash` (and `_bodyHash` for writes) for tracking schema identity
95
+ ## Tools
96
+
97
+ The server exposes four tools (plus `auth` when OAuth is configured):
98
+
99
+ ### `list_api` — Browse endpoints
100
+
101
+ Discover what the API offers. Call with no arguments to see all categories, provide `category` to list endpoints in a tag, or `search` to find endpoints by keyword.
102
+
103
+ ### `call_api` — Inspect an endpoint
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`. Also returns per-field token costs (`fieldTokenCosts`) and a `dataKey` for cache reuse.
215
106
 
216
- ### `explain_api`
107
+ ### `query_api` — Fetch data
217
108
 
218
- Get detailed documentation for an endpoint directly from the spec no HTTP request is made.
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.
219
110
 
220
- - Returns summary, description, operationId, deprecation status, tag
221
- - Lists all parameters with name, location (`path`/`query`/`header`), required flag, and description
222
- - Shows request body schema with property types, required fields, and descriptions
223
- - Lists response status codes with descriptions (e.g. `200 OK`, `404 Not Found`)
224
- - Includes external docs link when available
111
+ ```graphql
112
+ # Read
113
+ { items { id name status } _count }
225
114
 
226
- ### `batch_query`
115
+ # Write
116
+ mutation { post_endpoint(input: { name: "example" }) { id } }
117
+ ```
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
+
124
+ ### `explain_api` — Read the docs
227
125
 
228
- Fetch data from multiple endpoints concurrently in a single tool call.
126
+ Returns spec documentation for an endpoint (parameters, request body schema, response codes) **without making an HTTP request**.
229
127
 
230
- - Accepts an array of 1–10 requests, each with `method`, `path`, `params`, `body`, `query`, and optional `headers`
231
- - All requests execute in parallel via `Promise.allSettled` — one failure does not affect the others
232
- - Each request follows the `query_api` flow: HTTP fetch → schema inference → GraphQL field selection
233
- - Returns an array of results: `{ method, path, data, shapeHash }` on success or a structured error with status, suggestion, and spec context on failure
234
- - Run `call_api` first on each endpoint to discover the schema field names
128
+ ### `auth` OAuth authentication
235
129
 
236
- ## Workflow
130
+ Only available when `--oauth-*` flags are configured. Manages the OAuth flow:
131
+ - `action: "start"` — returns an authorization URL (or exchanges credentials for `client_credentials`)
132
+ - `action: "exchange"` — completes the authorization code flow (callback is captured automatically)
133
+ - `action: "status"` — shows current token status
237
134
 
238
- 1. **Discover** endpoints with `list_api`
239
- 2. **Understand** an endpoint with `explain_api` to see its parameters, request body, and response codes
240
- 3. **Inspect** a specific endpoint with `call_api` to see the inferred response schema and suggested queries
241
- 4. **Query** the endpoint with `query_api` to fetch exactly the fields you need
242
- 5. **Batch** multiple queries with `batch_query` when you need data from several endpoints at once
135
+ Tokens are persisted and refreshed automatically.
243
136
 
244
- ## How It Works
137
+ ## Typical workflow
138
+
139
+ ```
140
+ list_api → discover what's available
141
+
142
+ explain_api → read the docs for an endpoint
143
+
144
+ call_api → inspect the response schema (returns dataKey)
145
+
146
+ query_api → fetch exactly the fields you need (pass dataKey for zero HTTP calls)
147
+
148
+ query_api → re-query with different fields using the same dataKey
149
+ ```
150
+
151
+ ## How it works
245
152
 
246
153
  ```
247
154
  OpenAPI/Postman spec
248
155
 
249
156
 
250
- ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────────────┐
251
- │list_api │ │ explain_api │ │ call_api │ │ query_api │ │ batch_query │
252
- │(browse) │ │ (docs) │ │ (schema) │ │ (data) │ │ (parallel) │
253
- └─────────┘ └─────────────┘ └──────────┘ └───────────┘ └─────────────┘
254
- │ │ no HTTP │ │
255
- ▼ ▼ request ▼ ▼
256
- Spec index Spec index REST API call REST API call N concurrent
257
- (tags, (params, (with retry (cached if REST API calls
258
- paths) responses, + caching) same as + GraphQL
259
- body schema) │ call_api) execution
260
-
261
- Infer GraphQL
262
- schema from Execute GraphQL
263
- JSON response query against
264
- 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
265
171
  ```
266
172
 
267
- 1. The spec is loaded at startup (from a local file or fetched from an HTTPS URL with filesystem caching) and parsed into an endpoint index with tags, paths, parameters, and request body schemas
268
- 2. `call_api` makes a real HTTP request, infers a GraphQL schema from the JSON response, and caches both the response (30s TTL) and the schema. Schemas are keyed by endpoint + response shape, so the same path returning different structures gets distinct schemas
269
- 3. `query_api` re-uses the cached response if called within 30s, executes your GraphQL field selection against the data, and returns only the fields you asked for. Includes `_shapeHash` in the response for tracking schema identity
270
- 4. Write operations (POST/PUT/DELETE/PATCH) with OpenAPI request body schemas get a Mutation type with typed `GraphQLInputObjectType` inputs
271
- 5. When an API call fails, the error response includes a parsed error message (extracted from common formats like RFC 7807 Problem Details, `{ error: { message } }`, GraphQL-style `{ errors: [...] }`), the HTTP status code, a status-specific suggestion for what to try next, and — for validation errors (400/422) — the full parameter list and request body schema from the spec
173
+ ## Features
272
174
 
273
- ## Supported Spec Formats
175
+ - **Any REST API** — provide an OpenAPI (JSON/YAML) or Postman Collection v2.x spec as a file or URL
176
+ - **Remote spec caching** — HTTPS specs are fetched once and cached to `~/.cache/anyapi-mcp/`
177
+ - **GraphQL field selection** — query only the fields you need from any response
178
+ - **Schema inference** — automatically builds GraphQL schemas from live API responses
179
+ - **Multi-sample merging** — samples up to 10 array elements for richer schemas
180
+ - **Mutation support** — write operations get typed GraphQL mutations from OpenAPI body schemas
181
+ - **Smart suggestions** — `call_api` returns ready-to-use queries based on the inferred schema
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"`)
188
+ - **Retry with backoff** — automatic retries for 429/5xx with exponential backoff and `Retry-After` support
189
+ - **Multi-format** — parses JSON, XML, CSV, and plain text responses
190
+ - **Rich errors** — structured error messages with status-specific suggestions and spec context for self-correction
191
+ - **OAuth 2.0** — Authorization Code (with PKCE) and Client Credentials flows with automatic token refresh
192
+ - **Env var interpolation** — `${ENV_VAR}` in base URLs, headers, and spec paths
193
+ - **Request logging** — optional NDJSON log with sensitive header masking
194
+
195
+ ## Supported spec formats
274
196
 
275
197
  - **OpenAPI 3.x** (JSON or YAML)
276
198
  - **OpenAPI 2.0 / Swagger** (JSON or YAML)
277
199
  - **Postman Collection v2.x** (JSON)
278
200
 
279
- `$ref` resolution is supported for OpenAPI request body schemas. Postman `:param` path variables are converted to OpenAPI-style `{param}` automatically.
280
-
281
201
  ## License
282
202
 
283
203
  Proprietary Non-Commercial. Free for personal and educational use. Commercial use requires written permission. See [LICENSE](LICENSE) for details.
@@ -1,8 +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";
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
+ }
6
43
  const TIMEOUT_MS = 30_000;
7
44
  function interpolatePath(pathTemplate, params) {
8
45
  const remaining = { ...params };
@@ -16,22 +53,7 @@ function interpolatePath(pathTemplate, params) {
16
53
  });
17
54
  return { url, remainingParams: remaining };
18
55
  }
19
- /**
20
- * @param cacheMode
21
- * - "populate" — skip cache read, always fetch, store result (used by call_api)
22
- * - "consume" — read-and-evict cache, fetch on miss, do NOT re-store (used by query_api)
23
- * - "none" — no caching at all (default)
24
- */
25
- export async function callApi(config, method, pathTemplate, params, body, extraHeaders, cacheMode = "none") {
26
- // --- Cache check (consume mode only) ---
27
- const cacheKey = cacheMode !== "none"
28
- ? buildCacheKey(method, pathTemplate, params, body, extraHeaders)
29
- : "";
30
- if (cacheMode === "consume") {
31
- const cached = consumeCached(cacheKey);
32
- if (cached !== undefined)
33
- return cached;
34
- }
56
+ export async function callApi(config, method, pathTemplate, params, body, extraHeaders) {
35
57
  // --- URL construction ---
36
58
  const { url: interpolatedPath, remainingParams } = interpolatePath(pathTemplate, params ?? {});
37
59
  let fullUrl = `${config.baseUrl}${interpolatedPath}`;
@@ -44,75 +66,112 @@ export async function callApi(config, method, pathTemplate, params, body, extraH
44
66
  }
45
67
  fullUrl += `?${qs.toString()}`;
46
68
  }
47
- const mergedHeaders = {
48
- "Content-Type": "application/json",
69
+ // Check if an explicit Authorization header was provided by the caller
70
+ const hasExplicitAuth = Object.keys({
49
71
  ...config.headers,
50
72
  ...extraHeaders,
51
- };
52
- // --- Retry-wrapped fetch ---
53
- const result = await withRetry(async () => {
54
- const controller = new AbortController();
55
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
56
- const startTime = Date.now();
57
- try {
58
- const fetchOptions = {
59
- method,
60
- headers: mergedHeaders,
61
- signal: controller.signal,
62
- };
63
- if (body && method !== "GET") {
64
- fetchOptions.body = JSON.stringify(body);
73
+ }).some((k) => k.toLowerCase() === "authorization");
74
+ const doRequest = async () => {
75
+ const mergedHeaders = {
76
+ "Content-Type": "application/json",
77
+ ...config.headers,
78
+ ...extraHeaders,
79
+ };
80
+ // Inject OAuth bearer token if no explicit Authorization header
81
+ if (!hasExplicitAuth) {
82
+ const accessToken = await getValidAccessToken(config.oauth);
83
+ if (accessToken) {
84
+ mergedHeaders["Authorization"] = `Bearer ${accessToken}`;
65
85
  }
66
- const response = await fetch(fullUrl, fetchOptions);
67
- const durationMs = Date.now() - startTime;
68
- const bodyText = await response.text();
69
- const responseHeaders = {};
70
- response.headers.forEach((v, k) => {
71
- responseHeaders[k] = v;
72
- });
73
- // Log request/response
74
- if (isLoggingEnabled()) {
75
- await logEntry({
76
- timestamp: new Date().toISOString(),
86
+ }
87
+ return withRetry(async () => {
88
+ const controller = new AbortController();
89
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
90
+ const startTime = Date.now();
91
+ try {
92
+ const fetchOptions = {
77
93
  method,
78
- url: fullUrl,
79
- requestHeaders: mergedHeaders,
80
- requestBody: body,
81
- responseStatus: response.status,
82
- responseHeaders,
83
- responseBody: bodyText,
84
- durationMs,
94
+ headers: mergedHeaders,
95
+ signal: controller.signal,
96
+ };
97
+ if (body && method !== "GET") {
98
+ fetchOptions.body = JSON.stringify(body);
99
+ }
100
+ const response = await fetch(fullUrl, fetchOptions);
101
+ const durationMs = Date.now() - startTime;
102
+ const bodyText = await response.text();
103
+ const responseHeaders = {};
104
+ response.headers.forEach((v, k) => {
105
+ responseHeaders[k] = v;
85
106
  });
86
- }
87
- if (!response.ok) {
88
- if (isRetryableStatus(response.status)) {
89
- const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
90
- let retryAfterMs;
91
- const retryAfter = response.headers.get("retry-after");
92
- if (retryAfter) {
93
- const seconds = parseInt(retryAfter, 10);
94
- if (!isNaN(seconds))
95
- retryAfterMs = seconds * 1000;
107
+ // Log request/response
108
+ if (isLoggingEnabled()) {
109
+ await logEntry({
110
+ timestamp: new Date().toISOString(),
111
+ method,
112
+ url: fullUrl,
113
+ requestHeaders: mergedHeaders,
114
+ requestBody: body,
115
+ responseStatus: response.status,
116
+ responseHeaders,
117
+ responseBody: bodyText,
118
+ durationMs,
119
+ });
120
+ }
121
+ // Track rate limit headers from every response
122
+ trackRateLimit(parseRateLimits(responseHeaders));
123
+ if (!response.ok) {
124
+ if (isRetryableStatus(response.status)) {
125
+ const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
126
+ let retryAfterMs;
127
+ const retryAfter = response.headers.get("retry-after");
128
+ if (retryAfter) {
129
+ const seconds = parseInt(retryAfter, 10);
130
+ if (!isNaN(seconds))
131
+ retryAfterMs = seconds * 1000;
132
+ }
133
+ throw new RetryableError(msg, response.status, retryAfterMs);
96
134
  }
97
- throw new RetryableError(msg, response.status, retryAfterMs);
135
+ throw new ApiError(`API error ${response.status} ${response.statusText}`, response.status, response.statusText, bodyText, responseHeaders);
98
136
  }
99
- throw new ApiError(`API error ${response.status} ${response.statusText}`, response.status, response.statusText, bodyText, responseHeaders);
137
+ const parsedData = parseResponse(response.headers.get("content-type"), bodyText);
138
+ return { data: parsedData, responseHeaders };
100
139
  }
101
- return parseResponse(response.headers.get("content-type"), bodyText);
102
- }
103
- catch (error) {
104
- if (error instanceof DOMException && error.name === "AbortError") {
105
- throw new Error(`Request to ${method} ${pathTemplate} timed out after ${TIMEOUT_MS / 1000}s`);
140
+ catch (error) {
141
+ if (error instanceof DOMException && error.name === "AbortError") {
142
+ throw new Error(`Request to ${method} ${pathTemplate} timed out after ${TIMEOUT_MS / 1000}s`);
143
+ }
144
+ throw error;
145
+ }
146
+ finally {
147
+ clearTimeout(timeout);
148
+ }
149
+ });
150
+ };
151
+ // --- Rate limit pre-check ---
152
+ await waitIfNeeded();
153
+ // --- Execute with 401 refresh-and-retry ---
154
+ let result;
155
+ try {
156
+ result = await doRequest();
157
+ }
158
+ catch (error) {
159
+ if (error instanceof ApiError &&
160
+ error.status === 401 &&
161
+ config.oauth &&
162
+ !hasExplicitAuth) {
163
+ // Refresh token and retry once
164
+ try {
165
+ await refreshTokens(config.oauth);
166
+ result = await doRequest();
167
+ }
168
+ catch {
169
+ throw error; // Throw the original 401
106
170
  }
107
- throw error;
108
171
  }
109
- finally {
110
- clearTimeout(timeout);
172
+ else {
173
+ throw error;
111
174
  }
112
- });
113
- // --- Cache store (populate mode only) ---
114
- if (cacheMode === "populate") {
115
- setCache(cacheKey, result);
116
175
  }
117
176
  return result;
118
177
  }