api2ai 1.0.14 → 1.0.15
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 +39 -59
- package/package.json +23 -237
- package/src/generate-mcp-use-server.js +1037 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenAPI to MCP Server Generator (mcp-use framework)
|
|
5
|
+
*
|
|
6
|
+
* Generates a complete MCP server using the mcp-use framework from any OpenAPI spec.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx api2ai <openapi-spec> [output-folder] [options]
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* npx api2ai ./petstore.json ./my-mcp-server
|
|
13
|
+
* npx api2ai https://petstore3.swagger.io/api/v3/openapi.json ./petstore-mcp --base-url https://petstore3.swagger.io/api/v3
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import { realpathSync } from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// OpenAPI Spec Loading & Parsing
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
async function loadOpenApiSpec(specPathOrUrl) {
|
|
26
|
+
if (specPathOrUrl.startsWith('http://') || specPathOrUrl.startsWith('https://')) {
|
|
27
|
+
const response = await fetch(specPathOrUrl);
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`);
|
|
30
|
+
}
|
|
31
|
+
return response.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const content = await fs.readFile(specPathOrUrl, 'utf-8');
|
|
35
|
+
|
|
36
|
+
if (specPathOrUrl.endsWith('.yaml') || specPathOrUrl.endsWith('.yml')) {
|
|
37
|
+
const yaml = await import('js-yaml').catch(() => null);
|
|
38
|
+
if (yaml) {
|
|
39
|
+
return yaml.load(content);
|
|
40
|
+
}
|
|
41
|
+
throw new Error('YAML spec detected but js-yaml is not installed. Run: npm install js-yaml');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return JSON.parse(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Convert OpenAPI schema to Zod schema string
|
|
48
|
+
function schemaToZod(schema, required = false) {
|
|
49
|
+
if (!schema) return 'z.unknown()';
|
|
50
|
+
|
|
51
|
+
let zodStr;
|
|
52
|
+
|
|
53
|
+
switch (schema.type) {
|
|
54
|
+
case 'string':
|
|
55
|
+
if (schema.enum) {
|
|
56
|
+
zodStr = `z.enum([${schema.enum.map(e => `'${e}'`).join(', ')}])`;
|
|
57
|
+
} else if (schema.format === 'date-time') {
|
|
58
|
+
zodStr = 'z.string().datetime()';
|
|
59
|
+
} else if (schema.format === 'date') {
|
|
60
|
+
zodStr = 'z.string().date()';
|
|
61
|
+
} else if (schema.format === 'email') {
|
|
62
|
+
zodStr = 'z.string().email()';
|
|
63
|
+
} else if (schema.format === 'uri' || schema.format === 'url') {
|
|
64
|
+
zodStr = 'z.string().url()';
|
|
65
|
+
} else {
|
|
66
|
+
zodStr = 'z.string()';
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case 'integer':
|
|
71
|
+
zodStr = 'z.number().int()';
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case 'number':
|
|
75
|
+
zodStr = 'z.number()';
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case 'boolean':
|
|
79
|
+
zodStr = 'z.boolean()';
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case 'array':
|
|
83
|
+
zodStr = `z.array(${schemaToZod(schema.items, true)})`;
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'object':
|
|
87
|
+
if (schema.properties) {
|
|
88
|
+
const props = Object.entries(schema.properties)
|
|
89
|
+
.map(([key, val]) => {
|
|
90
|
+
const isReq = schema.required?.includes(key);
|
|
91
|
+
const propZod = schemaToZod(val, isReq);
|
|
92
|
+
// Add description if available
|
|
93
|
+
const desc = val.description ? `.describe('${val.description.replace(/'/g, "\\'")}')` : '';
|
|
94
|
+
return ` ${sanitizePropertyName(key)}: ${propZod}${desc}`;
|
|
95
|
+
})
|
|
96
|
+
.join(',\n');
|
|
97
|
+
zodStr = `z.object({\n${props}\n })`;
|
|
98
|
+
} else if (schema.additionalProperties) {
|
|
99
|
+
zodStr = `z.record(z.string(), ${schemaToZod(schema.additionalProperties, true)})`;
|
|
100
|
+
} else {
|
|
101
|
+
zodStr = 'z.record(z.string(), z.unknown())';
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
// Handle anyOf, oneOf, allOf
|
|
107
|
+
if (schema.anyOf) {
|
|
108
|
+
const options = schema.anyOf.map(s => schemaToZod(s, true)).join(', ');
|
|
109
|
+
zodStr = `z.union([${options}])`;
|
|
110
|
+
} else if (schema.oneOf) {
|
|
111
|
+
const options = schema.oneOf.map(s => schemaToZod(s, true)).join(', ');
|
|
112
|
+
zodStr = `z.union([${options}])`;
|
|
113
|
+
} else {
|
|
114
|
+
zodStr = 'z.unknown()';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add optional if not required
|
|
119
|
+
if (!required) {
|
|
120
|
+
zodStr += '.optional()';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return zodStr;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sanitize property name for JS object key
|
|
127
|
+
function sanitizePropertyName(name) {
|
|
128
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
|
|
129
|
+
return name;
|
|
130
|
+
}
|
|
131
|
+
return `'${name}'`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build Zod schema for tool parameters
|
|
135
|
+
function buildZodSchema(operation, pathParams) {
|
|
136
|
+
const allParams = [...(pathParams || []), ...(operation.parameters || [])];
|
|
137
|
+
const properties = [];
|
|
138
|
+
|
|
139
|
+
for (const param of allParams) {
|
|
140
|
+
const schema = param.schema || { type: 'string' };
|
|
141
|
+
const zodType = schemaToZod(schema, param.required);
|
|
142
|
+
const desc = param.description ? `.describe('${param.description.replace(/'/g, "\\'")}')` : '';
|
|
143
|
+
properties.push(` ${sanitizePropertyName(param.name)}: ${zodType}${desc}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle request body
|
|
147
|
+
if (operation.requestBody) {
|
|
148
|
+
const content = operation.requestBody.content;
|
|
149
|
+
const mediaType = content?.['application/json'] || Object.values(content || {})[0];
|
|
150
|
+
|
|
151
|
+
if (mediaType?.schema) {
|
|
152
|
+
const zodType = schemaToZod(mediaType.schema, operation.requestBody.required);
|
|
153
|
+
const desc = operation.requestBody.description
|
|
154
|
+
? `.describe('${operation.requestBody.description.replace(/'/g, "\\'")}')`
|
|
155
|
+
: `.describe('Request body')`;
|
|
156
|
+
properties.push(` requestBody: ${zodType}${desc}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (properties.length === 0) {
|
|
161
|
+
return 'z.object({})';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return `z.object({\n${properties.join(',\n')}\n })`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function classifyRisk(operation, method, pathTemplate) {
|
|
168
|
+
const tags = operation.tags || [];
|
|
169
|
+
const haystack = [
|
|
170
|
+
operation.operationId,
|
|
171
|
+
operation.summary,
|
|
172
|
+
operation.description,
|
|
173
|
+
pathTemplate,
|
|
174
|
+
...tags,
|
|
175
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
176
|
+
|
|
177
|
+
const isMutation = !['get', 'head', 'options'].includes(method);
|
|
178
|
+
const dangerousPatterns = [
|
|
179
|
+
'delete', 'remove', 'destroy', 'purge',
|
|
180
|
+
'payment', 'payout', 'billing', 'invoice',
|
|
181
|
+
'admin', 'role', 'permission', 'token', 'secret', 'key',
|
|
182
|
+
'webhook', 'auth', 'oauth', 'user', 'member',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const matchedDanger = dangerousPatterns.some(p => haystack.includes(p));
|
|
186
|
+
const riskLevel = matchedDanger ? 'high' : isMutation ? 'medium' : 'low';
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
tags,
|
|
190
|
+
isMutation,
|
|
191
|
+
riskLevel,
|
|
192
|
+
requiresApproval: riskLevel !== 'low',
|
|
193
|
+
allowedInInspector: riskLevel === 'low',
|
|
194
|
+
enabledByDefault: riskLevel === 'low',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Extract tools from OpenAPI spec
|
|
199
|
+
function extractTools(spec, options = {}) {
|
|
200
|
+
const tools = [];
|
|
201
|
+
const { baseUrl: overrideBaseUrl, excludeOperationIds = [], filterFn, allowMutations = false, includeTags = [], excludeTags = [] } = options;
|
|
202
|
+
|
|
203
|
+
let baseUrl = overrideBaseUrl;
|
|
204
|
+
if (!baseUrl && spec.servers && spec.servers.length > 0) {
|
|
205
|
+
baseUrl = spec.servers[0].url;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const [pathTemplate, pathItem] of Object.entries(spec.paths || {})) {
|
|
209
|
+
const pathParams = pathItem.parameters || [];
|
|
210
|
+
|
|
211
|
+
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']) {
|
|
212
|
+
const operation = pathItem[method];
|
|
213
|
+
if (!operation) continue;
|
|
214
|
+
|
|
215
|
+
const operationId = operation.operationId ||
|
|
216
|
+
`${method}_${pathTemplate.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
217
|
+
|
|
218
|
+
if (excludeOperationIds.includes(operationId)) continue;
|
|
219
|
+
|
|
220
|
+
const risk = classifyRisk(operation, method, pathTemplate);
|
|
221
|
+
|
|
222
|
+
// Override enabledByDefault for mutations if allowMutations flag is set
|
|
223
|
+
if (allowMutations && risk.isMutation && risk.riskLevel === 'medium') {
|
|
224
|
+
risk.enabledByDefault = true;
|
|
225
|
+
risk.requiresApproval = false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Tag-based filtering
|
|
229
|
+
if (includeTags.length > 0 && !risk.tags.some(t => includeTags.includes(t))) continue;
|
|
230
|
+
if (excludeTags.length > 0 && risk.tags.some(t => excludeTags.includes(t))) continue;
|
|
231
|
+
|
|
232
|
+
// Build tool name (sanitized)
|
|
233
|
+
const name = operationId
|
|
234
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
235
|
+
.replace(/_+/g, '_')
|
|
236
|
+
.replace(/^_|_$/g, '');
|
|
237
|
+
|
|
238
|
+
const description = operation.summary ||
|
|
239
|
+
operation.description ||
|
|
240
|
+
`${method.toUpperCase()} ${pathTemplate}`;
|
|
241
|
+
|
|
242
|
+
// Build Zod schema string
|
|
243
|
+
const zodSchema = buildZodSchema(operation, pathParams);
|
|
244
|
+
|
|
245
|
+
// Extract execution parameters
|
|
246
|
+
const allParams = [...pathParams, ...(operation.parameters || [])];
|
|
247
|
+
const executionParameters = allParams.map(p => ({
|
|
248
|
+
name: p.name,
|
|
249
|
+
in: p.in,
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
let requestBodyContentType;
|
|
253
|
+
if (operation.requestBody?.content) {
|
|
254
|
+
requestBodyContentType = Object.keys(operation.requestBody.content)[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const tool = {
|
|
258
|
+
name,
|
|
259
|
+
description: description.substring(0, 1024),
|
|
260
|
+
zodSchema,
|
|
261
|
+
method,
|
|
262
|
+
pathTemplate,
|
|
263
|
+
executionParameters,
|
|
264
|
+
requestBodyContentType,
|
|
265
|
+
operationId,
|
|
266
|
+
baseUrl,
|
|
267
|
+
tags: risk.tags,
|
|
268
|
+
isMutation: risk.isMutation,
|
|
269
|
+
riskLevel: risk.riskLevel,
|
|
270
|
+
requiresApproval: risk.requiresApproval,
|
|
271
|
+
allowedInInspector: risk.allowedInInspector,
|
|
272
|
+
enabledByDefault: risk.enabledByDefault,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (filterFn && !filterFn(tool)) continue;
|
|
276
|
+
|
|
277
|
+
tools.push(tool);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return tools;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// Code Generation
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
function generatePackageJson(serverName, tools, port) {
|
|
289
|
+
return JSON.stringify({
|
|
290
|
+
name: serverName,
|
|
291
|
+
version: '1.0.0',
|
|
292
|
+
description: `MCP server generated from OpenAPI spec (${tools.length} tools)`,
|
|
293
|
+
type: 'module',
|
|
294
|
+
main: 'src/index.js',
|
|
295
|
+
scripts: {
|
|
296
|
+
start: 'node src/index.js',
|
|
297
|
+
dev: 'node --watch src/index.js',
|
|
298
|
+
},
|
|
299
|
+
dependencies: {
|
|
300
|
+
// mcp-use has required `zod@^4` as a peer since 1.12; pinning ^3 here
|
|
301
|
+
// made every generated server fail `npm install` on ERESOLVE.
|
|
302
|
+
'mcp-use': '^1.11.2',
|
|
303
|
+
'zod': '^4.0.0',
|
|
304
|
+
'dotenv': '^16.4.0',
|
|
305
|
+
},
|
|
306
|
+
engines: { node: '>=18.0.0' },
|
|
307
|
+
}, null, 2);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function generateEnvFile(baseUrl, port) {
|
|
311
|
+
return `# Server Configuration
|
|
312
|
+
PORT=${port}
|
|
313
|
+
NODE_ENV=development
|
|
314
|
+
|
|
315
|
+
# API Configuration
|
|
316
|
+
API_BASE_URL=${baseUrl || 'https://api.example.com'}
|
|
317
|
+
|
|
318
|
+
# Authentication (uncomment and configure as needed)
|
|
319
|
+
# API_KEY=your-api-key
|
|
320
|
+
# API_AUTH_HEADER=X-Custom-Auth:your-token
|
|
321
|
+
|
|
322
|
+
# Security — allowed API hosts (comma-separated). Leave empty to allow the spec's host only.
|
|
323
|
+
# ALLOWED_API_HOSTS=api.example.com,api2.example.com
|
|
324
|
+
|
|
325
|
+
# Tool policy — set to true to allow medium/high-risk (mutating) tools
|
|
326
|
+
ALLOW_RESTRICTED_TOOLS=false
|
|
327
|
+
# Set to false to bypass per-call approval requirement for restricted tools
|
|
328
|
+
REQUIRE_APPROVALS=true
|
|
329
|
+
|
|
330
|
+
# Request limits
|
|
331
|
+
REQUEST_TIMEOUT_MS=30000
|
|
332
|
+
MAX_RESPONSE_BYTES=10485760
|
|
333
|
+
|
|
334
|
+
# MCP Server URL (for UI widgets in production)
|
|
335
|
+
# MCP_URL=https://your-production-url.com
|
|
336
|
+
|
|
337
|
+
# Allowed Origins (comma-separated, for production)
|
|
338
|
+
# ALLOWED_ORIGINS=https://app1.com,https://app2.com
|
|
339
|
+
`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function generateEnvExampleFile(baseUrl, port) {
|
|
343
|
+
return `# Server Configuration
|
|
344
|
+
PORT=${port}
|
|
345
|
+
NODE_ENV=development
|
|
346
|
+
|
|
347
|
+
# API Configuration
|
|
348
|
+
API_BASE_URL=${baseUrl || 'https://api.example.com'}
|
|
349
|
+
|
|
350
|
+
# Authentication
|
|
351
|
+
API_KEY=your-api-key-here
|
|
352
|
+
# API_AUTH_HEADER=Header-Name:header-value
|
|
353
|
+
|
|
354
|
+
# Security — allowed API hosts (comma-separated). Leave empty to allow the spec's host only.
|
|
355
|
+
# ALLOWED_API_HOSTS=api.example.com,api2.example.com
|
|
356
|
+
|
|
357
|
+
# Tool policy — set to true to allow medium/high-risk (mutating) tools
|
|
358
|
+
ALLOW_RESTRICTED_TOOLS=false
|
|
359
|
+
# Set to false to bypass per-call approval requirement for restricted tools
|
|
360
|
+
REQUIRE_APPROVALS=true
|
|
361
|
+
|
|
362
|
+
# Request limits
|
|
363
|
+
REQUEST_TIMEOUT_MS=30000
|
|
364
|
+
MAX_RESPONSE_BYTES=10485760
|
|
365
|
+
|
|
366
|
+
# MCP Server URL (for UI widgets in production)
|
|
367
|
+
# MCP_URL=https://your-production-url.com
|
|
368
|
+
|
|
369
|
+
# Allowed Origins (comma-separated, for production)
|
|
370
|
+
# ALLOWED_ORIGINS=https://app1.com,https://app2.com
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function generatePolicy() {
|
|
375
|
+
return `// Runtime security policy for generated MCP server
|
|
376
|
+
|
|
377
|
+
const BLOCKED_HEADER_NAMES = new Set([
|
|
378
|
+
'authorization', 'cookie', 'set-cookie', 'x-api-key',
|
|
379
|
+
'x-auth-token', 'proxy-authorization', 'www-authenticate',
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
const MAX_RESPONSE_BYTES = parseInt(process.env.MAX_RESPONSE_BYTES || '10485760');
|
|
383
|
+
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS || '30000');
|
|
384
|
+
|
|
385
|
+
export { MAX_RESPONSE_BYTES, REQUEST_TIMEOUT_MS };
|
|
386
|
+
|
|
387
|
+
export function sanitizeHeaderParams(headerParams = {}) {
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const [k, v] of Object.entries(headerParams)) {
|
|
390
|
+
if (!BLOCKED_HEADER_NAMES.has(k.toLowerCase())) {
|
|
391
|
+
out[k] = String(v);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function assertAllowedBaseUrl(baseUrl) {
|
|
398
|
+
const url = new URL(baseUrl);
|
|
399
|
+
const allowedHosts = (process.env.ALLOWED_API_HOSTS || '')
|
|
400
|
+
.split(',')
|
|
401
|
+
.map(s => s.trim())
|
|
402
|
+
.filter(Boolean);
|
|
403
|
+
if (allowedHosts.length > 0 && !allowedHosts.includes(url.host)) {
|
|
404
|
+
throw new Error(\`Disallowed API host: \${url.host}. Set ALLOWED_API_HOSTS to include it.\`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function checkToolPolicy(toolConfig) {
|
|
409
|
+
if (!toolConfig.enabledByDefault && process.env.ALLOW_RESTRICTED_TOOLS !== 'true') {
|
|
410
|
+
throw new Error(
|
|
411
|
+
\`Tool '\${toolConfig.name}' is disabled by policy (riskLevel: \${toolConfig.riskLevel}). \` +
|
|
412
|
+
\`Set ALLOW_RESTRICTED_TOOLS=true to enable restricted tools.\`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (toolConfig.requiresApproval && process.env.REQUIRE_APPROVALS !== 'false') {
|
|
416
|
+
throw new Error(
|
|
417
|
+
\`Tool '\${toolConfig.name}' requires approval (riskLevel: \${toolConfig.riskLevel}). \` +
|
|
418
|
+
\`Set REQUIRE_APPROVALS=false to bypass, or route through an approval handler.\`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function generateHttpClient() {
|
|
426
|
+
return `// HTTP client for API requests
|
|
427
|
+
import { sanitizeHeaderParams, MAX_RESPONSE_BYTES, REQUEST_TIMEOUT_MS } from './policy.js';
|
|
428
|
+
|
|
429
|
+
export function buildUrl(baseUrl, pathTemplate, pathParams = {}) {
|
|
430
|
+
let url = pathTemplate;
|
|
431
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
432
|
+
url = url.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
|
433
|
+
}
|
|
434
|
+
return new URL(url, baseUrl).toString();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function buildQueryString(queryParams = {}) {
|
|
438
|
+
const params = new URLSearchParams();
|
|
439
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
440
|
+
if (value !== undefined && value !== null) {
|
|
441
|
+
if (Array.isArray(value)) {
|
|
442
|
+
value.forEach(v => params.append(key, String(v)));
|
|
443
|
+
} else {
|
|
444
|
+
params.append(key, String(value));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return params.toString();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function executeRequest(toolConfig, args, config = {}) {
|
|
452
|
+
const { baseUrl: configBaseUrl, headers: configHeaders = {} } = config;
|
|
453
|
+
const baseUrl = configBaseUrl || toolConfig.baseUrl;
|
|
454
|
+
|
|
455
|
+
if (!baseUrl) {
|
|
456
|
+
throw new Error(\`No base URL configured for tool: \${toolConfig.name}\`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const pathParams = {};
|
|
460
|
+
const queryParams = {};
|
|
461
|
+
const rawHeaderParams = {};
|
|
462
|
+
let body;
|
|
463
|
+
|
|
464
|
+
for (const param of toolConfig.executionParameters || []) {
|
|
465
|
+
const value = args[param.name];
|
|
466
|
+
if (value === undefined) continue;
|
|
467
|
+
switch (param.in) {
|
|
468
|
+
case 'path': pathParams[param.name] = value; break;
|
|
469
|
+
case 'query': queryParams[param.name] = value; break;
|
|
470
|
+
case 'header': rawHeaderParams[param.name] = value; break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (args.requestBody !== undefined) {
|
|
475
|
+
body = args.requestBody;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let url = buildUrl(baseUrl, toolConfig.pathTemplate, pathParams);
|
|
479
|
+
const queryString = buildQueryString(queryParams);
|
|
480
|
+
if (queryString) {
|
|
481
|
+
url += (url.includes('?') ? '&' : '?') + queryString;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// configHeaders (auth) always win over spec-defined header params
|
|
485
|
+
const headers = {
|
|
486
|
+
Accept: 'application/json',
|
|
487
|
+
...sanitizeHeaderParams(rawHeaderParams),
|
|
488
|
+
...configHeaders,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (body !== undefined) {
|
|
492
|
+
headers['Content-Type'] = toolConfig.requestBodyContentType || 'application/json';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const controller = new AbortController();
|
|
496
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
497
|
+
|
|
498
|
+
let response;
|
|
499
|
+
try {
|
|
500
|
+
response = await fetch(url, {
|
|
501
|
+
method: toolConfig.method.toUpperCase(),
|
|
502
|
+
headers,
|
|
503
|
+
body: body !== undefined && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(toolConfig.method.toUpperCase())
|
|
504
|
+
? (typeof body === 'string' ? body : JSON.stringify(body))
|
|
505
|
+
: undefined,
|
|
506
|
+
redirect: 'error',
|
|
507
|
+
signal: controller.signal,
|
|
508
|
+
});
|
|
509
|
+
} finally {
|
|
510
|
+
clearTimeout(timeout);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const contentType = response.headers.get('content-type') || '';
|
|
514
|
+
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
|
515
|
+
if (contentLength > MAX_RESPONSE_BYTES) {
|
|
516
|
+
throw new Error(\`Response too large: \${contentLength} bytes (max \${MAX_RESPONSE_BYTES})\`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let data;
|
|
520
|
+
if (contentType.includes('application/json')) {
|
|
521
|
+
const text = await response.text();
|
|
522
|
+
if (text.length > MAX_RESPONSE_BYTES) {
|
|
523
|
+
throw new Error(\`Response body too large: \${text.length} bytes (max \${MAX_RESPONSE_BYTES})\`);
|
|
524
|
+
}
|
|
525
|
+
data = JSON.parse(text);
|
|
526
|
+
} else {
|
|
527
|
+
const text = await response.text();
|
|
528
|
+
if (text.length > MAX_RESPONSE_BYTES) {
|
|
529
|
+
throw new Error(\`Response body too large: \${text.length} bytes (max \${MAX_RESPONSE_BYTES})\`);
|
|
530
|
+
}
|
|
531
|
+
data = text;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { status: response.status, statusText: response.statusText, data, ok: response.ok };
|
|
535
|
+
}
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function generateToolsConfig(tools) {
|
|
540
|
+
const toolConfigs = tools.map(tool => ({
|
|
541
|
+
name: tool.name,
|
|
542
|
+
description: tool.description,
|
|
543
|
+
method: tool.method,
|
|
544
|
+
pathTemplate: tool.pathTemplate,
|
|
545
|
+
executionParameters: tool.executionParameters,
|
|
546
|
+
requestBodyContentType: tool.requestBodyContentType,
|
|
547
|
+
baseUrl: tool.baseUrl,
|
|
548
|
+
tags: tool.tags,
|
|
549
|
+
operationId: tool.operationId,
|
|
550
|
+
riskLevel: tool.riskLevel,
|
|
551
|
+
requiresApproval: tool.requiresApproval,
|
|
552
|
+
allowedInInspector: tool.allowedInInspector,
|
|
553
|
+
enabledByDefault: tool.enabledByDefault,
|
|
554
|
+
}));
|
|
555
|
+
|
|
556
|
+
return `// Tool configurations extracted from OpenAPI spec
|
|
557
|
+
// Generated: ${new Date().toISOString()}
|
|
558
|
+
|
|
559
|
+
export const toolConfigs = ${JSON.stringify(toolConfigs, null, 2)};
|
|
560
|
+
|
|
561
|
+
// Create a map for quick lookup
|
|
562
|
+
export const toolConfigMap = new Map(toolConfigs.map(t => [t.name, t]));
|
|
563
|
+
`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function generateServerIndex(serverName, tools, baseUrl, port) {
|
|
567
|
+
// Generate tool registration code using proper MCP format
|
|
568
|
+
const toolRegistrations = tools.map(tool => {
|
|
569
|
+
return `
|
|
570
|
+
// ${tool.description}
|
|
571
|
+
server.tool(
|
|
572
|
+
{
|
|
573
|
+
name: '${tool.name}',
|
|
574
|
+
description: '${tool.description.replace(/'/g, "\\'")}',
|
|
575
|
+
schema: ${tool.zodSchema},
|
|
576
|
+
},
|
|
577
|
+
async (params) => {
|
|
578
|
+
const toolConfig = toolConfigMap.get('${tool.name}');
|
|
579
|
+
checkToolPolicy(toolConfig);
|
|
580
|
+
const result = await executeRequest(toolConfig, params, apiConfig);
|
|
581
|
+
|
|
582
|
+
if (!result.ok) {
|
|
583
|
+
return text(\`Error: \${result.status} \${result.statusText}\\n\${
|
|
584
|
+
typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2)
|
|
585
|
+
}\`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Return MCP content based on response type
|
|
589
|
+
if (typeof result.data === 'string') {
|
|
590
|
+
return text(result.data);
|
|
591
|
+
} else if (typeof result.data === 'object' && result.data !== null) {
|
|
592
|
+
return object(result.data);
|
|
593
|
+
} else {
|
|
594
|
+
return text(String(result.data));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
);`;
|
|
598
|
+
}).join('\n');
|
|
599
|
+
|
|
600
|
+
return `#!/usr/bin/env node
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* ${serverName} - MCP Server
|
|
604
|
+
*
|
|
605
|
+
* Features:
|
|
606
|
+
* - ${tools.length} API tools available
|
|
607
|
+
* - Built-in Inspector at http://localhost:${port}/inspector
|
|
608
|
+
*/
|
|
609
|
+
|
|
610
|
+
import 'dotenv/config';
|
|
611
|
+
import { MCPServer } from 'mcp-use/server';
|
|
612
|
+
import { text, object } from 'mcp-use/server';
|
|
613
|
+
import { z } from 'zod';
|
|
614
|
+
import { executeRequest } from './http-client.js';
|
|
615
|
+
import { toolConfigMap } from './tools-config.js';
|
|
616
|
+
import { assertAllowedBaseUrl, checkToolPolicy } from './policy.js';
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// Configuration
|
|
620
|
+
// ============================================================================
|
|
621
|
+
|
|
622
|
+
const PORT = parseInt(process.env.PORT || '${port}');
|
|
623
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
624
|
+
|
|
625
|
+
// API configuration
|
|
626
|
+
const apiConfig = {
|
|
627
|
+
baseUrl: process.env.API_BASE_URL || ${baseUrl ? `'${baseUrl}'` : 'null'},
|
|
628
|
+
headers: {},
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Set up authentication headers
|
|
632
|
+
if (process.env.API_KEY) {
|
|
633
|
+
apiConfig.headers['Authorization'] = \`Bearer \${process.env.API_KEY}\`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (process.env.API_AUTH_HEADER) {
|
|
637
|
+
const [key, ...valueParts] = process.env.API_AUTH_HEADER.split(':');
|
|
638
|
+
const value = valueParts.join(':'); // Handle values with colons
|
|
639
|
+
if (key && value) {
|
|
640
|
+
apiConfig.headers[key.trim()] = value.trim();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Validate API base URL against allowed hosts
|
|
645
|
+
if (apiConfig.baseUrl) {
|
|
646
|
+
assertAllowedBaseUrl(apiConfig.baseUrl);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// Server Setup
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
const server = new MCPServer({
|
|
654
|
+
name: '${serverName}',
|
|
655
|
+
version: '1.0.0',
|
|
656
|
+
description: 'MCP server generated from OpenAPI specification',
|
|
657
|
+
baseUrl: process.env.MCP_URL || \`http://localhost:\${PORT}\`,
|
|
658
|
+
allowedOrigins: isDev
|
|
659
|
+
? undefined // Development: allow all origins
|
|
660
|
+
: process.env.ALLOWED_ORIGINS?.split(',').map(s => s.trim()) || [],
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ============================================================================
|
|
664
|
+
// Tool Registrations
|
|
665
|
+
// ============================================================================
|
|
666
|
+
${toolRegistrations}
|
|
667
|
+
|
|
668
|
+
// ============================================================================
|
|
669
|
+
// Start Server
|
|
670
|
+
// ============================================================================
|
|
671
|
+
|
|
672
|
+
server.listen(PORT);
|
|
673
|
+
|
|
674
|
+
console.log(\`
|
|
675
|
+
🚀 ${serverName} MCP Server Started
|
|
676
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
677
|
+
|
|
678
|
+
📍 Server: http://localhost:\${PORT}
|
|
679
|
+
🔍 Inspector: http://localhost:\${PORT}/inspector
|
|
680
|
+
📡 MCP: http://localhost:\${PORT}/mcp
|
|
681
|
+
🔄 SSE: http://localhost:\${PORT}/sse
|
|
682
|
+
|
|
683
|
+
🛠️ Tools Available: ${tools.length}
|
|
684
|
+
${tools.slice(0, 5).map(t => ` • ${t.name}`).join('\n')}${tools.length > 5 ? `\n ... and ${tools.length - 5} more` : ''}
|
|
685
|
+
Environment: \${isDev ? 'Development' : 'Production'}
|
|
686
|
+
API Base: \${apiConfig.baseUrl || 'Not configured'}
|
|
687
|
+
Security: ALLOW_RESTRICTED_TOOLS=\${process.env.ALLOW_RESTRICTED_TOOLS || 'false'}, REQUIRE_APPROVALS=\${process.env.REQUIRE_APPROVALS || 'true'}
|
|
688
|
+
\`);
|
|
689
|
+
`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function generateReadme(serverName, tools, specPath, baseUrl, port) {
|
|
693
|
+
const toolList = tools
|
|
694
|
+
.map(t => `| \`${t.name}\` | ${t.method.toUpperCase()} | ${t.pathTemplate} | ${t.description.substring(0, 50)}${t.description.length > 50 ? '...' : ''} |`)
|
|
695
|
+
.join('\n');
|
|
696
|
+
|
|
697
|
+
return `# ${serverName}
|
|
698
|
+
|
|
699
|
+
MCP server auto-generated from OpenAPI specification using the [mcp-use](https://mcp-use.com) framework.
|
|
700
|
+
|
|
701
|
+
## Features
|
|
702
|
+
|
|
703
|
+
- 🛠️ **${tools.length} API Tools** - All operations from the OpenAPI spec
|
|
704
|
+
- 🔍 **Built-in Inspector** - Test tools at \`/inspector\`
|
|
705
|
+
- 📡 **Streamable HTTP** - Modern MCP transport
|
|
706
|
+
- 🔐 **Authentication Support** - Bearer tokens & custom headers
|
|
707
|
+
- 🎨 **UI Widgets** - Compatible with ChatGPT and MCP-UI
|
|
708
|
+
|
|
709
|
+
## Quick Start
|
|
710
|
+
|
|
711
|
+
\`\`\`bash
|
|
712
|
+
# Install dependencies
|
|
713
|
+
npm install
|
|
714
|
+
|
|
715
|
+
# Configure environment
|
|
716
|
+
cp .env.example .env
|
|
717
|
+
# Edit .env with your API credentials
|
|
718
|
+
|
|
719
|
+
# Start the server
|
|
720
|
+
npm start
|
|
721
|
+
|
|
722
|
+
# Or with hot reload
|
|
723
|
+
npm run dev
|
|
724
|
+
\`\`\`
|
|
725
|
+
|
|
726
|
+
Then open http://localhost:${port}/inspector to test your tools!
|
|
727
|
+
|
|
728
|
+
## Security
|
|
729
|
+
|
|
730
|
+
This server enforces a three-layer security policy:
|
|
731
|
+
|
|
732
|
+
**Generation-time classification** — every tool is classified as \`low\`, \`medium\`, or \`high\` risk.
|
|
733
|
+
Read-only (\`GET\`/\`HEAD\`/\`OPTIONS\`) endpoints are \`low\` risk and enabled by default.
|
|
734
|
+
Mutating endpoints (\`POST\`/\`PUT\`/\`PATCH\`/\`DELETE\`) are \`medium\` risk and blocked unless \`ALLOW_RESTRICTED_TOOLS=true\`.
|
|
735
|
+
Operations matching admin, payment, auth, billing, or credential patterns are \`high\` risk.
|
|
736
|
+
|
|
737
|
+
**Runtime policy enforcement** — \`checkToolPolicy()\` runs before every API call and enforces the risk level and approval requirements set via environment variables.
|
|
738
|
+
|
|
739
|
+
**HTTP hardening** — request timeouts, response size caps, redirect blocking, and credential header protection are enforced on every outbound request. Tool arguments cannot override \`Authorization\`, \`Cookie\`, or other credential headers.
|
|
740
|
+
|
|
741
|
+
> **Inspector note**: The built-in inspector at \`/inspector\` exposes all registered tools. In production, restrict inspector access using a reverse proxy or firewall rule, and set \`NODE_ENV=production\`.
|
|
742
|
+
|
|
743
|
+
## Environment Variables
|
|
744
|
+
|
|
745
|
+
| Variable | Description | Default |
|
|
746
|
+
|----------|-------------|---------|
|
|
747
|
+
| \`PORT\` | Server port | ${port} |
|
|
748
|
+
| \`NODE_ENV\` | Environment (development/production) | development |
|
|
749
|
+
| \`API_BASE_URL\` | Base URL for API requests | ${baseUrl || 'From OpenAPI spec'} |
|
|
750
|
+
| \`API_KEY\` | Bearer token for Authorization header | - |
|
|
751
|
+
| \`API_AUTH_HEADER\` | Custom auth header (format: \`Header:value\`) | - |
|
|
752
|
+
| \`MCP_URL\` | Public MCP server URL (for widgets) | http://localhost:${port} |
|
|
753
|
+
| \`ALLOWED_ORIGINS\` | Allowed origins in production (comma-separated) | - |
|
|
754
|
+
| \`ALLOW_RESTRICTED_TOOLS\` | Allow medium/high-risk mutating tools | false |
|
|
755
|
+
| \`REQUIRE_APPROVALS\` | Require explicit approval for restricted tools | true |
|
|
756
|
+
| \`ALLOWED_API_HOSTS\` | Comma-separated allowed API hostnames | (spec's host) |
|
|
757
|
+
| \`REQUEST_TIMEOUT_MS\` | Outbound request timeout in ms | 30000 |
|
|
758
|
+
| \`MAX_RESPONSE_BYTES\` | Maximum response body size in bytes | 10485760 |
|
|
759
|
+
|
|
760
|
+
## Connect to Claude Desktop
|
|
761
|
+
|
|
762
|
+
Add to your Claude Desktop configuration:
|
|
763
|
+
|
|
764
|
+
**macOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\`
|
|
765
|
+
**Windows**: \`%APPDATA%\\Claude\\claude_desktop_config.json\`
|
|
766
|
+
|
|
767
|
+
\`\`\`json
|
|
768
|
+
{
|
|
769
|
+
"mcpServers": {
|
|
770
|
+
"${serverName}": {
|
|
771
|
+
"url": "http://localhost:${port}/mcp"
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
\`\`\`
|
|
776
|
+
|
|
777
|
+
## Connect to ChatGPT
|
|
778
|
+
|
|
779
|
+
This server supports the OpenAI Apps SDK. Configure your ChatGPT integration to use:
|
|
780
|
+
|
|
781
|
+
\`\`\`
|
|
782
|
+
http://localhost:${port}/mcp
|
|
783
|
+
\`\`\`
|
|
784
|
+
|
|
785
|
+
## Available Tools
|
|
786
|
+
|
|
787
|
+
| Tool | Method | Path | Description |
|
|
788
|
+
|------|--------|------|-------------|
|
|
789
|
+
${toolList}
|
|
790
|
+
|
|
791
|
+
## API Endpoints
|
|
792
|
+
|
|
793
|
+
| Endpoint | Description |
|
|
794
|
+
|----------|-------------|
|
|
795
|
+
| \`GET /inspector\` | Interactive tool testing UI |
|
|
796
|
+
| \`POST /mcp\` | MCP protocol endpoint |
|
|
797
|
+
| \`GET /sse\` | Server-Sent Events endpoint |
|
|
798
|
+
| \`GET /health\` | Health check endpoint |
|
|
799
|
+
|
|
800
|
+
## Project Structure
|
|
801
|
+
|
|
802
|
+
\`\`\`
|
|
803
|
+
${serverName}/
|
|
804
|
+
├── .env # Environment configuration
|
|
805
|
+
├── .env.example # Example environment file
|
|
806
|
+
├── package.json # Dependencies
|
|
807
|
+
├── README.md # This file
|
|
808
|
+
└── src/
|
|
809
|
+
├── index.js # Main server with MCP tool registrations
|
|
810
|
+
├── http-client.js # HTTP utilities for API calls
|
|
811
|
+
├── tools-config.js # Tool configurations from OpenAPI spec
|
|
812
|
+
└── policy.js # Runtime security policy (tool policy, header sanitization)
|
|
813
|
+
\`\`\`
|
|
814
|
+
|
|
815
|
+
## How It Works
|
|
816
|
+
|
|
817
|
+
Each tool is registered using the proper MCP format:
|
|
818
|
+
|
|
819
|
+
\`\`\`javascript
|
|
820
|
+
server.tool(
|
|
821
|
+
{
|
|
822
|
+
name: 'getPetById',
|
|
823
|
+
description: 'Find pet by ID',
|
|
824
|
+
schema: z.object({
|
|
825
|
+
petId: z.number().int().describe('ID of pet to return'),
|
|
826
|
+
}),
|
|
827
|
+
},
|
|
828
|
+
async (params) => {
|
|
829
|
+
// Fetch data from the API
|
|
830
|
+
const result = await executeRequest(toolConfig, params, apiConfig);
|
|
831
|
+
|
|
832
|
+
// Return MCP content (text or object)
|
|
833
|
+
return result.ok ? object(result.data) : text(\`Error: \${result.status}\`);
|
|
834
|
+
}
|
|
835
|
+
);
|
|
836
|
+
\`\`\`
|
|
837
|
+
|
|
838
|
+
## Production Deployment
|
|
839
|
+
|
|
840
|
+
### Docker
|
|
841
|
+
|
|
842
|
+
\`\`\`dockerfile
|
|
843
|
+
FROM node:20-alpine
|
|
844
|
+
WORKDIR /app
|
|
845
|
+
COPY package*.json ./
|
|
846
|
+
RUN npm ci --only=production
|
|
847
|
+
COPY . .
|
|
848
|
+
ENV NODE_ENV=production
|
|
849
|
+
EXPOSE ${port}
|
|
850
|
+
CMD ["npm", "start"]
|
|
851
|
+
\`\`\`
|
|
852
|
+
|
|
853
|
+
### PM2
|
|
854
|
+
|
|
855
|
+
\`\`\`bash
|
|
856
|
+
pm2 start src/index.js --name ${serverName}
|
|
857
|
+
\`\`\`
|
|
858
|
+
|
|
859
|
+
## Source
|
|
860
|
+
|
|
861
|
+
- **OpenAPI Spec**: \`${specPath}\`
|
|
862
|
+
- **Generated**: ${new Date().toISOString()}
|
|
863
|
+
- **Framework**: [mcp-use](https://mcp-use.com)
|
|
864
|
+
|
|
865
|
+
## License
|
|
866
|
+
|
|
867
|
+
MIT
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ============================================================================
|
|
872
|
+
// Main Generator
|
|
873
|
+
// ============================================================================
|
|
874
|
+
|
|
875
|
+
async function generateMcpServer(specPathOrUrl, outputFolder, options = {}) {
|
|
876
|
+
const {
|
|
877
|
+
baseUrl,
|
|
878
|
+
serverName = 'openapi-mcp-server',
|
|
879
|
+
port = 3000,
|
|
880
|
+
} = options;
|
|
881
|
+
|
|
882
|
+
console.log(`\n📖 Loading OpenAPI spec: ${specPathOrUrl}`);
|
|
883
|
+
const spec = await loadOpenApiSpec(specPathOrUrl);
|
|
884
|
+
|
|
885
|
+
console.log(` Title: ${spec.info?.title || 'Unknown'}`);
|
|
886
|
+
console.log(` Version: ${spec.info?.version || 'Unknown'}`);
|
|
887
|
+
|
|
888
|
+
const tools = extractTools(spec, { baseUrl, ...options });
|
|
889
|
+
console.log(`✅ Extracted ${tools.length} tools\n`);
|
|
890
|
+
|
|
891
|
+
// Create directory structure
|
|
892
|
+
const srcDir = path.join(outputFolder, 'src');
|
|
893
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
894
|
+
|
|
895
|
+
const effectiveBaseUrl = baseUrl || tools[0]?.baseUrl;
|
|
896
|
+
|
|
897
|
+
// Generate all files
|
|
898
|
+
const files = [
|
|
899
|
+
{
|
|
900
|
+
path: path.join(outputFolder, 'package.json'),
|
|
901
|
+
content: generatePackageJson(serverName, tools, port)
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
path: path.join(outputFolder, '.env'),
|
|
905
|
+
content: generateEnvFile(effectiveBaseUrl, port)
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
path: path.join(outputFolder, '.env.example'),
|
|
909
|
+
content: generateEnvExampleFile(effectiveBaseUrl, port)
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
path: path.join(srcDir, 'policy.js'),
|
|
913
|
+
content: generatePolicy()
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
path: path.join(srcDir, 'http-client.js'),
|
|
917
|
+
content: generateHttpClient()
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
path: path.join(srcDir, 'tools-config.js'),
|
|
921
|
+
content: generateToolsConfig(tools)
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
path: path.join(srcDir, 'index.js'),
|
|
925
|
+
content: generateServerIndex(serverName, tools, effectiveBaseUrl, port)
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
path: path.join(outputFolder, 'README.md'),
|
|
929
|
+
content: generateReadme(serverName, tools, specPathOrUrl, effectiveBaseUrl, port)
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
path: path.join(outputFolder, '.gitignore'),
|
|
933
|
+
content: 'node_modules/\n.env\n*.log\n'
|
|
934
|
+
},
|
|
935
|
+
];
|
|
936
|
+
|
|
937
|
+
for (const file of files) {
|
|
938
|
+
await fs.writeFile(file.path, file.content);
|
|
939
|
+
console.log(` ✓ ${path.relative(outputFolder, file.path)}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
console.log(`
|
|
943
|
+
🎉 MCP-Use Server Generated
|
|
944
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
cd ${outputFolder}
|
|
948
|
+
npm install
|
|
949
|
+
npm start
|
|
950
|
+
|
|
951
|
+
Then open http://localhost:${port}/inspector to test your tools!
|
|
952
|
+
`);
|
|
953
|
+
|
|
954
|
+
return {
|
|
955
|
+
outputFolder,
|
|
956
|
+
toolCount: tools.length,
|
|
957
|
+
tools: tools.map(t => t.name),
|
|
958
|
+
port,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ============================================================================
|
|
963
|
+
// Exports & CLI
|
|
964
|
+
// ============================================================================
|
|
965
|
+
|
|
966
|
+
export { generateMcpServer, extractTools, loadOpenApiSpec };
|
|
967
|
+
|
|
968
|
+
// CLI entry point. realpathSync follows the npm/npx bin symlink so this
|
|
969
|
+
// still matches when invoked as `npx api2ai` (argv[1] is the symlink in
|
|
970
|
+
// node_modules/.bin, not this file's real path).
|
|
971
|
+
const isMainModule = Boolean(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
972
|
+
|
|
973
|
+
if (isMainModule) {
|
|
974
|
+
const args = process.argv.slice(2);
|
|
975
|
+
|
|
976
|
+
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
|
|
977
|
+
console.log(`
|
|
978
|
+
OpenAPI to MCP Server Generator (mcp-use framework)
|
|
979
|
+
|
|
980
|
+
Usage:
|
|
981
|
+
npx api2ai <openapi-spec> [output-folder] [options]
|
|
982
|
+
|
|
983
|
+
Arguments:
|
|
984
|
+
openapi-spec Path to local file or URL to remote OpenAPI spec
|
|
985
|
+
output-folder Directory to create the server in (default: ./mcp-server)
|
|
986
|
+
|
|
987
|
+
Options:
|
|
988
|
+
--name <name> Server name (default: openapi-mcp-server)
|
|
989
|
+
--base-url <url> Override API base URL from the spec
|
|
990
|
+
--port <port> Server port (default: 3000)
|
|
991
|
+
--allow-mutations Enable POST/PUT/PATCH/DELETE tools by default
|
|
992
|
+
--include-tags <tags> Only include tools with these tags (comma-separated)
|
|
993
|
+
--exclude-tags <tags> Exclude tools with these tags (comma-separated)
|
|
994
|
+
--approve-writes Disable approval requirement for restricted tools
|
|
995
|
+
--help, -h Show this help message
|
|
996
|
+
|
|
997
|
+
Examples:
|
|
998
|
+
npx api2ai ./petstore.json ./my-server
|
|
999
|
+
npx api2ai https://petstore3.swagger.io/api/v3/openapi.json ./petstore-mcp \\
|
|
1000
|
+
--name petstore-api --port 8080
|
|
1001
|
+
`);
|
|
1002
|
+
process.exit(0);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const options = {
|
|
1006
|
+
specPath: args[0],
|
|
1007
|
+
outputFolder: './mcp-server',
|
|
1008
|
+
baseUrl: null,
|
|
1009
|
+
serverName: 'api-mcp-server',
|
|
1010
|
+
port: 3000,
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
for (let i = 1; i < args.length; i++) {
|
|
1014
|
+
if (args[i] === '--base-url' && args[i + 1]) {
|
|
1015
|
+
options.baseUrl = args[++i];
|
|
1016
|
+
} else if (args[i] === '--name' && args[i + 1]) {
|
|
1017
|
+
options.serverName = args[++i];
|
|
1018
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
1019
|
+
options.port = parseInt(args[++i]);
|
|
1020
|
+
} else if (args[i] === '--allow-mutations') {
|
|
1021
|
+
options.allowMutations = true;
|
|
1022
|
+
} else if (args[i] === '--include-tags' && args[i + 1]) {
|
|
1023
|
+
options.includeTags = args[++i].split(',').map(s => s.trim());
|
|
1024
|
+
} else if (args[i] === '--exclude-tags' && args[i + 1]) {
|
|
1025
|
+
options.excludeTags = args[++i].split(',').map(s => s.trim());
|
|
1026
|
+
} else if (args[i] === '--approve-writes') {
|
|
1027
|
+
options.requireApprovals = false;
|
|
1028
|
+
} else if (!args[i].startsWith('--')) {
|
|
1029
|
+
options.outputFolder = args[i];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
generateMcpServer(options.specPath, options.outputFolder, options).catch(e => {
|
|
1034
|
+
console.error('❌ Error:', e.message);
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
});
|
|
1037
|
+
}
|