api2ai 1.0.13 → 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.
@@ -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
+ }