@avidian/mcp-openapi 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.js +815 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Michael Manlupig
|
|
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 deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @avidian/mcp-openapi
|
|
2
|
+
|
|
3
|
+
MCP server for OpenAPI/Swagger — gives AI agents the ability to discover, search, and call any REST API described by an OpenAPI or Swagger document.
|
|
4
|
+
|
|
5
|
+
Point it at a single OpenAPI/Swagger document (URL or local file, 2.0/3.0/3.1) and it exposes:
|
|
6
|
+
|
|
7
|
+
- The full, dereferenced document as a readable MCP resource
|
|
8
|
+
- A keyword search tool for finding the right operation when a spec defines many of them
|
|
9
|
+
- One MCP tool per operation, which proxies a real HTTP request to the underlying API
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
### npm (requires Node.js ≥ 20)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @avidian/mcp-openapi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Compiled binary (no runtime needed)
|
|
20
|
+
|
|
21
|
+
Download from [GitHub Releases](https://github.com/avidianity/mcp-openapi/releases).
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mcp-openapi <openapi-url-or-path> [--base-url <url>] [--timeout <ms>]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
mcp-openapi https://petstore3.swagger.io/api/v3/openapi.json
|
|
31
|
+
mcp-openapi ./openapi.yaml --base-url https://api.internal.example.com --timeout 10000
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The source may also come from `OPENAPI_URL` instead of the positional argument, which is convenient for MCP client configs.
|
|
35
|
+
|
|
36
|
+
### MCP client configuration
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"my-api": {
|
|
42
|
+
"command": "mcp-openapi",
|
|
43
|
+
"args": ["https://api.example.com/openapi.json"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
| Variable | Description |
|
|
52
|
+
| ---------------------------- | ----------------------------------------------------------------------------- |
|
|
53
|
+
| `OPENAPI_URL` | OpenAPI/Swagger source (URL or local path), if not passed as the CLI argument |
|
|
54
|
+
| `OPENAPI_BASE_URL` | Overrides every server URL declared in the spec |
|
|
55
|
+
| `OPENAPI_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds (default `30000`) |
|
|
56
|
+
|
|
57
|
+
### Authentication
|
|
58
|
+
|
|
59
|
+
For each security scheme declared in the OpenAPI document, credentials are read from environment variables named after the scheme (converted to `SCREAMING_SNAKE_CASE`) — e.g. a scheme called `apiKeyAuth` reads `OPENAPI_AUTH_API_KEY_AUTH`.
|
|
60
|
+
|
|
61
|
+
| Scheme type | Environment variables |
|
|
62
|
+
| ------------------------------ | --------------------------------------------------------------------- |
|
|
63
|
+
| HTTP bearer | `OPENAPI_AUTH_<SCHEME>_TOKEN` (or bare `OPENAPI_AUTH_<SCHEME>`) |
|
|
64
|
+
| HTTP basic | `OPENAPI_AUTH_<SCHEME>_USERNAME` and `OPENAPI_AUTH_<SCHEME>_PASSWORD` |
|
|
65
|
+
| apiKey (header, query, cookie) | `OPENAPI_AUTH_<SCHEME>` |
|
|
66
|
+
|
|
67
|
+
Schemes with no matching environment variable, and unsupported types (OAuth2, OpenID Connect), are skipped with a warning rather than failing startup.
|
|
68
|
+
|
|
69
|
+
## What it exposes
|
|
70
|
+
|
|
71
|
+
- **Resource** `openapi://spec` — the full, dereferenced OpenAPI/Swagger document.
|
|
72
|
+
- **Tool** `search_operations` — keyword search across every operation's name, summary, description, tags, and path. Use this first when a spec defines many operations.
|
|
73
|
+
- **One tool per operation** — named after its `operationId` (sanitized and deduplicated), taking the operation's path/query/header parameters and request body as arguments.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Install dependencies
|
|
79
|
+
bun install
|
|
80
|
+
|
|
81
|
+
# Run in dev mode
|
|
82
|
+
bun run dev <openapi-url-or-path>
|
|
83
|
+
|
|
84
|
+
# Type check
|
|
85
|
+
bun run typecheck
|
|
86
|
+
|
|
87
|
+
# Lint
|
|
88
|
+
bun run lint
|
|
89
|
+
|
|
90
|
+
# Format
|
|
91
|
+
bun run format
|
|
92
|
+
|
|
93
|
+
# Test
|
|
94
|
+
bun run test
|
|
95
|
+
|
|
96
|
+
# Build for npm
|
|
97
|
+
bun run build
|
|
98
|
+
|
|
99
|
+
# Compile native binary
|
|
100
|
+
bun run compile
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`ajv` is listed as a direct devDependency even though nothing in `src/` imports it. It's a workaround: `@apidevtools/swagger-parser` depends on `ajv-draft-04`, which only declares `ajv` as a peer dependency, and Bun's bundler fails to statically resolve that peer dependency when producing standalone binaries (`bun run compile`) unless `ajv` is also resolvable as a direct dependency somewhere in the root of the tree. Not needed for `bun run build` (the npm-published bundle), which keeps `@apidevtools/swagger-parser` external and lets Node resolve it normally at install time.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
9
|
+
function resolveConfig(argv, env) {
|
|
10
|
+
const { values, positionals } = parseArgs({
|
|
11
|
+
args: argv,
|
|
12
|
+
allowPositionals: true,
|
|
13
|
+
options: {
|
|
14
|
+
"base-url": { type: "string" },
|
|
15
|
+
timeout: { type: "string" }
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
const source = positionals[0] ?? env.OPENAPI_URL;
|
|
19
|
+
if (!source) {
|
|
20
|
+
throw new Error(`Missing OpenAPI source. Pass it as the first argument or set OPENAPI_URL, e.g.:
|
|
21
|
+
` + " mcp-openapi https://example.com/openapi.json");
|
|
22
|
+
}
|
|
23
|
+
const baseUrlOverride = values["base-url"] ?? env.OPENAPI_BASE_URL;
|
|
24
|
+
const requestTimeoutMs = parseTimeout(values.timeout ?? env.OPENAPI_REQUEST_TIMEOUT_MS);
|
|
25
|
+
return {
|
|
26
|
+
source,
|
|
27
|
+
requestTimeoutMs,
|
|
28
|
+
...baseUrlOverride !== undefined && { baseUrlOverride }
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function parseTimeout(raw) {
|
|
32
|
+
if (raw === undefined)
|
|
33
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
34
|
+
const parsed = Number(raw);
|
|
35
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
36
|
+
throw new Error(`Invalid request timeout "${raw}". Pass a positive number of milliseconds.`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/errors.ts
|
|
42
|
+
function errorMessage(error) {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/openapi/load.ts
|
|
47
|
+
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
48
|
+
async function loadOpenApiDocument(source) {
|
|
49
|
+
try {
|
|
50
|
+
return await SwaggerParser.dereference(source);
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
throw new Error(`Failed to load OpenAPI document from "${source}": ${errorMessage(cause)}`, {
|
|
53
|
+
cause
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function isSwagger2Document(doc) {
|
|
58
|
+
return "swagger" in doc;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/openapi/operations.ts
|
|
62
|
+
var HTTP_METHODS = [
|
|
63
|
+
"get",
|
|
64
|
+
"put",
|
|
65
|
+
"post",
|
|
66
|
+
"delete",
|
|
67
|
+
"options",
|
|
68
|
+
"head",
|
|
69
|
+
"patch",
|
|
70
|
+
"trace"
|
|
71
|
+
];
|
|
72
|
+
function extractOperations(doc, reservedNames = []) {
|
|
73
|
+
const drafts = isSwagger2Document(doc) ? extractSwagger2Operations(doc) : extractOpenApi3Operations(doc);
|
|
74
|
+
return assignToolNames(drafts, reservedNames);
|
|
75
|
+
}
|
|
76
|
+
function extractOpenApi3Operations(doc) {
|
|
77
|
+
const docServers = serverUrls(doc.servers);
|
|
78
|
+
const operations = [];
|
|
79
|
+
for (const [path, pathItem] of Object.entries(doc.paths ?? {})) {
|
|
80
|
+
if (!pathItem)
|
|
81
|
+
continue;
|
|
82
|
+
const pathServers = serverUrls(pathItem.servers);
|
|
83
|
+
const pathParams = pathItem.parameters ?? [];
|
|
84
|
+
for (const method of HTTP_METHODS) {
|
|
85
|
+
const operation = pathItem[method];
|
|
86
|
+
if (!operation)
|
|
87
|
+
continue;
|
|
88
|
+
const opServers = serverUrls(operation.servers);
|
|
89
|
+
const servers = opServers.length > 0 ? opServers : pathServers.length > 0 ? pathServers : docServers;
|
|
90
|
+
const rawParams = mergeParameterObjects(pathParams, operation.parameters ?? []);
|
|
91
|
+
const requestBody = buildRequestBodyV3(operation.requestBody);
|
|
92
|
+
operations.push({
|
|
93
|
+
method,
|
|
94
|
+
path,
|
|
95
|
+
deprecated: operation.deprecated ?? false,
|
|
96
|
+
parameters: buildParameters(rawParams),
|
|
97
|
+
servers,
|
|
98
|
+
tags: operation.tags ?? [],
|
|
99
|
+
...requestBody !== undefined && { requestBody },
|
|
100
|
+
...operation.operationId !== undefined && { operationId: operation.operationId },
|
|
101
|
+
...operation.summary !== undefined && { summary: operation.summary },
|
|
102
|
+
...operation.description !== undefined && { description: operation.description }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return operations;
|
|
107
|
+
}
|
|
108
|
+
function mergeParameterObjects(pathParams, opParams) {
|
|
109
|
+
const byKey = new Map;
|
|
110
|
+
for (const param of [...pathParams, ...opParams]) {
|
|
111
|
+
byKey.set(`${param.in}:${param.name}`, param);
|
|
112
|
+
}
|
|
113
|
+
return [...byKey.values()];
|
|
114
|
+
}
|
|
115
|
+
function buildParameters(raw) {
|
|
116
|
+
const used = new Set;
|
|
117
|
+
return raw.map((param) => {
|
|
118
|
+
const location = param.in;
|
|
119
|
+
const argName = used.has(param.name) ? `${location}_${param.name}` : param.name;
|
|
120
|
+
used.add(argName);
|
|
121
|
+
return {
|
|
122
|
+
name: param.name,
|
|
123
|
+
in: location,
|
|
124
|
+
required: param.required ?? location === "path",
|
|
125
|
+
schema: param.schema ?? {},
|
|
126
|
+
argName,
|
|
127
|
+
...param.description !== undefined && { description: param.description }
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function buildRequestBodyV3(requestBody) {
|
|
132
|
+
if (!requestBody)
|
|
133
|
+
return;
|
|
134
|
+
const content = requestBody.content;
|
|
135
|
+
const jsonMedia = content["application/json"] ?? Object.entries(content).find(([type]) => /\bjson\b/i.test(type))?.[1];
|
|
136
|
+
if (jsonMedia) {
|
|
137
|
+
return {
|
|
138
|
+
required: requestBody.required ?? false,
|
|
139
|
+
schema: jsonMedia.schema ?? {},
|
|
140
|
+
encoding: "json",
|
|
141
|
+
...requestBody.description !== undefined && { description: requestBody.description }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const multipart = content["multipart/form-data"];
|
|
145
|
+
if (multipart) {
|
|
146
|
+
return {
|
|
147
|
+
required: requestBody.required ?? false,
|
|
148
|
+
schema: multipart.schema ?? {},
|
|
149
|
+
encoding: "multipart",
|
|
150
|
+
...requestBody.description !== undefined && { description: requestBody.description }
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const formUrlEncoded = content["application/x-www-form-urlencoded"];
|
|
154
|
+
if (formUrlEncoded) {
|
|
155
|
+
return {
|
|
156
|
+
required: requestBody.required ?? false,
|
|
157
|
+
schema: formUrlEncoded.schema ?? {},
|
|
158
|
+
encoding: "form-urlencoded",
|
|
159
|
+
...requestBody.description !== undefined && { description: requestBody.description }
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
function serverUrls(servers) {
|
|
165
|
+
if (!servers)
|
|
166
|
+
return [];
|
|
167
|
+
return servers.map((server) => substituteServerVariables(server.url, server.variables));
|
|
168
|
+
}
|
|
169
|
+
function substituteServerVariables(url, variables) {
|
|
170
|
+
if (!variables)
|
|
171
|
+
return url;
|
|
172
|
+
return url.replace(/\{(\w+)\}/g, (match, name) => variables[name]?.default ?? match);
|
|
173
|
+
}
|
|
174
|
+
function extractSwagger2Operations(doc) {
|
|
175
|
+
const servers = swagger2ServerUrls(doc);
|
|
176
|
+
const operations = [];
|
|
177
|
+
for (const [path, pathItem] of Object.entries(doc.paths)) {
|
|
178
|
+
const pathParams = pathItem.parameters ?? [];
|
|
179
|
+
for (const method of HTTP_METHODS) {
|
|
180
|
+
if (method === "trace")
|
|
181
|
+
continue;
|
|
182
|
+
const operation = pathItem[method];
|
|
183
|
+
if (!operation)
|
|
184
|
+
continue;
|
|
185
|
+
const rawParams = mergeSwagger2Parameters(pathParams, operation.parameters ?? []);
|
|
186
|
+
const consumesMultipart = (operation.consumes ?? doc.consumes ?? []).includes("multipart/form-data");
|
|
187
|
+
const requestBody = buildRequestBodySwagger2(rawParams, consumesMultipart);
|
|
188
|
+
operations.push({
|
|
189
|
+
method,
|
|
190
|
+
path,
|
|
191
|
+
deprecated: operation.deprecated ?? false,
|
|
192
|
+
parameters: buildSwagger2Parameters(rawParams),
|
|
193
|
+
servers,
|
|
194
|
+
tags: operation.tags ?? [],
|
|
195
|
+
...requestBody !== undefined && { requestBody },
|
|
196
|
+
...operation.operationId !== undefined && { operationId: operation.operationId },
|
|
197
|
+
...operation.summary !== undefined && { summary: operation.summary },
|
|
198
|
+
...operation.description !== undefined && { description: operation.description }
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return operations;
|
|
203
|
+
}
|
|
204
|
+
function mergeSwagger2Parameters(pathParams, opParams) {
|
|
205
|
+
const byKey = new Map;
|
|
206
|
+
for (const param of [...pathParams, ...opParams]) {
|
|
207
|
+
byKey.set(`${param.in}:${param.name}`, param);
|
|
208
|
+
}
|
|
209
|
+
return [...byKey.values()];
|
|
210
|
+
}
|
|
211
|
+
function buildSwagger2Parameters(raw) {
|
|
212
|
+
const used = new Set;
|
|
213
|
+
const result = [];
|
|
214
|
+
for (const param of raw) {
|
|
215
|
+
if (param.in === "body" || param.in === "formData")
|
|
216
|
+
continue;
|
|
217
|
+
const location = param.in;
|
|
218
|
+
const argName = used.has(param.name) ? `${location}_${param.name}` : param.name;
|
|
219
|
+
used.add(argName);
|
|
220
|
+
result.push({
|
|
221
|
+
name: param.name,
|
|
222
|
+
in: location,
|
|
223
|
+
required: param.required ?? location === "path",
|
|
224
|
+
schema: itemsObjectToJsonSchema(param),
|
|
225
|
+
argName,
|
|
226
|
+
...param.description !== undefined && { description: param.description }
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
function itemsObjectToJsonSchema(param) {
|
|
232
|
+
const { name: _name, in: _in, description: _description, required: _required, ...rest } = param;
|
|
233
|
+
return rest;
|
|
234
|
+
}
|
|
235
|
+
function buildRequestBodySwagger2(raw, consumesMultipart) {
|
|
236
|
+
const bodyParam = raw.find((param) => param.in === "body");
|
|
237
|
+
if (bodyParam) {
|
|
238
|
+
return {
|
|
239
|
+
required: bodyParam.required ?? false,
|
|
240
|
+
schema: bodyParam.schema,
|
|
241
|
+
encoding: "json",
|
|
242
|
+
...bodyParam.description !== undefined && { description: bodyParam.description }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const formParams = raw.filter((param) => param.in === "formData");
|
|
246
|
+
if (formParams.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
const properties = {};
|
|
249
|
+
const required = [];
|
|
250
|
+
for (const param of formParams) {
|
|
251
|
+
properties[param.name] = itemsObjectToJsonSchema(param);
|
|
252
|
+
if (param.required)
|
|
253
|
+
required.push(param.name);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
required: required.length > 0,
|
|
257
|
+
schema: { type: "object", properties, ...required.length > 0 && { required } },
|
|
258
|
+
encoding: consumesMultipart ? "multipart" : "form-urlencoded"
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function swagger2ServerUrls(doc) {
|
|
262
|
+
const schemes = doc.schemes && doc.schemes.length > 0 ? doc.schemes : ["https"];
|
|
263
|
+
const host = doc.host ?? "localhost";
|
|
264
|
+
const basePath = doc.basePath ?? "";
|
|
265
|
+
return schemes.map((scheme) => `${scheme}://${host}${basePath}`);
|
|
266
|
+
}
|
|
267
|
+
function sanitizeToolName(raw) {
|
|
268
|
+
const cleaned = raw.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
|
269
|
+
const truncated = cleaned.slice(0, 128);
|
|
270
|
+
return truncated.length > 0 ? truncated : "operation";
|
|
271
|
+
}
|
|
272
|
+
function assignToolNames(drafts, reservedNames) {
|
|
273
|
+
const used = new Set(reservedNames);
|
|
274
|
+
return drafts.map((draft) => {
|
|
275
|
+
const base = sanitizeToolName(draft.operationId ?? `${draft.method}_${draft.path}`);
|
|
276
|
+
let name = base;
|
|
277
|
+
let counter = 2;
|
|
278
|
+
while (used.has(name)) {
|
|
279
|
+
name = `${base}_${counter}`;
|
|
280
|
+
counter += 1;
|
|
281
|
+
}
|
|
282
|
+
used.add(name);
|
|
283
|
+
return { ...draft, toolName: name };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/openapi/search.ts
|
|
288
|
+
import MiniSearch from "minisearch";
|
|
289
|
+
var SEARCH_TOOL_NAME = "search_operations";
|
|
290
|
+
function buildSearchIndex(operations) {
|
|
291
|
+
const index = new MiniSearch({
|
|
292
|
+
idField: "id",
|
|
293
|
+
fields: ["toolName", "summary", "description", "tags", "path"],
|
|
294
|
+
storeFields: ["toolName", "method", "path", "summary", "deprecated"],
|
|
295
|
+
searchOptions: {
|
|
296
|
+
prefix: true,
|
|
297
|
+
fuzzy: 0.2,
|
|
298
|
+
boost: { toolName: 3, summary: 2, tags: 1.5 }
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
index.addAll(operations.map((operation) => ({
|
|
302
|
+
id: operation.toolName,
|
|
303
|
+
toolName: operation.toolName,
|
|
304
|
+
method: operation.method,
|
|
305
|
+
path: operation.path,
|
|
306
|
+
summary: operation.summary ?? "",
|
|
307
|
+
description: operation.description ?? "",
|
|
308
|
+
tags: operation.tags.join(" "),
|
|
309
|
+
deprecated: operation.deprecated
|
|
310
|
+
})));
|
|
311
|
+
return index;
|
|
312
|
+
}
|
|
313
|
+
function searchOperations(index, query, limit) {
|
|
314
|
+
return index.search(query).slice(0, limit).map(toSearchMatch);
|
|
315
|
+
}
|
|
316
|
+
function toSearchMatch(result) {
|
|
317
|
+
const summary = typeof result.summary === "string" && result.summary.length > 0 ? result.summary : undefined;
|
|
318
|
+
return {
|
|
319
|
+
toolName: String(result.toolName),
|
|
320
|
+
method: String(result.method),
|
|
321
|
+
path: String(result.path),
|
|
322
|
+
deprecated: result.deprecated === true,
|
|
323
|
+
score: result.score,
|
|
324
|
+
...summary !== undefined && { summary }
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/server.ts
|
|
329
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
330
|
+
import { z as z2 } from "zod/v4";
|
|
331
|
+
|
|
332
|
+
// src/openapi/auth.ts
|
|
333
|
+
function resolveAuth(doc, env) {
|
|
334
|
+
const schemes = isSwagger2Document(doc) ? swagger2SecuritySchemes(doc) : openApi3SecuritySchemes(doc);
|
|
335
|
+
const resolved = [];
|
|
336
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
337
|
+
const credential = resolveSchemeCredential(name, scheme, env);
|
|
338
|
+
if (credential)
|
|
339
|
+
resolved.push(credential);
|
|
340
|
+
}
|
|
341
|
+
return resolved;
|
|
342
|
+
}
|
|
343
|
+
function applyAuth(auth, target) {
|
|
344
|
+
for (const entry of auth) {
|
|
345
|
+
switch (entry.kind) {
|
|
346
|
+
case "bearer":
|
|
347
|
+
target.headers.set("Authorization", `Bearer ${entry.token}`);
|
|
348
|
+
break;
|
|
349
|
+
case "basic":
|
|
350
|
+
target.headers.set("Authorization", `Basic ${btoa(`${entry.username}:${entry.password}`)}`);
|
|
351
|
+
break;
|
|
352
|
+
case "apiKey":
|
|
353
|
+
applyApiKey(entry, target);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function applyApiKey(entry, target) {
|
|
359
|
+
if (entry.in === "query") {
|
|
360
|
+
target.url.searchParams.set(entry.name, entry.value);
|
|
361
|
+
} else if (entry.in === "cookie") {
|
|
362
|
+
target.cookies.set(entry.name, entry.value);
|
|
363
|
+
} else {
|
|
364
|
+
target.headers.set(entry.name, entry.value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function resolveSchemeCredential(name, scheme, env) {
|
|
368
|
+
const base = envVarName(name);
|
|
369
|
+
switch (scheme.kind) {
|
|
370
|
+
case "bearer": {
|
|
371
|
+
const token = env[`${base}_TOKEN`] ?? env[base];
|
|
372
|
+
return token !== undefined ? { kind: "bearer", token } : undefined;
|
|
373
|
+
}
|
|
374
|
+
case "basic": {
|
|
375
|
+
const username = env[`${base}_USERNAME`];
|
|
376
|
+
const password = env[`${base}_PASSWORD`];
|
|
377
|
+
return username !== undefined && password !== undefined ? { kind: "basic", username, password } : undefined;
|
|
378
|
+
}
|
|
379
|
+
case "apiKey": {
|
|
380
|
+
const value = env[base];
|
|
381
|
+
return value !== undefined ? { kind: "apiKey", in: scheme.in, name: scheme.name, value } : undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function envVarName(schemeName) {
|
|
386
|
+
const upper = schemeName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase();
|
|
387
|
+
return `OPENAPI_AUTH_${upper}`;
|
|
388
|
+
}
|
|
389
|
+
function openApi3SecuritySchemes(doc) {
|
|
390
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
391
|
+
const normalized = {};
|
|
392
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
393
|
+
if (scheme.type === "http" && scheme.scheme.toLowerCase() === "bearer") {
|
|
394
|
+
normalized[name] = { kind: "bearer" };
|
|
395
|
+
} else if (scheme.type === "http" && scheme.scheme.toLowerCase() === "basic") {
|
|
396
|
+
normalized[name] = { kind: "basic" };
|
|
397
|
+
} else if (scheme.type === "apiKey") {
|
|
398
|
+
normalized[name] = { kind: "apiKey", in: scheme.in, name: scheme.name };
|
|
399
|
+
} else {
|
|
400
|
+
console.warn(`mcp-openapi: security scheme "${name}" has unsupported type "${scheme.type}" and will be ignored.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return normalized;
|
|
404
|
+
}
|
|
405
|
+
function swagger2SecuritySchemes(doc) {
|
|
406
|
+
const schemes = doc.securityDefinitions ?? {};
|
|
407
|
+
const normalized = {};
|
|
408
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
409
|
+
if (scheme.type === "basic") {
|
|
410
|
+
normalized[name] = { kind: "basic" };
|
|
411
|
+
} else if (scheme.type === "apiKey") {
|
|
412
|
+
normalized[name] = { kind: "apiKey", in: scheme.in, name: scheme.name };
|
|
413
|
+
} else {
|
|
414
|
+
console.warn(`mcp-openapi: security scheme "${name}" has unsupported type "${scheme.type}" and will be ignored.`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return normalized;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/openapi/request.ts
|
|
421
|
+
var MAX_RESPONSE_BODY_BYTES = 1e5;
|
|
422
|
+
async function executeOperation(options) {
|
|
423
|
+
const { operation, args, auth, requestTimeoutMs } = options;
|
|
424
|
+
const baseUrl = resolveBaseUrl(operation, options.baseUrlOverride);
|
|
425
|
+
const target = {
|
|
426
|
+
url: buildUrl(operation, args, baseUrl),
|
|
427
|
+
headers: new Headers,
|
|
428
|
+
cookies: new Map
|
|
429
|
+
};
|
|
430
|
+
applyAuth(auth, target);
|
|
431
|
+
applyHeaderParams(operation, args, target.headers);
|
|
432
|
+
const body = buildBody(operation, args, target.headers);
|
|
433
|
+
if (target.cookies.size > 0) {
|
|
434
|
+
target.headers.set("Cookie", [...target.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
435
|
+
}
|
|
436
|
+
const response = await performFetch(target.url, operation, target.headers, body, requestTimeoutMs);
|
|
437
|
+
const { bytes, truncated } = await readBoundedBody(response, MAX_RESPONSE_BODY_BYTES);
|
|
438
|
+
return {
|
|
439
|
+
ok: response.ok,
|
|
440
|
+
status: response.status,
|
|
441
|
+
statusText: response.statusText,
|
|
442
|
+
truncated,
|
|
443
|
+
body: decodeBody(bytes, truncated, response.headers.get("content-type"))
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
async function performFetch(url, operation, headers, body, requestTimeoutMs) {
|
|
447
|
+
try {
|
|
448
|
+
return await fetch(url, {
|
|
449
|
+
method: operation.method.toUpperCase(),
|
|
450
|
+
headers,
|
|
451
|
+
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
452
|
+
...body !== undefined && { body }
|
|
453
|
+
});
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
456
|
+
throw new Error(`Request to ${url.toString()} timed out after ${requestTimeoutMs}ms.`, {
|
|
457
|
+
cause: error
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async function readBoundedBody(response, maxBytes) {
|
|
464
|
+
const stream = response.body;
|
|
465
|
+
if (!stream)
|
|
466
|
+
return { bytes: new Uint8Array(0), truncated: false };
|
|
467
|
+
const reader = stream.getReader();
|
|
468
|
+
const chunks = [];
|
|
469
|
+
let total = 0;
|
|
470
|
+
let truncated = false;
|
|
471
|
+
try {
|
|
472
|
+
for (;; ) {
|
|
473
|
+
const { done, value } = await reader.read();
|
|
474
|
+
if (done)
|
|
475
|
+
break;
|
|
476
|
+
const remaining = maxBytes - total;
|
|
477
|
+
const chunk = value.length > remaining ? value.subarray(0, remaining) : value;
|
|
478
|
+
chunks.push(chunk);
|
|
479
|
+
total += chunk.length;
|
|
480
|
+
if (chunk.length < value.length) {
|
|
481
|
+
truncated = true;
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} finally {
|
|
486
|
+
await reader.cancel().catch(() => {
|
|
487
|
+
return;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
const bytes = new Uint8Array(total);
|
|
491
|
+
let offset = 0;
|
|
492
|
+
for (const chunk of chunks) {
|
|
493
|
+
bytes.set(chunk, offset);
|
|
494
|
+
offset += chunk.length;
|
|
495
|
+
}
|
|
496
|
+
return { bytes, truncated };
|
|
497
|
+
}
|
|
498
|
+
function isTextContentType(contentType) {
|
|
499
|
+
if (!contentType)
|
|
500
|
+
return true;
|
|
501
|
+
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
502
|
+
return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/x-www-form-urlencoded" || type.endsWith("+json") || type.endsWith("+xml");
|
|
503
|
+
}
|
|
504
|
+
function decodeBody(bytes, truncated, contentType) {
|
|
505
|
+
if (bytes.length === 0)
|
|
506
|
+
return "";
|
|
507
|
+
if (isTextContentType(contentType)) {
|
|
508
|
+
const text = new TextDecoder().decode(bytes);
|
|
509
|
+
return truncated ? `${text}
|
|
510
|
+
...[response truncated, exceeded the ${MAX_RESPONSE_BODY_BYTES} byte cap]` : text;
|
|
511
|
+
}
|
|
512
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
513
|
+
const label = contentType ?? "application/octet-stream";
|
|
514
|
+
return `[base64-encoded ${label} response, ${bytes.length} bytes${truncated ? ", truncated" : ""}]
|
|
515
|
+
${base64}`;
|
|
516
|
+
}
|
|
517
|
+
function resolveBaseUrl(operation, baseUrlOverride) {
|
|
518
|
+
const base = baseUrlOverride ?? operation.servers[0];
|
|
519
|
+
if (base === undefined) {
|
|
520
|
+
throw new Error(`No server URL is available for operation "${operation.toolName}". Set OPENAPI_BASE_URL or pass --base-url.`);
|
|
521
|
+
}
|
|
522
|
+
return base;
|
|
523
|
+
}
|
|
524
|
+
function buildUrl(operation, args, baseUrl) {
|
|
525
|
+
let path = operation.path;
|
|
526
|
+
for (const param of operation.parameters) {
|
|
527
|
+
if (param.in !== "path")
|
|
528
|
+
continue;
|
|
529
|
+
path = path.replace(`{${param.name}}`, encodeURIComponent(serializeSimpleValue(args[param.argName])));
|
|
530
|
+
}
|
|
531
|
+
const url = new URL(trimTrailingSlash(baseUrl) + path);
|
|
532
|
+
for (const param of operation.parameters) {
|
|
533
|
+
if (param.in !== "query")
|
|
534
|
+
continue;
|
|
535
|
+
const value = args[param.argName];
|
|
536
|
+
if (value !== undefined)
|
|
537
|
+
appendQueryParam(url.searchParams, param.name, value);
|
|
538
|
+
}
|
|
539
|
+
return url;
|
|
540
|
+
}
|
|
541
|
+
function trimTrailingSlash(url) {
|
|
542
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
543
|
+
}
|
|
544
|
+
function appendQueryParam(searchParams, name, value) {
|
|
545
|
+
if (Array.isArray(value)) {
|
|
546
|
+
for (const item of value)
|
|
547
|
+
searchParams.append(name, stringifyPrimitive(item));
|
|
548
|
+
} else if (value !== null && typeof value === "object") {
|
|
549
|
+
searchParams.append(name, JSON.stringify(value));
|
|
550
|
+
} else {
|
|
551
|
+
searchParams.append(name, stringifyPrimitive(value));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function serializeSimpleValue(value) {
|
|
555
|
+
if (value === undefined || value === null)
|
|
556
|
+
return "";
|
|
557
|
+
if (Array.isArray(value))
|
|
558
|
+
return value.map((item) => stringifyPrimitive(item)).join(",");
|
|
559
|
+
if (typeof value === "object") {
|
|
560
|
+
return Object.entries(value).map(([key, item]) => `${key},${stringifyPrimitive(item)}`).join(",");
|
|
561
|
+
}
|
|
562
|
+
return stringifyPrimitive(value);
|
|
563
|
+
}
|
|
564
|
+
function stringifyPrimitive(value) {
|
|
565
|
+
if (typeof value === "string")
|
|
566
|
+
return value;
|
|
567
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
568
|
+
return String(value);
|
|
569
|
+
if (value === null || value === undefined)
|
|
570
|
+
return "";
|
|
571
|
+
return JSON.stringify(value);
|
|
572
|
+
}
|
|
573
|
+
function applyHeaderParams(operation, args, headers) {
|
|
574
|
+
for (const param of operation.parameters) {
|
|
575
|
+
if (param.in !== "header")
|
|
576
|
+
continue;
|
|
577
|
+
const value = args[param.argName];
|
|
578
|
+
if (value !== undefined)
|
|
579
|
+
headers.set(param.name, serializeSimpleValue(value));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function buildBody(operation, args, headers) {
|
|
583
|
+
const requestBody = operation.requestBody;
|
|
584
|
+
const value = args.requestBody;
|
|
585
|
+
if (!requestBody || value === undefined)
|
|
586
|
+
return;
|
|
587
|
+
switch (requestBody.encoding) {
|
|
588
|
+
case "json":
|
|
589
|
+
headers.set("Content-Type", "application/json");
|
|
590
|
+
return JSON.stringify(value);
|
|
591
|
+
case "form-urlencoded":
|
|
592
|
+
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
|
593
|
+
return new URLSearchParams(flattenToStrings(value)).toString();
|
|
594
|
+
case "multipart":
|
|
595
|
+
return buildFormData(value, requestBody.schema);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function flattenToStrings(value) {
|
|
599
|
+
if (value === null || typeof value !== "object")
|
|
600
|
+
return {};
|
|
601
|
+
const result = {};
|
|
602
|
+
for (const [key, item] of Object.entries(value)) {
|
|
603
|
+
result[key] = stringifyPrimitive(item);
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
function buildFormData(value, schema) {
|
|
608
|
+
const form = new FormData;
|
|
609
|
+
if (value === null || typeof value !== "object")
|
|
610
|
+
return form;
|
|
611
|
+
const properties = schema.properties ?? {};
|
|
612
|
+
for (const [key, item] of Object.entries(value)) {
|
|
613
|
+
const format = properties[key]?.format;
|
|
614
|
+
if ((format === "binary" || format === "byte") && typeof item === "string") {
|
|
615
|
+
form.append(key, base64ToBlob(item));
|
|
616
|
+
} else if (Array.isArray(item)) {
|
|
617
|
+
for (const entry of item)
|
|
618
|
+
form.append(key, stringifyPrimitive(entry));
|
|
619
|
+
} else {
|
|
620
|
+
form.append(key, stringifyPrimitive(item));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return form;
|
|
624
|
+
}
|
|
625
|
+
function base64ToBlob(base64) {
|
|
626
|
+
const binary = atob(base64);
|
|
627
|
+
const bytes = new Uint8Array(binary.length);
|
|
628
|
+
for (let i = 0;i < binary.length; i += 1)
|
|
629
|
+
bytes[i] = binary.charCodeAt(i);
|
|
630
|
+
return new Blob([bytes]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/openapi/schema.ts
|
|
634
|
+
import { z } from "zod/v4";
|
|
635
|
+
import { convertJsonSchemaToZod } from "zod-from-json-schema";
|
|
636
|
+
function normalizeJsonSchema(schema) {
|
|
637
|
+
return normalizeNode(schema);
|
|
638
|
+
}
|
|
639
|
+
function normalizeNode(node) {
|
|
640
|
+
if (Array.isArray(node)) {
|
|
641
|
+
return node.map((item) => normalizeNode(item));
|
|
642
|
+
}
|
|
643
|
+
if (node === null || typeof node !== "object") {
|
|
644
|
+
return node;
|
|
645
|
+
}
|
|
646
|
+
const source = node;
|
|
647
|
+
const normalized = {};
|
|
648
|
+
for (const [key, value] of Object.entries(source)) {
|
|
649
|
+
normalized[key] = normalizeNode(value);
|
|
650
|
+
}
|
|
651
|
+
const nullable = normalized.nullable === true || normalized["x-nullable"] === true;
|
|
652
|
+
delete normalized.nullable;
|
|
653
|
+
delete normalized["x-nullable"];
|
|
654
|
+
if (nullable) {
|
|
655
|
+
const { type } = normalized;
|
|
656
|
+
if (typeof type === "string") {
|
|
657
|
+
normalized.type = [type, "null"];
|
|
658
|
+
} else if (Array.isArray(type)) {
|
|
659
|
+
const types = type;
|
|
660
|
+
normalized.type = types.includes("null") ? types : [...types, "null"];
|
|
661
|
+
} else if (type === undefined) {
|
|
662
|
+
normalized.type = ["null"];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return normalized;
|
|
666
|
+
}
|
|
667
|
+
function toZodType(schema, options) {
|
|
668
|
+
const base = schema ? convertJsonSchemaToZod(normalizeJsonSchema(schema)) : z.unknown();
|
|
669
|
+
const described = options.description !== undefined ? base.describe(options.description) : base;
|
|
670
|
+
return options.required ? described : described.optional();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/server.ts
|
|
674
|
+
function buildServer(doc, operations, config, env) {
|
|
675
|
+
const server = new McpServer({ name: "mcp-openapi", version: "0.1.0", title: specTitle(doc) });
|
|
676
|
+
registerSpecResource(server, doc);
|
|
677
|
+
if (operations.length > 0)
|
|
678
|
+
registerSearchTool(server, operations);
|
|
679
|
+
const auth = resolveAuth(doc, env);
|
|
680
|
+
for (const operation of operations) {
|
|
681
|
+
registerOperationTool(server, operation, auth, config);
|
|
682
|
+
}
|
|
683
|
+
return server;
|
|
684
|
+
}
|
|
685
|
+
function specTitle(doc) {
|
|
686
|
+
return doc.info.title;
|
|
687
|
+
}
|
|
688
|
+
function registerSpecResource(server, doc) {
|
|
689
|
+
server.registerResource("openapi-spec", "openapi://spec", {
|
|
690
|
+
title: "OpenAPI document",
|
|
691
|
+
description: "The full, dereferenced OpenAPI/Swagger document this server exposes tools for.",
|
|
692
|
+
mimeType: "application/json"
|
|
693
|
+
}, (uri) => ({
|
|
694
|
+
contents: [
|
|
695
|
+
{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(doc, null, 2) }
|
|
696
|
+
]
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
function registerSearchTool(server, operations) {
|
|
700
|
+
const index = buildSearchIndex(operations);
|
|
701
|
+
server.registerTool(SEARCH_TOOL_NAME, {
|
|
702
|
+
title: "Search API operations",
|
|
703
|
+
description: "Search the available API operations by keyword (matches operation names, summaries, descriptions, tags, and paths). " + "Use this to find the right tool when there are many operations, then call that tool directly by name.",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
query: z2.string().min(1).describe('Keywords to search for, e.g. "create pet" or "delete order".'),
|
|
706
|
+
limit: z2.number().int().positive().max(50).optional().describe("Maximum number of results to return (default 10).")
|
|
707
|
+
},
|
|
708
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
|
|
709
|
+
}, (args) => {
|
|
710
|
+
const matches = searchOperations(index, args.query, args.limit ?? 10);
|
|
711
|
+
return { content: [{ type: "text", text: formatSearchResults(args.query, matches) }] };
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function formatSearchResults(query, matches) {
|
|
715
|
+
if (matches.length === 0)
|
|
716
|
+
return `No operations matched "${query}".`;
|
|
717
|
+
const lines = matches.map((match, index) => {
|
|
718
|
+
const flags = match.deprecated ? " [deprecated]" : "";
|
|
719
|
+
const summary = match.summary ? `
|
|
720
|
+
${match.summary}` : "";
|
|
721
|
+
return `${index + 1}. ${match.toolName} (${match.method.toUpperCase()} ${match.path})${flags}${summary}`;
|
|
722
|
+
});
|
|
723
|
+
return `Found ${matches.length} matching operation(s) for "${query}":
|
|
724
|
+
|
|
725
|
+
${lines.join(`
|
|
726
|
+
`)}`;
|
|
727
|
+
}
|
|
728
|
+
function registerOperationTool(server, operation, auth, config) {
|
|
729
|
+
server.registerTool(operation.toolName, {
|
|
730
|
+
title: operation.summary ?? operation.toolName,
|
|
731
|
+
description: toolDescription(operation),
|
|
732
|
+
inputSchema: buildInputShape(operation),
|
|
733
|
+
annotations: {
|
|
734
|
+
readOnlyHint: operation.method === "get" || operation.method === "head",
|
|
735
|
+
destructiveHint: operation.method === "delete",
|
|
736
|
+
idempotentHint: ["get", "put", "delete", "head"].includes(operation.method),
|
|
737
|
+
openWorldHint: true
|
|
738
|
+
}
|
|
739
|
+
}, async (args) => {
|
|
740
|
+
try {
|
|
741
|
+
const result = await executeOperation({
|
|
742
|
+
operation,
|
|
743
|
+
args,
|
|
744
|
+
auth,
|
|
745
|
+
baseUrlOverride: config.baseUrlOverride,
|
|
746
|
+
requestTimeoutMs: config.requestTimeoutMs
|
|
747
|
+
});
|
|
748
|
+
return { isError: !result.ok, content: [{ type: "text", text: formatResult(result) }] };
|
|
749
|
+
} catch (error) {
|
|
750
|
+
return {
|
|
751
|
+
isError: true,
|
|
752
|
+
content: [{ type: "text", text: `Request failed: ${errorMessage(error)}` }]
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function buildInputShape(operation) {
|
|
758
|
+
const shape = {};
|
|
759
|
+
for (const param of operation.parameters) {
|
|
760
|
+
shape[param.argName] = toZodType(param.schema, {
|
|
761
|
+
required: param.required,
|
|
762
|
+
...param.description !== undefined && { description: param.description }
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
if (operation.requestBody) {
|
|
766
|
+
shape.requestBody = toZodType(operation.requestBody.schema, {
|
|
767
|
+
required: operation.requestBody.required,
|
|
768
|
+
...operation.requestBody.description !== undefined && {
|
|
769
|
+
description: operation.requestBody.description
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
return shape;
|
|
774
|
+
}
|
|
775
|
+
function toolDescription(operation) {
|
|
776
|
+
const summary = operation.description ?? operation.summary ?? `${operation.method.toUpperCase()} ${operation.path}`;
|
|
777
|
+
const parts = [summary, `HTTP ${operation.method.toUpperCase()} ${operation.path}`];
|
|
778
|
+
if (operation.deprecated)
|
|
779
|
+
parts.push("This operation is marked deprecated in the OpenAPI document.");
|
|
780
|
+
return parts.join(`
|
|
781
|
+
|
|
782
|
+
`);
|
|
783
|
+
}
|
|
784
|
+
function formatResult(result) {
|
|
785
|
+
const header = `HTTP ${result.status} ${result.statusText}`;
|
|
786
|
+
return result.body.length > 0 ? `${header}
|
|
787
|
+
|
|
788
|
+
${result.body}` : header;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/index.ts
|
|
792
|
+
var LARGE_TOOL_COUNT_WARNING_THRESHOLD = 200;
|
|
793
|
+
async function main() {
|
|
794
|
+
const config = resolveConfig(process.argv.slice(2), process.env);
|
|
795
|
+
const doc = await loadOpenApiDocument(config.source);
|
|
796
|
+
const operations = extractOperations(doc, [SEARCH_TOOL_NAME]);
|
|
797
|
+
if (operations.length === 0) {
|
|
798
|
+
console.warn(`mcp-openapi: no operations were found in "${config.source}".`);
|
|
799
|
+
} else if (operations.length > LARGE_TOOL_COUNT_WARNING_THRESHOLD) {
|
|
800
|
+
console.warn(`mcp-openapi: "${config.source}" defines ${operations.length} operations, which will register that many tools and may overwhelm the connected agent.`);
|
|
801
|
+
}
|
|
802
|
+
const server = buildServer(doc, operations, config, process.env);
|
|
803
|
+
const transport = new StdioServerTransport;
|
|
804
|
+
await server.connect(transport);
|
|
805
|
+
}
|
|
806
|
+
var isMainModule = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
|
|
807
|
+
if (isMainModule) {
|
|
808
|
+
main().catch((error) => {
|
|
809
|
+
console.error(`mcp-openapi: ${errorMessage(error)}`);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
export {
|
|
814
|
+
main
|
|
815
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avidian/mcp-openapi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for OpenAPI/Swagger — gives AI agents the ability to discover, search, and call any REST API described by an OpenAPI document.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-openapi": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"imports": {
|
|
11
|
+
"#*": "./src/*"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "bun --watch src/index.ts",
|
|
18
|
+
"start": "bun run dist/index.js",
|
|
19
|
+
"build": "bun scripts/build.ts",
|
|
20
|
+
"compile": "bun build src/index.ts --compile --outfile dist/mcp-openapi",
|
|
21
|
+
"compile:linux-x64": "bun build src/index.ts --compile --target=bun-linux-x64 --outfile dist/mcp-openapi-linux-x64",
|
|
22
|
+
"compile:macos-arm64": "bun build src/index.ts --compile --target=bun-darwin-arm64 --outfile dist/mcp-openapi-macos-arm64",
|
|
23
|
+
"compile:macos-x64": "bun build src/index.ts --compile --target=bun-darwin-x64 --outfile dist/mcp-openapi-macos-x64",
|
|
24
|
+
"compile:all": "bun run compile:linux-x64 && bun run compile:macos-arm64 && bun run compile:macos-x64",
|
|
25
|
+
"typecheck": "tsc -b",
|
|
26
|
+
"test": "bun test",
|
|
27
|
+
"lint": "eslint .",
|
|
28
|
+
"lint:fix": "eslint . --fix",
|
|
29
|
+
"format": "prettier --write .",
|
|
30
|
+
"format:check": "prettier --check .",
|
|
31
|
+
"check": "bun run typecheck && bun run lint && bun run format:check && bun run test",
|
|
32
|
+
"prepublishOnly": "bun run build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^10.0.1",
|
|
36
|
+
"@types/bun": "^1.3.14",
|
|
37
|
+
"@types/node": "^26.0.1",
|
|
38
|
+
"ajv": "^8.17.1",
|
|
39
|
+
"eslint": "^10.6.0",
|
|
40
|
+
"eslint-config-prettier": "^10.1.8",
|
|
41
|
+
"globals": "^17.7.0",
|
|
42
|
+
"openapi-types": "^12.1.3",
|
|
43
|
+
"prettier": "^3.9.4",
|
|
44
|
+
"typescript": "^6.0.3",
|
|
45
|
+
"typescript-eslint": "^8.62.1"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@apidevtools/swagger-parser": "^12.1.0",
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
+
"minisearch": "^7.2.0",
|
|
51
|
+
"zod": "^4.4.3",
|
|
52
|
+
"zod-from-json-schema": "^0.5.3"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"keywords": [
|
|
58
|
+
"mcp",
|
|
59
|
+
"openapi",
|
|
60
|
+
"swagger",
|
|
61
|
+
"api",
|
|
62
|
+
"rest",
|
|
63
|
+
"ai",
|
|
64
|
+
"model-context-protocol",
|
|
65
|
+
"agent"
|
|
66
|
+
],
|
|
67
|
+
"license": "MIT",
|
|
68
|
+
"repository": {
|
|
69
|
+
"type": "git",
|
|
70
|
+
"url": "git+https://github.com/avidianity/mcp-openapi.git"
|
|
71
|
+
},
|
|
72
|
+
"author": "avidian",
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
}
|
|
76
|
+
}
|