anyapi-mcp-server 1.1.1
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/LICENSE +32 -0
- package/README.md +178 -0
- package/build/api-client.js +105 -0
- package/build/api-index.js +263 -0
- package/build/config.js +64 -0
- package/build/graphql-schema.js +334 -0
- package/build/index.js +263 -0
- package/build/logger.js +51 -0
- package/build/query-suggestions.js +138 -0
- package/build/response-cache.js +39 -0
- package/build/response-parser.js +80 -0
- package/build/retry.js +53 -0
- package/build/types.js +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
anyapi-mcp-server - Proprietary Non-Commercial License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 quiloos39
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to use,
|
|
7
|
+
copy, modify, and distribute the Software for personal, educational, or
|
|
8
|
+
non-commercial purposes only, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
1. COMMERCIAL USE PROHIBITED. The Software may not be used, in whole or in
|
|
11
|
+
part, for any commercial purpose without prior written permission from the
|
|
12
|
+
copyright holder. "Commercial purpose" includes, but is not limited to:
|
|
13
|
+
- Selling, licensing, or sublicensing the Software or derivative works
|
|
14
|
+
- Using the Software in a product or service sold to customers
|
|
15
|
+
- Using the Software to generate revenue, directly or indirectly
|
|
16
|
+
- Using the Software in a commercial organization's operations
|
|
17
|
+
|
|
18
|
+
2. The above copyright notice and this permission notice shall be included in
|
|
19
|
+
all copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
3. Derivative works must include this same license and may not be released
|
|
22
|
+
under a different license.
|
|
23
|
+
|
|
24
|
+
4. For commercial licensing inquiries, contact the copyright holder.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
28
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
29
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
30
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# anyapi-mcp-server
|
|
2
|
+
|
|
3
|
+
A universal [Model Context Protocol (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.
|
|
4
|
+
|
|
5
|
+
Instead of building a custom MCP server for every API, `anyapi-mcp-server` reads your spec file and dynamically creates tools with **GraphQL-style field selection** and automatic schema inference.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Works with any REST API** — provide an OpenAPI (JSON/YAML) or Postman Collection v2.x spec
|
|
10
|
+
- **GraphQL-style queries** — select only the fields you need from API responses
|
|
11
|
+
- **Automatic schema inference** — calls an endpoint once, infers the response schema, then lets you query specific fields
|
|
12
|
+
- **Multi-sample merging** — samples up to 10 array elements to build richer schemas that capture fields missing from individual items
|
|
13
|
+
- **Mutation support** — POST/PUT/DELETE/PATCH endpoints with OpenAPI request body schemas get GraphQL mutation types with typed inputs
|
|
14
|
+
- **Smart query suggestions** — `call_api` returns ready-to-use GraphQL queries based on the inferred schema
|
|
15
|
+
- **Response caching** — 30-second TTL cache prevents duplicate HTTP calls across consecutive `call_api` → `query_api` flows
|
|
16
|
+
- **Retry with backoff** — automatic retries with exponential backoff and jitter for 429/5xx errors, honoring `Retry-After` headers
|
|
17
|
+
- **Multi-format responses** — parses JSON, XML, CSV, and plain text responses automatically
|
|
18
|
+
- **Built-in pagination** — API-level pagination via `params`; client-side slicing with top-level `limit`/`offset`
|
|
19
|
+
- **Per-request headers** — override default headers on individual `call_api`/`query_api` calls
|
|
20
|
+
- **Environment variable interpolation** — use `${ENV_VAR}` in base URLs and headers
|
|
21
|
+
- **Request logging** — optional NDJSON request/response log with sensitive header masking
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g anyapi-mcp-server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or use directly with `npx`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx anyapi-mcp-server --name petstore --spec ./petstore.yaml --base-url https://petstore.swagger.io/v2
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
anyapi-mcp --name <name> --spec <path> --base-url <url> [--header "Key: Value"]... [--log <path>]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Required arguments
|
|
42
|
+
|
|
43
|
+
| Flag | Description |
|
|
44
|
+
|------|-------------|
|
|
45
|
+
| `--name` | Server name (e.g. `petstore`) |
|
|
46
|
+
| `--spec` | Path to OpenAPI spec file (JSON or YAML) or Postman Collection |
|
|
47
|
+
| `--base-url` | API base URL (e.g. `https://api.example.com`). Supports `${ENV_VAR}` interpolation. |
|
|
48
|
+
|
|
49
|
+
### Optional arguments
|
|
50
|
+
|
|
51
|
+
| Flag | Description |
|
|
52
|
+
|------|-------------|
|
|
53
|
+
| `--header` | HTTP header as `"Key: Value"` (repeatable). Supports `${ENV_VAR}` interpolation in values. |
|
|
54
|
+
| `--log` | Path to request/response log file (NDJSON format). Sensitive headers are masked automatically. |
|
|
55
|
+
|
|
56
|
+
### Example: Cursor / Claude Desktop configuration
|
|
57
|
+
|
|
58
|
+
Add to your MCP configuration (e.g. `~/.cursor/mcp.json` or Claude Desktop config):
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"petstore": {
|
|
64
|
+
"command": "npx",
|
|
65
|
+
"args": [
|
|
66
|
+
"-y",
|
|
67
|
+
"anyapi-mcp-server",
|
|
68
|
+
"--name", "petstore",
|
|
69
|
+
"--spec", "/path/to/petstore.yaml",
|
|
70
|
+
"--base-url", "https://petstore.swagger.io/v2"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With authentication headers:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"my-api": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": [
|
|
85
|
+
"-y",
|
|
86
|
+
"anyapi-mcp-server",
|
|
87
|
+
"--name", "my-api",
|
|
88
|
+
"--spec", "/path/to/openapi.json",
|
|
89
|
+
"--base-url", "https://api.example.com",
|
|
90
|
+
"--header", "Authorization: Bearer ${API_TOKEN}"
|
|
91
|
+
],
|
|
92
|
+
"env": {
|
|
93
|
+
"API_TOKEN": "your-token-here"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Tools
|
|
101
|
+
|
|
102
|
+
The server exposes three MCP tools:
|
|
103
|
+
|
|
104
|
+
### `list_api`
|
|
105
|
+
|
|
106
|
+
Browse and search available API endpoints from the spec.
|
|
107
|
+
|
|
108
|
+
- Call with no arguments to see all categories/tags
|
|
109
|
+
- Provide `category` to list endpoints in a tag
|
|
110
|
+
- Provide `search` to search across paths and descriptions
|
|
111
|
+
- Results are paginated with `limit` (default 20) and `offset`
|
|
112
|
+
- Uses GraphQL selection: `{ items { tag endpointCount } _count }` for categories, `{ items { method path summary } _count }` for endpoints
|
|
113
|
+
|
|
114
|
+
### `call_api`
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
- Returns the full schema SDL showing all available fields and types
|
|
119
|
+
- Returns accepted parameters (name, location, required) from the API spec
|
|
120
|
+
- Returns `suggestedQueries` — ready-to-use GraphQL queries generated from the schema
|
|
121
|
+
- Accepts optional `headers` to override defaults for this request
|
|
122
|
+
- For write operations (POST/PUT/DELETE/PATCH) with request body schemas, the schema includes a Mutation type
|
|
123
|
+
|
|
124
|
+
### `query_api`
|
|
125
|
+
|
|
126
|
+
Fetch data from an API endpoint, returning only the fields you select via GraphQL.
|
|
127
|
+
|
|
128
|
+
- Object responses: `{ id name collection { id name } }`
|
|
129
|
+
- Array responses: `{ items { id name } _count }`
|
|
130
|
+
- Mutation syntax for writes: `mutation { post_endpoint(input: { ... }) { id name } }`
|
|
131
|
+
- Supports `limit` and `offset` for client-side slicing of already-fetched data
|
|
132
|
+
- For API-level pagination, pass limit/offset inside `params` instead
|
|
133
|
+
- Accepts optional `headers` to override defaults for this request
|
|
134
|
+
|
|
135
|
+
## Workflow
|
|
136
|
+
|
|
137
|
+
1. **Discover** endpoints with `list_api`
|
|
138
|
+
2. **Inspect** a specific endpoint with `call_api` to see its schema and suggested queries
|
|
139
|
+
3. **Query** the endpoint with `query_api` to fetch exactly the fields you need
|
|
140
|
+
|
|
141
|
+
## How It Works
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
OpenAPI/Postman spec
|
|
145
|
+
│
|
|
146
|
+
▼
|
|
147
|
+
┌─────────┐ ┌──────────┐ ┌────────────┐
|
|
148
|
+
│ list_api │ │ call_api │────▶│ query_api │
|
|
149
|
+
│ (browse) │ │ (schema) │ │ (data) │
|
|
150
|
+
└─────────┘ └──────────┘ └────────────┘
|
|
151
|
+
│ │
|
|
152
|
+
▼ ▼
|
|
153
|
+
REST API call REST API call
|
|
154
|
+
(with retry (cached if same
|
|
155
|
+
+ caching) as call_api)
|
|
156
|
+
│ │
|
|
157
|
+
▼ ▼
|
|
158
|
+
Infer GraphQL Execute GraphQL
|
|
159
|
+
schema from query against
|
|
160
|
+
JSON response response data
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
1. The spec file is parsed at startup into an endpoint index with tags, paths, parameters, and request body schemas
|
|
164
|
+
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
|
|
165
|
+
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
|
|
166
|
+
4. Write operations (POST/PUT/DELETE/PATCH) with OpenAPI request body schemas get a Mutation type with typed `GraphQLInputObjectType` inputs
|
|
167
|
+
|
|
168
|
+
## Supported Spec Formats
|
|
169
|
+
|
|
170
|
+
- **OpenAPI 3.x** (JSON or YAML)
|
|
171
|
+
- **OpenAPI 2.0 / Swagger** (JSON or YAML)
|
|
172
|
+
- **Postman Collection v2.x** (JSON)
|
|
173
|
+
|
|
174
|
+
`$ref` resolution is supported for OpenAPI request body schemas. Postman `:param` path variables are converted to OpenAPI-style `{param}` automatically.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
Proprietary Non-Commercial. Free for personal and educational use. Commercial use requires written permission. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { withRetry, RetryableError, isRetryableStatus } from "./retry.js";
|
|
2
|
+
import { buildCacheKey, getCached, setCache } from "./response-cache.js";
|
|
3
|
+
import { logEntry, isLoggingEnabled } from "./logger.js";
|
|
4
|
+
import { parseResponse } from "./response-parser.js";
|
|
5
|
+
const TIMEOUT_MS = 30_000;
|
|
6
|
+
function interpolatePath(pathTemplate, params) {
|
|
7
|
+
const remaining = { ...params };
|
|
8
|
+
const url = pathTemplate.replace(/\{([^}]+)\}/g, (_, paramName) => {
|
|
9
|
+
const value = remaining[paramName];
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
throw new Error(`Missing required path parameter: ${paramName}`);
|
|
12
|
+
}
|
|
13
|
+
delete remaining[paramName];
|
|
14
|
+
return encodeURIComponent(String(value));
|
|
15
|
+
});
|
|
16
|
+
return { url, remainingParams: remaining };
|
|
17
|
+
}
|
|
18
|
+
export async function callApi(config, method, pathTemplate, params, body, extraHeaders) {
|
|
19
|
+
// --- Cache check ---
|
|
20
|
+
const cacheKey = buildCacheKey(method, pathTemplate, params, body, extraHeaders);
|
|
21
|
+
const cached = getCached(cacheKey);
|
|
22
|
+
if (cached !== undefined)
|
|
23
|
+
return cached;
|
|
24
|
+
// --- URL construction ---
|
|
25
|
+
const { url: interpolatedPath, remainingParams } = interpolatePath(pathTemplate, params ?? {});
|
|
26
|
+
let fullUrl = `${config.baseUrl}${interpolatedPath}`;
|
|
27
|
+
if (method === "GET" && Object.keys(remainingParams).length > 0) {
|
|
28
|
+
const qs = new URLSearchParams();
|
|
29
|
+
for (const [k, v] of Object.entries(remainingParams)) {
|
|
30
|
+
if (v !== undefined && v !== null) {
|
|
31
|
+
qs.append(k, String(v));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
fullUrl += `?${qs.toString()}`;
|
|
35
|
+
}
|
|
36
|
+
const mergedHeaders = {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
...config.headers,
|
|
39
|
+
...extraHeaders,
|
|
40
|
+
};
|
|
41
|
+
// --- Retry-wrapped fetch ---
|
|
42
|
+
const result = await withRetry(async () => {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
45
|
+
const startTime = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const fetchOptions = {
|
|
48
|
+
method,
|
|
49
|
+
headers: mergedHeaders,
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
};
|
|
52
|
+
if (body && method !== "GET") {
|
|
53
|
+
fetchOptions.body = JSON.stringify(body);
|
|
54
|
+
}
|
|
55
|
+
const response = await fetch(fullUrl, fetchOptions);
|
|
56
|
+
const durationMs = Date.now() - startTime;
|
|
57
|
+
const bodyText = await response.text();
|
|
58
|
+
// Log request/response
|
|
59
|
+
if (isLoggingEnabled()) {
|
|
60
|
+
const responseHeaders = {};
|
|
61
|
+
response.headers.forEach((v, k) => {
|
|
62
|
+
responseHeaders[k] = v;
|
|
63
|
+
});
|
|
64
|
+
await logEntry({
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
method,
|
|
67
|
+
url: fullUrl,
|
|
68
|
+
requestHeaders: mergedHeaders,
|
|
69
|
+
requestBody: body,
|
|
70
|
+
responseStatus: response.status,
|
|
71
|
+
responseHeaders,
|
|
72
|
+
responseBody: bodyText,
|
|
73
|
+
durationMs,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const msg = `API error ${response.status} ${response.statusText}: ${bodyText}`;
|
|
78
|
+
if (isRetryableStatus(response.status)) {
|
|
79
|
+
let retryAfterMs;
|
|
80
|
+
const retryAfter = response.headers.get("retry-after");
|
|
81
|
+
if (retryAfter) {
|
|
82
|
+
const seconds = parseInt(retryAfter, 10);
|
|
83
|
+
if (!isNaN(seconds))
|
|
84
|
+
retryAfterMs = seconds * 1000;
|
|
85
|
+
}
|
|
86
|
+
throw new RetryableError(msg, response.status, retryAfterMs);
|
|
87
|
+
}
|
|
88
|
+
throw new Error(msg);
|
|
89
|
+
}
|
|
90
|
+
return parseResponse(response.headers.get("content-type"), bodyText);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
94
|
+
throw new Error(`Request to ${method} ${pathTemplate} timed out after ${TIMEOUT_MS / 1000}s`);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// --- Cache store ---
|
|
103
|
+
setCache(cacheKey, result);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import yaml from "js-yaml";
|
|
3
|
+
const PAGE_SIZE = 20;
|
|
4
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
|
|
5
|
+
function extractRequestBodySchema(requestBody, spec) {
|
|
6
|
+
if (!requestBody || typeof requestBody !== "object")
|
|
7
|
+
return undefined;
|
|
8
|
+
const rb = requestBody;
|
|
9
|
+
// Handle $ref at the requestBody level
|
|
10
|
+
if (rb["$ref"] && typeof rb["$ref"] === "string") {
|
|
11
|
+
const resolved = resolveRef(rb["$ref"], spec);
|
|
12
|
+
if (!resolved)
|
|
13
|
+
return undefined;
|
|
14
|
+
return extractRequestBodySchema(resolved, spec);
|
|
15
|
+
}
|
|
16
|
+
const content = rb.content;
|
|
17
|
+
if (!content)
|
|
18
|
+
return undefined;
|
|
19
|
+
const jsonContent = content["application/json"];
|
|
20
|
+
if (!jsonContent?.schema)
|
|
21
|
+
return undefined;
|
|
22
|
+
let schema = jsonContent.schema;
|
|
23
|
+
// Resolve top-level $ref
|
|
24
|
+
if (schema["$ref"] && typeof schema["$ref"] === "string") {
|
|
25
|
+
const resolved = resolveRef(schema["$ref"], spec);
|
|
26
|
+
if (!resolved)
|
|
27
|
+
return undefined;
|
|
28
|
+
schema = resolved;
|
|
29
|
+
}
|
|
30
|
+
const properties = schema.properties;
|
|
31
|
+
if (!properties)
|
|
32
|
+
return undefined;
|
|
33
|
+
const requiredFields = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
34
|
+
const result = {};
|
|
35
|
+
for (const [propName, propDef] of Object.entries(properties)) {
|
|
36
|
+
let def = propDef;
|
|
37
|
+
// Resolve property-level $ref
|
|
38
|
+
if (def["$ref"] && typeof def["$ref"] === "string") {
|
|
39
|
+
const resolved = resolveRef(def["$ref"], spec);
|
|
40
|
+
if (resolved)
|
|
41
|
+
def = resolved;
|
|
42
|
+
}
|
|
43
|
+
const prop = {
|
|
44
|
+
type: def.type ?? "string",
|
|
45
|
+
required: requiredFields.has(propName),
|
|
46
|
+
};
|
|
47
|
+
if (def.description)
|
|
48
|
+
prop.description = def.description;
|
|
49
|
+
if (def.items && typeof def.items === "object") {
|
|
50
|
+
const items = def.items;
|
|
51
|
+
prop.items = { type: items.type ?? "string" };
|
|
52
|
+
}
|
|
53
|
+
result[propName] = prop;
|
|
54
|
+
}
|
|
55
|
+
return Object.keys(result).length > 0
|
|
56
|
+
? { contentType: "application/json", properties: result }
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
function resolveRef(ref, spec) {
|
|
60
|
+
// Handle "#/components/schemas/Foo" style refs
|
|
61
|
+
if (!ref.startsWith("#/"))
|
|
62
|
+
return undefined;
|
|
63
|
+
const parts = ref.slice(2).split("/");
|
|
64
|
+
let current = spec;
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (typeof current !== "object" || current === null)
|
|
67
|
+
return undefined;
|
|
68
|
+
current = current[part];
|
|
69
|
+
}
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
function isPostmanCollection(parsed) {
|
|
73
|
+
const obj = parsed;
|
|
74
|
+
return !!(obj.info &&
|
|
75
|
+
typeof obj.info === "object" &&
|
|
76
|
+
obj.info.schema &&
|
|
77
|
+
typeof obj.info.schema === "string" &&
|
|
78
|
+
obj.info.schema.includes("schema.getpostman.com"));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Extract path template from Postman URL.
|
|
82
|
+
* Converts Postman's :param to OpenAPI-style {param}.
|
|
83
|
+
*/
|
|
84
|
+
function postmanUrlToPath(url) {
|
|
85
|
+
let raw;
|
|
86
|
+
if (typeof url === "string") {
|
|
87
|
+
raw = url;
|
|
88
|
+
}
|
|
89
|
+
else if (url.raw) {
|
|
90
|
+
raw = url.raw;
|
|
91
|
+
}
|
|
92
|
+
else if (url.path) {
|
|
93
|
+
raw = "/" + url.path.join("/");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return "/";
|
|
97
|
+
}
|
|
98
|
+
// Strip protocol + host, keep path only
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(raw);
|
|
101
|
+
raw = parsed.pathname + parsed.search;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Not a full URL — might be just a path or use {{baseUrl}}
|
|
105
|
+
raw = raw.replace(/^\{\{[^}]+\}\}/, "");
|
|
106
|
+
if (!raw.startsWith("/")) {
|
|
107
|
+
const slashIdx = raw.indexOf("/");
|
|
108
|
+
raw = slashIdx >= 0 ? raw.slice(slashIdx) : "/" + raw;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Strip query string
|
|
112
|
+
const qIdx = raw.indexOf("?");
|
|
113
|
+
if (qIdx >= 0)
|
|
114
|
+
raw = raw.slice(0, qIdx);
|
|
115
|
+
// Convert :param to {param}
|
|
116
|
+
raw = raw.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
117
|
+
return raw || "/";
|
|
118
|
+
}
|
|
119
|
+
export class ApiIndex {
|
|
120
|
+
byTag = new Map();
|
|
121
|
+
allEndpoints = [];
|
|
122
|
+
constructor(specPath) {
|
|
123
|
+
const raw = readFileSync(specPath, "utf-8");
|
|
124
|
+
const isYaml = /\.ya?ml$/i.test(specPath);
|
|
125
|
+
const parsed = isYaml ? yaml.load(raw) : JSON.parse(raw);
|
|
126
|
+
if (isPostmanCollection(parsed)) {
|
|
127
|
+
this.parsePostman(parsed);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.parseOpenApi(parsed, parsed);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
parseOpenApi(spec, rawSpec) {
|
|
134
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
135
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
136
|
+
if (!HTTP_METHODS.has(method))
|
|
137
|
+
continue;
|
|
138
|
+
const op = operation;
|
|
139
|
+
const tag = op.tags?.[0] ?? "untagged";
|
|
140
|
+
const parameters = (op.parameters ?? []).map((p) => ({
|
|
141
|
+
name: p.name,
|
|
142
|
+
in: p.in,
|
|
143
|
+
required: p.required ?? false,
|
|
144
|
+
description: p.description,
|
|
145
|
+
}));
|
|
146
|
+
const requestBodySchema = extractRequestBodySchema(op.requestBody, rawSpec);
|
|
147
|
+
const endpoint = {
|
|
148
|
+
method: method.toUpperCase(),
|
|
149
|
+
path,
|
|
150
|
+
summary: op.summary ?? `${method.toUpperCase()} ${path}`,
|
|
151
|
+
description: op.description ?? "",
|
|
152
|
+
tag,
|
|
153
|
+
parameters,
|
|
154
|
+
hasRequestBody: !!op.requestBody,
|
|
155
|
+
requestBodySchema,
|
|
156
|
+
};
|
|
157
|
+
this.addEndpoint(endpoint);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
parsePostman(collection) {
|
|
162
|
+
this.walkPostmanItems(collection.item ?? [], []);
|
|
163
|
+
}
|
|
164
|
+
walkPostmanItems(items, folderPath) {
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
if (item.item) {
|
|
167
|
+
// Folder — recurse with folder name as tag context
|
|
168
|
+
this.walkPostmanItems(item.item, [...folderPath, item.name ?? "unnamed"]);
|
|
169
|
+
}
|
|
170
|
+
else if (item.request) {
|
|
171
|
+
this.parsePostmanRequest(item, folderPath);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
parsePostmanRequest(item, folderPath) {
|
|
176
|
+
const req = item.request;
|
|
177
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
178
|
+
if (!HTTP_METHODS.has(method.toLowerCase()))
|
|
179
|
+
return;
|
|
180
|
+
const path = req.url ? postmanUrlToPath(req.url) : "/";
|
|
181
|
+
const tag = folderPath.length > 0 ? folderPath.join("/") : "untagged";
|
|
182
|
+
const description = typeof req.description === "string" ? req.description : "";
|
|
183
|
+
// Extract query params
|
|
184
|
+
const parameters = [];
|
|
185
|
+
if (typeof req.url === "object" && req.url.query) {
|
|
186
|
+
for (const q of req.url.query) {
|
|
187
|
+
if (q.disabled)
|
|
188
|
+
continue;
|
|
189
|
+
parameters.push({
|
|
190
|
+
name: q.key,
|
|
191
|
+
in: "query",
|
|
192
|
+
required: false,
|
|
193
|
+
description: q.description,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Extract path variables
|
|
198
|
+
if (typeof req.url === "object" && req.url.variable) {
|
|
199
|
+
for (const v of req.url.variable) {
|
|
200
|
+
parameters.push({
|
|
201
|
+
name: v.key,
|
|
202
|
+
in: "path",
|
|
203
|
+
required: true,
|
|
204
|
+
description: v.description,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const endpoint = {
|
|
209
|
+
method,
|
|
210
|
+
path,
|
|
211
|
+
summary: item.name ?? `${method} ${path}`,
|
|
212
|
+
description,
|
|
213
|
+
tag,
|
|
214
|
+
parameters,
|
|
215
|
+
hasRequestBody: !!req.body,
|
|
216
|
+
};
|
|
217
|
+
this.addEndpoint(endpoint);
|
|
218
|
+
}
|
|
219
|
+
addEndpoint(endpoint) {
|
|
220
|
+
this.allEndpoints.push(endpoint);
|
|
221
|
+
let tagList = this.byTag.get(endpoint.tag);
|
|
222
|
+
if (!tagList) {
|
|
223
|
+
tagList = [];
|
|
224
|
+
this.byTag.set(endpoint.tag, tagList);
|
|
225
|
+
}
|
|
226
|
+
tagList.push(endpoint);
|
|
227
|
+
}
|
|
228
|
+
listAllCategories() {
|
|
229
|
+
const categories = [];
|
|
230
|
+
for (const [tag, endpoints] of this.byTag) {
|
|
231
|
+
categories.push({ tag, endpointCount: endpoints.length });
|
|
232
|
+
}
|
|
233
|
+
categories.sort((a, b) => a.tag.localeCompare(b.tag));
|
|
234
|
+
return categories;
|
|
235
|
+
}
|
|
236
|
+
listAllByCategory(category) {
|
|
237
|
+
const endpoints = this.byTag.get(category) ?? [];
|
|
238
|
+
return endpoints.map((ep) => ({
|
|
239
|
+
method: ep.method,
|
|
240
|
+
path: ep.path,
|
|
241
|
+
summary: ep.summary,
|
|
242
|
+
tag: ep.tag,
|
|
243
|
+
parameters: ep.parameters,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
searchAll(keyword) {
|
|
247
|
+
const lower = keyword.toLowerCase();
|
|
248
|
+
return this.allEndpoints
|
|
249
|
+
.filter((ep) => ep.path.toLowerCase().includes(lower) ||
|
|
250
|
+
ep.summary.toLowerCase().includes(lower) ||
|
|
251
|
+
ep.description.toLowerCase().includes(lower))
|
|
252
|
+
.map((ep) => ({
|
|
253
|
+
method: ep.method,
|
|
254
|
+
path: ep.path,
|
|
255
|
+
summary: ep.summary,
|
|
256
|
+
tag: ep.tag,
|
|
257
|
+
parameters: ep.parameters,
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
getEndpoint(method, path) {
|
|
261
|
+
return this.allEndpoints.find((ep) => ep.method === method.toUpperCase() && ep.path === path);
|
|
262
|
+
}
|
|
263
|
+
}
|
package/build/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
function getArg(flag) {
|
|
3
|
+
const idx = process.argv.indexOf(flag);
|
|
4
|
+
if (idx === -1 || !process.argv[idx + 1])
|
|
5
|
+
return undefined;
|
|
6
|
+
return process.argv[idx + 1];
|
|
7
|
+
}
|
|
8
|
+
function getAllArgs(flag) {
|
|
9
|
+
const values = [];
|
|
10
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
11
|
+
if (process.argv[i] === flag && process.argv[i + 1]) {
|
|
12
|
+
values.push(process.argv[i + 1]);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return values;
|
|
16
|
+
}
|
|
17
|
+
function interpolateEnv(value) {
|
|
18
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
19
|
+
const envValue = process.env[varName];
|
|
20
|
+
if (envValue === undefined) {
|
|
21
|
+
throw new Error(`Environment variable ${varName} is not set`);
|
|
22
|
+
}
|
|
23
|
+
return envValue;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const USAGE = `Usage: anyapi-mcp --name <name> --spec <path> --base-url <url> [--header "Key: Value"]...
|
|
27
|
+
|
|
28
|
+
Required:
|
|
29
|
+
--name Server name (e.g. "petstore")
|
|
30
|
+
--spec Path to OpenAPI spec file (JSON or YAML)
|
|
31
|
+
--base-url API base URL (e.g. "https://api.example.com")
|
|
32
|
+
|
|
33
|
+
Optional:
|
|
34
|
+
--header HTTP header as "Key: Value" (repeatable)
|
|
35
|
+
Supports \${ENV_VAR} interpolation in values
|
|
36
|
+
--log Path to request/response log file (NDJSON format)`;
|
|
37
|
+
export function loadConfig() {
|
|
38
|
+
const name = getArg("--name");
|
|
39
|
+
const spec = getArg("--spec");
|
|
40
|
+
const baseUrl = getArg("--base-url");
|
|
41
|
+
if (!name || !spec || !baseUrl) {
|
|
42
|
+
console.error(USAGE);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const headers = {};
|
|
46
|
+
for (const raw of getAllArgs("--header")) {
|
|
47
|
+
const colonIdx = raw.indexOf(":");
|
|
48
|
+
if (colonIdx === -1) {
|
|
49
|
+
console.error(`ERROR: Invalid header format "${raw}". Expected "Key: Value"`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
53
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
54
|
+
headers[key] = interpolateEnv(value);
|
|
55
|
+
}
|
|
56
|
+
const logPath = getArg("--log");
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
spec: resolve(spec),
|
|
60
|
+
baseUrl: interpolateEnv(baseUrl).replace(/\/+$/, ""),
|
|
61
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
62
|
+
logPath: logPath ? resolve(logPath) : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|