feat: Add parser and type definitions for OpenAPI to MCP generator

- Introduced a new parser module with exports from extract-tools.
- Created core type definitions for CLI options and MCP tool definitions.
- Removed outdated utility functions and replaced them with new code generation utilities.
- Implemented security handling utilities for API key, HTTP, and OAuth2 authentication.
- Added URL handling utilities for base URL determination and query parameter management.
- Updated TypeScript configuration for improved module resolution and output settings.
This commit is contained in:
harsha-iiiv 2025-04-13 23:32:24 +05:30
parent 7e5c34bc1f
commit 6f645e06f3
26 changed files with 2778 additions and 1497 deletions

18
.eslintrc.json Normal file
View File

@ -0,0 +1,18 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"env": {
"node": true,
"es2022": true
},
"rules": {
"no-console": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}

48
.gitignore vendored
View File

@ -1,2 +1,48 @@
node_modules
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
build/
*.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Coverage
coverage/
.nyc_output/
# IDEs and editors
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
*.sublime-workspace
*.sublime-project
# OS specific
.DS_Store
Thumbs.db
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# misc
.npm
.eslintcache

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

View File

@ -1,590 +0,0 @@
import { OpenAPIV3 } from 'openapi-types';
import { generateOperationId } from './utils.js';
/**
* Generates the TypeScript code content for the server's src/index.ts file.
*/
export function generateMcpServerCode(api, options, serverName, serverVersion) {
const tools = extractToolsFromApi(api);
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
const listToolsCode = generateListTools(tools);
const callToolCode = generateCallTool(tools, determinedBaseUrl);
// --- Template for src/index.ts ---
return `
// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion}
// Source OpenAPI spec: ${options.input}
// Generation date: ${new Date().toISOString()}
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// Import Schemas and Types from /types subpath with .js extension
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool,
type CallToolResult,
type CallToolRequest // Added type for the request parameter
} from "@modelcontextprotocol/sdk/types.js";
// Zod for runtime validation
import { z, ZodError } from 'zod';
// Library to convert JSON Schema to Zod schema string at runtime
import { jsonSchemaToZod } from 'json-schema-to-zod';
// Define JsonObject locally as a utility type
type JsonObject = Record<string, any>;
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
// --- Server Configuration ---
const SERVER_NAME = "${serverName}";
const SERVER_VERSION = "${serverVersion}";
const API_BASE_URL = "${determinedBaseUrl || ''}";
// --- Server Instance ---
const server = new Server(
{
name: SERVER_NAME,
version: SERVER_VERSION
},
{
capabilities: {
tools: {}
}
}
);
// --- Tool Definitions (for ListTools response) ---
// Corrected: Use Tool[] type
const toolsList: Tool[] = [
${listToolsCode}
];
// --- Request Handlers ---
// 1. List Available Tools Handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: toolsList,
};
});
// 2. Call Tool Handler
// Corrected: Added explicit type for 'request' parameter
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
const { name: toolName, arguments: toolArgs } = request.params;
const toolDefinition = toolsList.find(t => t.name === toolName);
if (!toolDefinition) {
console.error(\`Error: Received request for unknown tool: \${toolName}\`);
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
}
// --- Tool Execution Logic ---
${callToolCode} // This generated code now includes Zod validation
// Fallback error
console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`);
return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] };
});
// --- Main Execution Function ---
async function main() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
} catch (error) {
console.error("Error during server startup:", error);
process.exit(1);
}
}
// --- Cleanup Function ---
async function cleanup() {
console.error("Shutting down MCP server...");
process.exit(0);
}
// Register signal handlers
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// --- Start the Server ---
main().catch((error) => {
console.error("Fatal error in main execution:", error);
process.exit(1);
});
// --- Helper Functions (Included in the generated server code) ---
function formatApiError(error: AxiosError): string {
let message = 'API request failed.';
if (error.response) {
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
const responseData = error.response.data;
const MAX_LEN = 200;
if (typeof responseData === 'string') {
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
} else if (responseData) {
try {
const jsonString = JSON.stringify(responseData);
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
} catch {
message += 'Response: [Could not serialize response data]';
}
} else {
message += 'No response body received.';
}
} else if (error.request) {
message = 'API Network Error: No response received from the server. Check network connectivity or server availability.';
if (error.code) message += \` (Code: \${error.code})\`;
} else {
message = \`API Request Setup Error: \${error.message}\`;
}
return message;
}
/**
* Attempts to dynamically generate and evaluate a Zod schema from a JSON schema.
* WARNING: Uses eval(), which can be a security risk if the schema input is untrusted.
* In this context, the schema originates from the generator/OpenAPI spec, reducing risk.
* @param jsonSchema The JSON Schema object (or boolean).
* @param toolName For error logging.
* @returns The evaluated Zod schema object.
* @throws If schema conversion or evaluation fails.
*/
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
// Handle boolean schemas or invalid input
console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`)
// Fallback to allowing any object - adjust if stricter handling is needed
return z.object({}).passthrough();
}
try {
// Note: jsonSchemaToZod may require specific configurations or adjustments
// depending on the complexity of the JSON Schemas being converted.
const zodSchemaString = jsonSchemaToZod(jsonSchema);
// IMPORTANT: Using eval() to execute the generated Zod schema string.
// This is generally discouraged due to security risks with untrusted input.
// Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec).
// The 'z' variable (from imported zod) must be in scope for eval.
const zodSchema = eval(zodSchemaString);
if (typeof zodSchema?.parse !== 'function') {
throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.');
}
return zodSchema as z.ZodTypeAny;
} catch (err: any) {
console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err);
// Fallback schema in case of conversion/evaluation error
// This allows any object, effectively skipping validation on error.
// Consider throwing the error if validation is critical.
return z.object({}).passthrough();
}
}
`;
}
/**
* Generates the content for the package.json file for a buildable project.
* Adds zod and json-schema-to-zod dependencies.
*/
export function generatePackageJson(serverName, serverVersion) {
const packageData = {
name: serverName,
version: serverVersion,
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
private: true,
type: "module",
main: "build/index.js",
files: ["build", "src"],
scripts: {
"start": "node build/index.js",
"build": "tsc && chmod 755 build/index.js",
"typecheck": "tsc --noEmit",
"prestart": "npm run build"
},
engines: {
"node": ">=18.0.0"
},
dependencies: {
"@modelcontextprotocol/sdk": "^1.9.0",
"axios": "^1.8.4",
"zod": "^3.24.2",
"json-schema-to-zod": "^2.6.1"
},
devDependencies: {
"@types/node": "^18.19.0",
"typescript": "^5.4.5"
// Removed ts-node, tsc-watch
}
};
return JSON.stringify(packageData, null, 2);
}
/**
* Generates the content for the tsconfig.json file for a buildable project.
* Enables stricter type checking.
*/
export function generateTsconfigJson() {
const tsconfigData = {
compilerOptions: {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
};
return JSON.stringify(tsconfigData, null, 2);
}
/**
* Generates the content for the .gitignore file.
*/
export function generateGitignore() {
// Content unchanged from previous version
return `
# Node dependencies
node_modules
# Build output
dist
build
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory
coverage
*.lcov
.nyc_output
# Build artifacts
.grunt
bower_components
# build/Release # Covered by build/ above
jspm_packages/
web_modules/
.lock-wscript
# VS Code files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Caches
.eslintcache
.node_repl_history
.browserslistcache
# Environment variables
.env
.env.*.local
.env.local
`;
}
// --- Helper Functions below are mostly unchanged, except generateCallTool ---
function determineBaseUrl(api, cmdLineBaseUrl) {
// Logic unchanged
if (cmdLineBaseUrl)
return cmdLineBaseUrl.replace(/\/$/, '');
if (api.servers && api.servers.length === 1 && api.servers[0].url)
return api.servers[0].url.replace(/\/$/, '');
if (api.servers && api.servers.length > 1) {
console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
return api.servers[0].url.replace(/\/$/, '');
}
return null;
}
function extractToolsFromApi(api) {
// Logic unchanged
const tools = [];
const usedNames = new Set();
if (!api.paths)
return tools;
for (const [path, pathItem] of Object.entries(api.paths)) {
if (!pathItem)
continue;
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
const operation = pathItem[method];
if (!operation)
continue;
let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) {
console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`);
continue;
}
let finalToolName = baseName;
let counter = 1;
while (usedNames.has(finalToolName))
finalToolName = `${baseName}_${counter++}`;
usedNames.add(finalToolName);
const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`;
const { inputSchema, parameters, requestBody } = generateInputSchema(operation);
tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody });
}
}
return tools;
}
function generateInputSchema(operation) {
// Logic unchanged
const properties = {};
const required = [];
const allParameters = Array.isArray(operation.parameters)
? operation.parameters.map(p => p) : [];
allParameters.forEach(param => {
if (!param.name || !param.schema)
return;
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema);
if (typeof paramSchema === 'object')
paramSchema.description = param.description || paramSchema.description;
properties[param.name] = paramSchema;
if (param.required)
required.push(param.name);
});
let opRequestBody = undefined;
if (operation.requestBody) {
opRequestBody = operation.requestBody;
const jsonContent = opRequestBody.content?.['application/json'];
if (jsonContent?.schema) {
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema);
if (typeof bodySchema === 'object')
bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.';
properties['requestBody'] = bodySchema;
if (opRequestBody.required)
required.push('requestBody');
}
else {
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
if (firstContent) {
const [contentType] = firstContent;
properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` };
if (opRequestBody.required)
required.push('requestBody');
}
}
}
const inputSchema = { type: 'object', properties, ...(required.length > 0 && { required }) };
return { inputSchema, parameters: allParameters, requestBody: opRequestBody };
}
function mapOpenApiSchemaToJsonSchema(schema) {
// Logic mostly unchanged, ensure it handles recursion correctly
if ('$ref' in schema) {
console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`);
return { type: 'object', description: `Unresolved: ${schema.$ref}` };
}
if (typeof schema === 'boolean')
return schema;
const jsonSchema = { ...schema };
if (schema.type === 'integer')
jsonSchema.type = 'number';
delete jsonSchema.nullable;
delete jsonSchema.example;
delete jsonSchema.xml;
delete jsonSchema.externalDocs;
delete jsonSchema.deprecated;
delete jsonSchema.readOnly;
delete jsonSchema.writeOnly;
if (schema.nullable) {
if (Array.isArray(jsonSchema.type)) {
if (!jsonSchema.type.includes('null'))
jsonSchema.type.push('null');
}
else if (typeof jsonSchema.type === 'string')
jsonSchema.type = [jsonSchema.type, 'null'];
else if (!jsonSchema.type)
jsonSchema.type = 'null';
}
if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps = {};
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if (typeof propSchema === 'object' && propSchema !== null)
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema);
else if (typeof propSchema === 'boolean')
mappedProps[key] = propSchema;
}
jsonSchema.properties = mappedProps;
}
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items);
}
return jsonSchema;
}
function generateListTools(tools) {
// Logic unchanged
if (tools.length === 0)
return " // No tools extracted from the OpenAPI spec.";
return tools.map(tool => {
const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
let schemaString;
try {
schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' ');
}
catch (e) {
schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }';
}
return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`;
}).join(''); // Changed join to empty string to avoid extra newline
}
/**
* Generates the 'if/else if' block for the CallTool handler.
* Includes runtime Zod validation.
*/
function generateCallTool(tools, baseUrl) {
if (tools.length === 0)
return ' // No tools defined, so no handlers generated.';
const cases = tools.map(tool => {
const { name, method, path: rawPath, parameters, requestBody } = tool;
const pathParams = parameters.filter(p => p.in === 'path');
const queryParams = parameters.filter(p => p.in === 'query');
const headerParams = parameters.filter(p => p.in === 'header');
// --- Code Generation Snippets ---
// Zod validation block (remains the same)
const argsValidationCode = `
// --- Argument Validation using Zod ---
let validatedArgs: JsonObject;
try {
const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName);
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
validatedArgs = zodSchema.parse(argsToParse);
console.error(\`Arguments validated successfully for tool '\${toolName}'.\`);
} catch (error: any) {
if (error instanceof ZodError) {
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
console.error(validationErrorMessage);
return { content: [{ type: 'text', text: validationErrorMessage }] };
} else {
console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error);
return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] };
}
}
// --- End Argument Validation ---
`;
// URL Path Construction (uses validatedArgs)
let urlPathCode = ` let urlPath = "${rawPath}";\n`;
pathParams.forEach(p => {
urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash
urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`;
});
urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`;
urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`;
// Query Parameters Construction (uses validatedArgs)
let queryParamsCode = ' const queryParams: Record<string, any> = {};\n';
queryParams.forEach(p => {
queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`;
});
// Headers Construction (uses validatedArgs)
let headersCode = ` const headers: Record<string, string> = { 'Accept': 'application/json' };\n`;
headerParams.forEach(p => {
headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`;
});
// **Corrected Request Body Handling**
let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData
let axiosDataProperty = ''; // String part for the Axios config's 'data' property
let requestContentType = 'application/json'; // Default assumption
if (requestBody) { // Only generate body handling if the tool expects one
// Declare the variable *before* config construction
requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`;
// Assign value *after* validation (which sets validatedArgs)
requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`;
requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`;
requestBodyDeclarationCode += ` }\n`;
// Determine Content-Type (must happen before headers are finalized in config)
if (requestBody.content?.['application/json']) {
requestContentType = 'application/json';
}
else if (requestBody.content) {
const firstType = Object.keys(requestBody.content)[0];
if (firstType) {
requestContentType = firstType;
}
}
// Add Content-Type header *if* data might exist
headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`;
headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`;
// Set the string for the Axios config 'data' property
axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data';
}
// --- Assemble the 'if' block for this tool ---
// Ensure correct order: Validation -> Declarations -> Config -> Axios Call
return `
// Handler for tool: ${name}
if (toolName === "${name}") {
try {
${argsValidationCode}
// --- API Call Preparation ---
${urlPathCode}
${queryParamsCode}
${headersCode}
${requestBodyDeclarationCode} // Declare and assign requestBodyData *here*
// --- Axios Request Configuration ---
// Now 'requestBodyData' is declared before being referenced here
const config: AxiosRequestConfig = {
method: "${method.toUpperCase()}",
url: requestUrl,
params: queryParams,
headers: headers,
${axiosDataProperty} // Include data property conditionally
// Add Authentication logic here if needed
};
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
// --- Execute API Call ---
const response = await axios(config);
// --- Process Successful Response ---
let responseText = '';
const contentType = response.headers['content-type']?.toLowerCase() || '';
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
try { responseText = JSON.stringify(response.data, null, 2); }
catch (e) { responseText = "[Error: Failed to stringify JSON response]"; }
} else if (typeof response.data === 'string') {
responseText = response.data;
} else if (response.data !== undefined && response.data !== null) {
responseText = String(response.data);
} else {
responseText = \`(Status: \${response.status} - No body content)\`;
}
return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], };
} catch (error: any) {
// --- Handle Errors (Post-Validation) ---
let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`;
if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); }
else if (error instanceof Error) { errorMessage = error.message; }
else { errorMessage = 'An unexpected error occurred: ' + String(error); }
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack);
return { content: [{ type: "text", text: errorMessage }] };
}
}`;
}).join(' else ');
return cases || ' // No tools defined, so no handlers generated.';
}

View File

@ -1,82 +0,0 @@
#!/usr/bin/env node
import { Command } from 'commander';
import SwaggerParser from '@apidevtools/swagger-parser';
import * as fs from 'fs/promises';
import * as path from 'path';
import { generateMcpServerCode, generatePackageJson, generateTsconfigJson, generateGitignore } from './generator.js';
const program = new Command();
program
.name('openapi-mcp-generator')
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
// Ensure these option definitions are robust
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
.option('-n, --server-name <name>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
.version('2.0.0'); // Match package.json version
// Parse arguments explicitly from process.argv
// This is generally the most reliable way
program.parse(process.argv);
// Retrieve the options AFTER parsing
const options = program.opts();
async function main() {
// Use the parsed options directly
const outputDir = options.output;
const inputSpec = options.input; // Use the parsed input value
const srcDir = path.join(outputDir, 'src');
const serverFilePath = path.join(srcDir, 'index.ts');
const packageJsonPath = path.join(outputDir, 'package.json');
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
const gitignorePath = path.join(outputDir, '.gitignore');
try {
// Use the correct inputSpec variable
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
const api = await SwaggerParser.dereference(inputSpec);
console.error('OpenAPI spec parsed successfully.');
// Use options directly for name/version/baseUrl determination
const serverNameRaw = options.serverName || api.info.title || 'my-mcp-server';
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
const serverVersion = options.serverVersion || api.info.version || '0.1.0';
console.error('Generating server code...');
// Pass inputSpec to generator function if needed for comments, otherwise just options
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
console.error('Generating package.json...');
const packageJsonContent = generatePackageJson(serverName, serverVersion);
console.error('Generating tsconfig.json...');
const tsconfigJsonContent = generateTsconfigJson();
console.error('Generating .gitignore...');
const gitignoreContent = generateGitignore();
console.error(`Creating project directory structure at: ${outputDir}`);
await fs.mkdir(srcDir, { recursive: true });
await fs.writeFile(serverFilePath, serverTsContent);
console.error(` -> Created ${serverFilePath}`);
await fs.writeFile(packageJsonPath, packageJsonContent);
console.error(` -> Created ${packageJsonPath}`);
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
console.error(` -> Created ${tsconfigPath}`);
await fs.writeFile(gitignorePath, gitignoreContent);
console.error(` -> Created ${gitignorePath}`);
console.error("\n---");
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
console.error("\nNext steps:");
console.error(`1. Navigate to the directory: cd ${outputDir}`);
console.error(`2. Install dependencies: npm install`);
console.error(`3. Build the TypeScript code: npm run build`);
console.error(`4. Run the server: npm start`);
console.error(" (This runs the built JavaScript code in build/index.js)");
console.error("---");
}
catch (error) {
console.error('\nError generating MCP server project:', error);
try {
await fs.rm(outputDir, { recursive: true, force: true });
console.error(`Cleaned up partially created directory: ${outputDir}`);
}
catch (cleanupError) {
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
}
process.exit(1);
}
}
main();

View File

@ -1,35 +0,0 @@
export function titleCase(str) {
// Converts snake_case, kebab-case, or path/parts to TitleCase
return str
.toLowerCase()
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
.replace(/^{/, '') // Remove leading { from path params
.replace(/}$/, '') // Remove trailing } from path params
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
}
export function generateOperationId(method, path) {
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
const parts = path.split('/').filter(p => p); // Split and remove empty parts
let name = method.toLowerCase(); // Start with method name
parts.forEach((part, index) => {
if (part.startsWith('{') && part.endsWith('}')) {
// Append 'By' + ParamName only for the *last* path parameter segment
if (index === parts.length - 1) {
name += 'By' + titleCase(part);
}
// Potentially include non-terminal params differently if needed, e.g.:
// else { name += 'With' + titleCase(part); }
}
else {
// Append the static path part in TitleCase
name += titleCase(part);
}
});
// Simple fallback if name is just the method (e.g., GET /)
if (name === method.toLowerCase()) {
name += 'Root';
}
// Ensure first letter is uppercase after potential lowercase method start
name = name.charAt(0).toUpperCase() + name.slice(1);
return name;
}

View File

@ -1,30 +1,39 @@
{
"name": "openapi-mcp-generator",
"version": "2.0.0",
"version": "2.5.0-beta.0",
"description": "Generates MCP server code from OpenAPI specifications",
"license": "MIT",
"author": "Harsha",
"type": "module",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"bin": {
"openapi-mcp-generator": "./build/index.js"
"openapi-mcp-generator": "./dist/index.js"
},
"files": [
"build"
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"start": "node build/index.js",
"start": "node dist/index.js",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"build": "tsc && chmod 755 build/index.js"
"build": "tsc && chmod 755 dist/index.js",
"prepare": "npm run clean && npm run build",
"prepublishOnly": "npm run lint",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
},
"keywords": [
"openapi",
"mcp",
"model-context-protocol",
"generator",
"llm"
"llm",
"ai",
"api"
],
"repository": {
"type": "git",
@ -36,15 +45,21 @@
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.1",
"@modelcontextprotocol/sdk": "^1.9.0",
"axios": "^1.8.4",
"commander": "^13.1.0",
"zod": "^3.24.2"
"openapi-types": "^12.1.3"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"eslint": "^9.24.0",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"typescript": "^5.8.3"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.9.0"
"@modelcontextprotocol/sdk": "^1.9.0",
"zod": "^3.24.2",
"json-schema-to-zod": "^2.4.1"
}
}
}

View File

@ -1,604 +0,0 @@
import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { generateOperationId } from './utils.js';
interface CliOptions {
input: string;
output: string; // This is the directory path
serverName?: string;
serverVersion?: string;
baseUrl?: string;
}
interface McpToolDefinition {
name: string;
description: string;
inputSchema: JSONSchema7 | boolean;
operationId: string;
method: string;
path: string;
parameters: OpenAPIV3.ParameterObject[];
requestBody?: OpenAPIV3.RequestBodyObject;
}
/**
* Generates the TypeScript code content for the server's src/index.ts file.
*/
export function generateMcpServerCode(
api: OpenAPIV3.Document,
options: CliOptions,
serverName: string,
serverVersion: string
): string {
const tools: McpToolDefinition[] = extractToolsFromApi(api);
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
const listToolsCode = generateListTools(tools);
const callToolCode = generateCallTool(tools, determinedBaseUrl);
// --- Template for src/index.ts ---
return `
// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion}
// Source OpenAPI spec: ${options.input}
// Generation date: ${new Date().toISOString()}
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// Import Schemas and Types from /types subpath with .js extension
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool,
type CallToolResult,
type CallToolRequest // Added type for the request parameter
} from "@modelcontextprotocol/sdk/types.js";
// Zod for runtime validation
import { z, ZodError } from 'zod';
// Library to convert JSON Schema to Zod schema string at runtime
import { jsonSchemaToZod } from 'json-schema-to-zod';
// Define JsonObject locally as a utility type
type JsonObject = Record<string, any>;
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
// --- Server Configuration ---
const SERVER_NAME = "${serverName}";
const SERVER_VERSION = "${serverVersion}";
const API_BASE_URL = "${determinedBaseUrl || ''}";
// --- Server Instance ---
const server = new Server(
{
name: SERVER_NAME,
version: SERVER_VERSION
},
{
capabilities: {
tools: {}
}
}
);
// --- Tool Definitions (for ListTools response) ---
// Corrected: Use Tool[] type
const toolsList: Tool[] = [
${listToolsCode}
];
// --- Request Handlers ---
// 1. List Available Tools Handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: toolsList,
};
});
// 2. Call Tool Handler
// Corrected: Added explicit type for 'request' parameter
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
const { name: toolName, arguments: toolArgs } = request.params;
const toolDefinition = toolsList.find(t => t.name === toolName);
if (!toolDefinition) {
console.error(\`Error: Received request for unknown tool: \${toolName}\`);
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
}
// --- Tool Execution Logic ---
${callToolCode} // This generated code now includes Zod validation
// Fallback error
console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`);
return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] };
});
// --- Main Execution Function ---
async function main() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
} catch (error) {
console.error("Error during server startup:", error);
process.exit(1);
}
}
// --- Cleanup Function ---
async function cleanup() {
console.error("Shutting down MCP server...");
process.exit(0);
}
// Register signal handlers
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// --- Start the Server ---
main().catch((error) => {
console.error("Fatal error in main execution:", error);
process.exit(1);
});
// --- Helper Functions (Included in the generated server code) ---
function formatApiError(error: AxiosError): string {
let message = 'API request failed.';
if (error.response) {
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
const responseData = error.response.data;
const MAX_LEN = 200;
if (typeof responseData === 'string') {
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
} else if (responseData) {
try {
const jsonString = JSON.stringify(responseData);
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
} catch {
message += 'Response: [Could not serialize response data]';
}
} else {
message += 'No response body received.';
}
} else if (error.request) {
message = 'API Network Error: No response received from the server. Check network connectivity or server availability.';
if (error.code) message += \` (Code: \${error.code})\`;
} else {
message = \`API Request Setup Error: \${error.message}\`;
}
return message;
}
/**
* Attempts to dynamically generate and evaluate a Zod schema from a JSON schema.
* WARNING: Uses eval(), which can be a security risk if the schema input is untrusted.
* In this context, the schema originates from the generator/OpenAPI spec, reducing risk.
* @param jsonSchema The JSON Schema object (or boolean).
* @param toolName For error logging.
* @returns The evaluated Zod schema object.
* @throws If schema conversion or evaluation fails.
*/
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
// Handle boolean schemas or invalid input
console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`)
// Fallback to allowing any object - adjust if stricter handling is needed
return z.object({}).passthrough();
}
try {
// Note: jsonSchemaToZod may require specific configurations or adjustments
// depending on the complexity of the JSON Schemas being converted.
const zodSchemaString = jsonSchemaToZod(jsonSchema);
// IMPORTANT: Using eval() to execute the generated Zod schema string.
// This is generally discouraged due to security risks with untrusted input.
// Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec).
// The 'z' variable (from imported zod) must be in scope for eval.
const zodSchema = eval(zodSchemaString);
if (typeof zodSchema?.parse !== 'function') {
throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.');
}
return zodSchema as z.ZodTypeAny;
} catch (err: any) {
console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err);
// Fallback schema in case of conversion/evaluation error
// This allows any object, effectively skipping validation on error.
// Consider throwing the error if validation is critical.
return z.object({}).passthrough();
}
}
`;
}
/**
* Generates the content for the package.json file for a buildable project.
* Adds zod and json-schema-to-zod dependencies.
*/
export function generatePackageJson(serverName: string, serverVersion: string): string {
const packageData = {
name: serverName,
version: serverVersion,
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
private: true,
type: "module",
main: "build/index.js",
files: [ "build", "src" ],
scripts: {
"start": "node build/index.js",
"build": "tsc && chmod 755 build/index.js",
"typecheck": "tsc --noEmit",
"prestart": "npm run build"
},
engines: {
"node": ">=18.0.0"
},
dependencies: {
"@modelcontextprotocol/sdk": "^1.9.0",
"axios": "^1.8.4",
"zod": "^3.24.2",
"json-schema-to-zod": "^2.6.1"
},
devDependencies: {
"@types/node": "^18.19.0",
"typescript": "^5.4.5"
// Removed ts-node, tsc-watch
}
};
return JSON.stringify(packageData, null, 2);
}
/**
* Generates the content for the tsconfig.json file for a buildable project.
* Enables stricter type checking.
*/
export function generateTsconfigJson(): string {
const tsconfigData = {
compilerOptions: {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
};
return JSON.stringify(tsconfigData, null, 2);
}
/**
* Generates the content for the .gitignore file.
*/
export function generateGitignore(): string {
// Content unchanged from previous version
return `
# Node dependencies
node_modules
# Build output
dist
build
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory
coverage
*.lcov
.nyc_output
# Build artifacts
.grunt
bower_components
# build/Release # Covered by build/ above
jspm_packages/
web_modules/
.lock-wscript
# VS Code files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Caches
.eslintcache
.node_repl_history
.browserslistcache
# Environment variables
.env
.env.*.local
.env.local
`;
}
// --- Helper Functions below are mostly unchanged, except generateCallTool ---
function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null {
// Logic unchanged
if (cmdLineBaseUrl) return cmdLineBaseUrl.replace(/\/$/, '');
if (api.servers && api.servers.length === 1 && api.servers[0].url) return api.servers[0].url.replace(/\/$/, '');
if (api.servers && api.servers.length > 1) {
console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
return api.servers[0].url.replace(/\/$/, '');
}
return null;
}
function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
// Logic unchanged
const tools: McpToolDefinition[] = [];
const usedNames = new Set<string>();
if (!api.paths) return tools;
for (const [path, pathItem] of Object.entries(api.paths)) {
if (!pathItem) continue;
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
const operation = pathItem[method];
if (!operation) continue;
let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) {
console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`);
continue;
}
let finalToolName = baseName;
let counter = 1;
while (usedNames.has(finalToolName)) finalToolName = `${baseName}_${counter++}`;
usedNames.add(finalToolName);
const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`;
const { inputSchema, parameters, requestBody } = generateInputSchema(operation);
tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody });
}
}
return tools;
}
function generateInputSchema(operation: OpenAPIV3.OperationObject): { inputSchema: JSONSchema7 | boolean, parameters: OpenAPIV3.ParameterObject[], requestBody?: OpenAPIV3.RequestBodyObject } {
// Logic unchanged
const properties: { [key: string]: JSONSchema7 | boolean } = {};
const required: string[] = [];
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
? operation.parameters.map(p => p as OpenAPIV3.ParameterObject) : [];
allParameters.forEach(param => {
if (!param.name || !param.schema) return;
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
if (typeof paramSchema === 'object') paramSchema.description = param.description || paramSchema.description;
properties[param.name] = paramSchema;
if (param.required) required.push(param.name);
});
let opRequestBody: OpenAPIV3.RequestBodyObject | undefined = undefined;
if (operation.requestBody) {
opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
const jsonContent = opRequestBody.content?.['application/json'];
if (jsonContent?.schema) {
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
if (typeof bodySchema === 'object') bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.';
properties['requestBody'] = bodySchema;
if (opRequestBody.required) required.push('requestBody');
} else {
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
if(firstContent) {
const [contentType] = firstContent;
properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` };
if (opRequestBody.required) required.push('requestBody');
}
}
}
const inputSchema: JSONSchema7 = { type: 'object', properties, ...(required.length > 0 && { required }) };
return { inputSchema, parameters: allParameters, requestBody: opRequestBody };
}
function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean {
// Logic mostly unchanged, ensure it handles recursion correctly
if ('$ref' in schema) {
console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`);
return { type: 'object', description: `Unresolved: ${schema.$ref}` };
}
if (typeof schema === 'boolean') return schema;
const jsonSchema: JSONSchema7 = { ...schema } as any;
if (schema.type === 'integer') jsonSchema.type = 'number';
delete (jsonSchema as any).nullable; delete (jsonSchema as any).example; delete (jsonSchema as any).xml;
delete (jsonSchema as any).externalDocs; delete (jsonSchema as any).deprecated; delete (jsonSchema as any).readOnly; delete (jsonSchema as any).writeOnly;
if (schema.nullable) {
if (Array.isArray(jsonSchema.type)) { if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null'); }
else if (typeof jsonSchema.type === 'string') jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
else if (!jsonSchema.type) jsonSchema.type = 'null';
}
if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if (typeof propSchema === 'object' && propSchema !== null) mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
else if (typeof propSchema === 'boolean') mappedProps[key] = propSchema;
}
jsonSchema.properties = mappedProps;
}
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject);
}
return jsonSchema;
}
function generateListTools(tools: McpToolDefinition[]): string {
// Logic unchanged
if (tools.length === 0) return " // No tools extracted from the OpenAPI spec.";
return tools.map(tool => {
const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
let schemaString; try { schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' '); }
catch (e) { schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }'; }
return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`;
}).join(''); // Changed join to empty string to avoid extra newline
}
/**
* Generates the 'if/else if' block for the CallTool handler.
* Includes runtime Zod validation.
*/
function generateCallTool(tools: McpToolDefinition[], baseUrl: string | null): string {
if (tools.length === 0) return ' // No tools defined, so no handlers generated.';
const cases = tools.map(tool => {
const { name, method, path: rawPath, parameters, requestBody } = tool;
const pathParams = parameters.filter(p => p.in === 'path');
const queryParams = parameters.filter(p => p.in === 'query');
const headerParams = parameters.filter(p => p.in === 'header');
// --- Code Generation Snippets ---
// Zod validation block (remains the same)
const argsValidationCode = `
// --- Argument Validation using Zod ---
let validatedArgs: JsonObject;
try {
const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName);
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
validatedArgs = zodSchema.parse(argsToParse);
console.error(\`Arguments validated successfully for tool '\${toolName}'.\`);
} catch (error: any) {
if (error instanceof ZodError) {
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
console.error(validationErrorMessage);
return { content: [{ type: 'text', text: validationErrorMessage }] };
} else {
console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error);
return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] };
}
}
// --- End Argument Validation ---
`;
// URL Path Construction (uses validatedArgs)
let urlPathCode = ` let urlPath = "${rawPath}";\n`;
pathParams.forEach(p => {
urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash
urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`;
});
urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`;
urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`;
// Query Parameters Construction (uses validatedArgs)
let queryParamsCode = ' const queryParams: Record<string, any> = {};\n';
queryParams.forEach(p => {
queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`;
});
// Headers Construction (uses validatedArgs)
let headersCode = ` const headers: Record<string, string> = { 'Accept': 'application/json' };\n`;
headerParams.forEach(p => {
headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`;
});
// **Corrected Request Body Handling**
let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData
let axiosDataProperty = ''; // String part for the Axios config's 'data' property
let requestContentType = 'application/json'; // Default assumption
if (requestBody) { // Only generate body handling if the tool expects one
// Declare the variable *before* config construction
requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`;
// Assign value *after* validation (which sets validatedArgs)
requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`;
requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`;
requestBodyDeclarationCode += ` }\n`;
// Determine Content-Type (must happen before headers are finalized in config)
if (requestBody.content?.['application/json']) {
requestContentType = 'application/json';
} else if (requestBody.content) {
const firstType = Object.keys(requestBody.content)[0];
if (firstType) { requestContentType = firstType; }
}
// Add Content-Type header *if* data might exist
headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`;
headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`;
// Set the string for the Axios config 'data' property
axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data';
}
// --- Assemble the 'if' block for this tool ---
// Ensure correct order: Validation -> Declarations -> Config -> Axios Call
return `
// Handler for tool: ${name}
if (toolName === "${name}") {
try {
${argsValidationCode}
// --- API Call Preparation ---
${urlPathCode}
${queryParamsCode}
${headersCode}
${requestBodyDeclarationCode} // Declare and assign requestBodyData *here*
// --- Axios Request Configuration ---
// Now 'requestBodyData' is declared before being referenced here
const config: AxiosRequestConfig = {
method: "${method.toUpperCase()}",
url: requestUrl,
params: queryParams,
headers: headers,
${axiosDataProperty} // Include data property conditionally
// Add Authentication logic here if needed
};
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
// --- Execute API Call ---
const response = await axios(config);
// --- Process Successful Response ---
let responseText = '';
const contentType = response.headers['content-type']?.toLowerCase() || '';
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
try { responseText = JSON.stringify(response.data, null, 2); }
catch (e) { responseText = "[Error: Failed to stringify JSON response]"; }
} else if (typeof response.data === 'string') {
responseText = response.data;
} else if (response.data !== undefined && response.data !== null) {
responseText = String(response.data);
} else {
responseText = \`(Status: \${response.status} - No body content)\`;
}
return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], };
} catch (error: any) {
// --- Handle Errors (Post-Validation) ---
let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`;
if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); }
else if (error instanceof Error) { errorMessage = error.message; }
else { errorMessage = 'An unexpected error occurred: ' + String(error); }
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack);
return { content: [{ type: "text", text: errorMessage }] };
}
}`;
}).join(' else ');
return cases || ' // No tools defined, so no handlers generated.';
}

View File

@ -0,0 +1,178 @@
/**
* Generator for configuration files for MCP servers
*/
/**
* Generates the content of tsconfig.json for the MCP server
*
* @returns JSON string for tsconfig.json
*/
export function generateTsconfigJson(): string {
const tsconfigData = {
compilerOptions: {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"module": "Node16",
"moduleResolution": "Node16",
"noEmit": false,
"outDir": "./build",
"declaration": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "**/*.test.ts"]
};
return JSON.stringify(tsconfigData, null, 2);
}
/**
* Generates the content of .gitignore for the MCP server
*
* @returns Content for .gitignore
*/
export function generateGitignore(): string {
return `# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
build
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage
coverage
*.lcov
.nyc_output
# Build artifacts
.grunt
bower_components
jspm_packages/
web_modules/
.lock-wscript
# Editor settings
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
.idea
*.sublime-workspace
*.sublime-project
# Caches
.eslintcache
.stylelintcache
.node_repl_history
.browserslistcache
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS specific
.DS_Store
Thumbs.db
`;
}
/**
* Generates the content of .eslintrc.json for the MCP server
*
* @returns JSON string for .eslintrc.json
*/
export function generateEslintConfig(): string {
const eslintConfig = {
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"env": {
"node": true,
"es2022": true
},
"rules": {
"no-console": ["error", { "allow": ["error", "warn"] }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
};
return JSON.stringify(eslintConfig, null, 2);
}
/**
* Generates the content of jest.config.js for the MCP server
*
* @returns Content for jest.config.js
*/
export function generateJestConfig(): string {
return `export default {
preset: 'ts-jest',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
},
],
},
};
`;
}
/**
* Generates the content of .prettierrc for the MCP server
*
* @returns JSON string for .prettierrc
*/
export function generatePrettierConfig(): string {
const prettierConfig = {
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
};
return JSON.stringify(prettierConfig, null, 2);
}

96
src/generator/env-file.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* Generator for .env file and .env.example file
*/
import { OpenAPIV3 } from 'openapi-types';
import { getEnvVarName } from '../utils/security.js';
/**
* Generates the content of .env.example file for the MCP server
*
* @param securitySchemes Security schemes from the OpenAPI spec
* @returns Content for .env.example file
*/
export function generateEnvExample(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
let content = `# MCP Server Environment Variables
# Copy this file to .env and fill in the values
# Server configuration
PORT=3000
LOG_LEVEL=info
`;
// Add security scheme environment variables with examples
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
content += `# API Authentication\n`;
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
if ('$ref' in schemeOrRef) {
content += `# ${name} - Referenced security scheme (reference not resolved)\n`;
continue;
}
const scheme = schemeOrRef;
if (scheme.type === 'apiKey') {
const varName = getEnvVarName(name, 'API_KEY');
content += `${varName}=your_api_key_here\n`;
}
else if (scheme.type === 'http') {
if (scheme.scheme?.toLowerCase() === 'bearer') {
const varName = getEnvVarName(name, 'BEARER_TOKEN');
content += `${varName}=your_bearer_token_here\n`;
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
const usernameVar = getEnvVarName(name, 'BASIC_USERNAME');
const passwordVar = getEnvVarName(name, 'BASIC_PASSWORD');
content += `${usernameVar}=your_username_here\n`;
content += `${passwordVar}=your_password_here\n`;
}
}
else if (scheme.type === 'oauth2') {
content += `# OAuth2 authentication (${scheme.flows ? Object.keys(scheme.flows).join(', ') : 'unknown'} flow)\n`;
const varName = `OAUTH_TOKEN_${name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`;
content += `${varName}=your_oauth_token_here\n`;
}
}
} else {
content += `# No API authentication required\n`;
}
content += `\n# Add any other environment variables your API might need\n`;
return content;
}
/**
* Generates dotenv configuration code for the MCP server
*
* @returns Code for loading environment variables
*/
export function generateDotenvConfig(): string {
return `
/**
* Load environment variables from .env file
*/
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load environment variables from .env file
const result = dotenv.config({ path: path.resolve(__dirname, '../.env') });
if (result.error) {
console.warn('Warning: No .env file found or error loading .env file.');
console.warn('Using default environment variables.');
}
export const config = {
port: process.env.PORT || '3000',
logLevel: process.env.LOG_LEVEL || 'info',
};
`;
}

9
src/generator/index.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* Generator module exports
*/
export * from './server-code.js';
export * from './package-json.js';
export * from './config-files.js';
export * from './env-file.js';
export * from './oauth-docs.js';
export * from './web-server.js';

109
src/generator/oauth-docs.ts Normal file
View File

@ -0,0 +1,109 @@
/**
* Generator for OAuth2 documentation
*/
import { OpenAPIV3 } from 'openapi-types';
/**
* Generates documentation about OAuth2 configuration
*
* @param securitySchemes Security schemes from OpenAPI spec
* @returns Markdown documentation about OAuth2 configuration
*/
export function generateOAuth2Docs(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
if (!securitySchemes) {
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
}
let oauth2Schemes: {name: string, scheme: OpenAPIV3.OAuth2SecurityScheme}[] = [];
// Find OAuth2 schemes
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
if ('$ref' in schemeOrRef) continue;
if (schemeOrRef.type === 'oauth2') {
oauth2Schemes.push({
name,
scheme: schemeOrRef
});
}
}
if (oauth2Schemes.length === 0) {
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
}
let content = `# OAuth2 Configuration
This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authentication in the following ways:
1. **Using a pre-acquired token**: You provide a token you've already obtained
2. **Using client credentials flow**: The server automatically acquires a token using your client ID and secret
## Environment Variables
`;
// Document each OAuth2 scheme
for (const {name, scheme} of oauth2Schemes) {
content += `### ${name}\n\n`;
if (scheme.description) {
content += `${scheme.description}\n\n`;
}
content += "**Configuration Variables:**\n\n";
const envVarPrefix = name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
content += `- \`OAUTH_CLIENT_ID_${envVarPrefix}\`: Your OAuth client ID\n`;
content += `- \`OAUTH_CLIENT_SECRET_${envVarPrefix}\`: Your OAuth client secret\n`;
if (scheme.flows?.clientCredentials) {
content += `- \`OAUTH_SCOPES_${envVarPrefix}\`: Space-separated list of scopes to request (optional)\n`;
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (optional if using client credentials)\n\n`;
content += "**Client Credentials Flow:**\n\n";
content += `- Token URL: \`${scheme.flows.clientCredentials.tokenUrl}\`\n`;
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
content += "\n**Available Scopes:**\n\n";
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
content += `- \`${scope}\`: ${description}\n`;
}
}
content += "\n";
}
if (scheme.flows?.authorizationCode) {
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (required for authorization code flow)\n\n`;
content += "**Authorization Code Flow:**\n\n";
content += `- Authorization URL: \`${scheme.flows.authorizationCode.authorizationUrl}\`\n`;
content += `- Token URL: \`${scheme.flows.authorizationCode.tokenUrl}\`\n`;
if (scheme.flows.authorizationCode.scopes && Object.keys(scheme.flows.authorizationCode.scopes).length > 0) {
content += "\n**Available Scopes:**\n\n";
for (const [scope, description] of Object.entries(scheme.flows.authorizationCode.scopes)) {
content += `- \`${scope}\`: ${description}\n`;
}
}
content += "\n";
}
}
content += `## Token Caching
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the \`expires_in\` parameter in the token response) minus 60 seconds as a safety margin.
When making API requests, the server will:
1. Check for a cached token that's still valid
2. Use the cached token if available
3. Request a new token if no valid cached token exists
`;
return content;
}

View File

@ -0,0 +1,65 @@
/**
* Generates the content of package.json for the MCP server
*
* @param serverName Server name
* @param serverVersion Server version
* @param includeWebDeps Whether to include web server dependencies
* @returns JSON string for package.json
*/
export function generatePackageJson(
serverName: string,
serverVersion: string,
includeWebDeps: boolean = false
): string {
const packageData: any = {
name: serverName,
version: serverVersion,
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
private: true,
type: "module",
main: "build/index.js",
files: [ "build", "src" ],
scripts: {
"start": "node build/index.js",
"build": "tsc && chmod 755 build/index.js",
"typecheck": "tsc --noEmit",
"prestart": "npm run build"
},
engines: {
"node": ">=20.0.0"
},
dependencies: {
"@modelcontextprotocol/sdk": "^1.9.0",
"axios": "^1.8.4",
"dotenv": "^16.4.5",
"zod": "^3.24.2",
"json-schema-to-zod": "^2.4.1"
},
devDependencies: {
"@types/node": "^18.19.0",
"typescript": "^5.8.3"
}
};
// Add web server dependencies if needed
if (includeWebDeps) {
packageData.dependencies = {
...packageData.dependencies,
"express": "^4.18.2",
"cors": "^2.8.5",
"uuid": "^11.1.0"
};
packageData.devDependencies = {
...packageData.devDependencies,
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/uuid": "^10.0.0"
};
// Add a script to start in web mode
packageData.scripts["start:web"] = "node build/index.js --transport=web";
}
return JSON.stringify(packageData, null, 2);
}

View File

@ -0,0 +1,227 @@
import { OpenAPIV3 } from 'openapi-types';
import { CliOptions } from '../types/index.js';
import { extractToolsFromApi } from '../parser/extract-tools.js';
import { determineBaseUrl } from '../utils/index.js';
import {
generateToolDefinitionMap,
generateCallToolHandler,
generateListToolsHandler
} from '../utils/code-gen.js';
import { generateExecuteApiToolFunction } from '../utils/security.js';
/**
* Generates the TypeScript code for the MCP server
*
* @param api OpenAPI document
* @param options CLI options
* @param serverName Server name
* @param serverVersion Server version
* @returns Generated TypeScript code
*/
export function generateMcpServerCode(
api: OpenAPIV3.Document,
options: CliOptions,
serverName: string,
serverVersion: string
): string {
// Extract tools from API
const tools = extractToolsFromApi(api);
// Determine base URL
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
// Generate code for tool definition map
const toolDefinitionMapCode = generateToolDefinitionMap(tools, api.components?.securitySchemes);
// Generate code for API tool execution
const executeApiToolFunctionCode = generateExecuteApiToolFunction(api.components?.securitySchemes);
// Generate code for request handlers
const callToolHandlerCode = generateCallToolHandler();
const listToolsHandlerCode = generateListToolsHandler();
// Determine if we should include web server code
const includeWebServer = options.transport === 'web';
const webServerImport = includeWebServer
? `\nimport { setupWebServer } from "./web-server.js";`
: '';
// Define transport based on options
const transportCode = includeWebServer
? `// Set up Web Server transport
try {
await setupWebServer(server, ${options.port || 3000});
} catch (error) {
console.error("Error setting up web server:", error);
process.exit(1);
}`
: `// Set up stdio transport
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
} catch (error) {
console.error("Error during server startup:", error);
process.exit(1);
}`;
// Generate the full server code
return `#!/usr/bin/env node
/**
* MCP Server generated from OpenAPI spec for ${serverName} v${serverVersion}
* Generated on: ${new Date().toISOString()}
*/
// Load environment variables from .env file
import dotenv from 'dotenv';
dotenv.config();
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool,
type CallToolResult,
type CallToolRequest
} from "@modelcontextprotocol/sdk/types.js";${webServerImport}
import { z, ZodError } from 'zod';
import { jsonSchemaToZod } from 'json-schema-to-zod';
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
/**
* Type definition for JSON objects
*/
type JsonObject = Record<string, any>;
/**
* Interface for MCP Tool Definition
*/
interface McpToolDefinition {
name: string;
description: string;
inputSchema: any;
method: string;
pathTemplate: string;
executionParameters: { name: string, in: string }[];
requestBodyContentType?: string;
securityRequirements: any[];
}
/**
* Server configuration
*/
export const SERVER_NAME = "${serverName}";
export const SERVER_VERSION = "${serverVersion}";
export const API_BASE_URL = "${determinedBaseUrl || ''}";
/**
* MCP Server instance
*/
const server = new Server(
{ name: SERVER_NAME, version: SERVER_VERSION },
{ capabilities: { tools: {} } }
);
/**
* Map of tool definitions by name
*/
const toolDefinitionMap: Map<string, McpToolDefinition> = new Map([
${toolDefinitionMapCode}
]);
/**
* Security schemes from the OpenAPI spec
*/
const securitySchemes = ${JSON.stringify(api.components?.securitySchemes || {}, null, 2).replace(/^/gm, ' ')};
${listToolsHandlerCode}
${callToolHandlerCode}
${executeApiToolFunctionCode}
/**
* Main function to start the server
*/
async function main() {
${transportCode}
}
/**
* Cleanup function for graceful shutdown
*/
async function cleanup() {
console.error("Shutting down MCP server...");
process.exit(0);
}
// Register signal handlers
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// Start the server
main().catch((error) => {
console.error("Fatal error in main execution:", error);
process.exit(1);
});
/**
* Formats API errors for better readability
*
* @param error Axios error
* @returns Formatted error message
*/
function formatApiError(error: AxiosError): string {
let message = 'API request failed.';
if (error.response) {
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
const responseData = error.response.data;
const MAX_LEN = 200;
if (typeof responseData === 'string') {
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
}
else if (responseData) {
try {
const jsonString = JSON.stringify(responseData);
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
} catch {
message += 'Response: [Could not serialize data]';
}
}
else {
message += 'No response body received.';
}
} else if (error.request) {
message = 'API Network Error: No response received from server.';
if (error.code) message += \` (Code: \${error.code})\`;
} else {
message += \`API Request Setup Error: \${error.message}\`;
}
return message;
}
/**
* Converts a JSON Schema to a Zod schema for runtime validation
*
* @param jsonSchema JSON Schema
* @param toolName Tool name for error reporting
* @returns Zod schema
*/
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
return z.object({}).passthrough();
}
try {
const zodSchemaString = jsonSchemaToZod(jsonSchema);
const zodSchema = eval(zodSchemaString);
if (typeof zodSchema?.parse !== 'function') {
throw new Error('Eval did not produce a valid Zod schema.');
}
return zodSchema as z.ZodTypeAny;
} catch (err: any) {
console.error(\`Failed to generate/evaluate Zod schema for '\${toolName}':\`, err);
return z.object({}).passthrough();
}
}
`;
}

507
src/generator/web-server.ts Normal file
View File

@ -0,0 +1,507 @@
/**
* Generator for web server code for the MCP server
*/
/**
* Generates web server code for the MCP server (using Express and SSE)
*
* @param port Server port (default: 3000)
* @returns Generated code for the web server
*/
export function generateWebServerCode(port: number = 3000): string {
return `
/**
* Web server setup for HTTP-based MCP communication
*/
import express, { Request, Response } from 'express';
import cors from 'cors';
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
// Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js';
/**
* Sets up a web server for the MCP server using Server-Sent Events (SSE)
*
* @param server The MCP Server instance
* @param port The port to listen on (default: ${port})
* @returns The Express app instance
*/
export async function setupWebServer(server: Server, port = ${port}) {
// Create Express app
const app = express();
// Enable CORS
app.use(cors());
// Parse JSON requests
app.use(express.json());
// Add a simple health check endpoint
app.get('/health', (_, res) => {
res.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
});
// Store active SSE transports by session ID
const transports: {[sessionId: string]: SSEServerTransport} = {};
// SSE endpoint for clients to connect to
app.get("/sse", async (req: Request, res: Response) => {
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Enable CORS for SSE
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
// Send initial comment to establish connection
res.write(':\\n\\n');
// Create new transport for this client
const transport = new SSEServerTransport('/api/messages', res);
const sessionId = transport.sessionId;
console.error(\`New SSE connection established: \${sessionId}\`);
transports[sessionId] = transport;
// Clean up on connection close
req.on('close', () => {
console.error(\`SSE connection closed: \${sessionId}\`);
delete transports[sessionId];
});
// Connect the transport to the MCP server
try {
await server.connect(transport);
} catch (error) {
console.error(\`Error connecting transport for session \${sessionId}:\`, error);
// Don't try to send errors to the client here, as headers may already be sent
}
});
// API endpoint for clients to send messages
app.post("/api/messages", async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
if (!sessionId) {
return res.status(400).send('Missing sessionId query parameter');
}
const transport = transports[sessionId];
if (!transport) {
return res.status(404).send('No active session found with the provided sessionId');
}
try {
await transport.handlePostMessage(req, res);
} catch (error) {
console.error(\`Error handling message for session \${sessionId}:\`, error);
// If the response hasn't been sent yet, send an error response
if (!res.headersSent) {
res.status(500).send('Internal server error processing message');
}
}
});
// Static files for the web client (if any)
app.use(express.static('public'));
// Start the server
app.listen(port, () => {
console.error(\`MCP Web Server running at http://localhost:\${port}\`);
console.error(\`- SSE Endpoint: http://localhost:\${port}/sse\`);
console.error(\`- Messages Endpoint: http://localhost:\${port}/api/messages?sessionId=YOUR_SESSION_ID\`);
console.error(\`- Health Check: http://localhost:\${port}/health\`);
});
return app;
}
`;
}
/**
* Generates HTML client for testing the MCP server
*
* @param serverName The name of the MCP server
* @returns HTML content for the test client
*/
export function generateTestClientHtml(serverName: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${serverName} MCP Test Client</title>
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
h1 { margin-bottom: 10px; }
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
}
#conversation {
flex: 1;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.input-area {
display: flex;
margin-bottom: 20px;
}
#userInput {
flex: 1;
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
#sendButton {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
#sendButton:hover { background-color: #45a049; }
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 5px;
}
.user {
background-color: #e7f4ff;
align-self: flex-end;
}
.server {
background-color: #f1f1f1;
}
.system {
background-color: #fffde7;
color: #795548;
font-style: italic;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #f8f8f8;
padding: 2px 4px;
border-radius: 3px;
}
.status {
color: #666;
font-style: italic;
margin-bottom: 10px;
}
#debug {
margin-top: 20px;
background-color: #f8f8f8;
padding: 10px;
border-radius: 5px;
display: none;
}
.debug-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
#showDebug {
margin-top: 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 3px;
}
#debugLog {
max-height: 200px;
overflow-y: auto;
background-color: #111;
color: #0f0;
font-family: monospace;
padding: 5px;
margin-top: 10px;
}
.clear-debug {
padding: 3px 8px;
background-color: #f44336;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>${serverName} MCP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled>
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<script>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
const statusEl = document.getElementById('status');
const showDebugBtn = document.getElementById('showDebug');
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
const clearDebugBtn = document.getElementById('clearDebug');
let sessionId = null;
let messageId = 1;
let eventSource = null;
// Debug logging
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
const entry = document.createElement('div');
entry.innerHTML = \`<span style="color:#aaa;">\${timestamp}</span> <span style="color:#58a6ff;">\${type}:</span> \${message}\`;
debugLog.appendChild(entry);
debugLog.scrollTop = debugLog.scrollHeight;
console.log(\`\${type}: \${message}\`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
} else {
debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
}
});
// Clear debug logs
clearDebugBtn.addEventListener('click', () => {
debugLog.innerHTML = '';
});
// Connect to SSE endpoint
function connect() {
statusEl.textContent = 'Connecting...';
log('INFO', 'Connecting to SSE endpoint...');
// Close existing connection if any
if (eventSource) {
eventSource.close();
log('INFO', 'Closed existing connection');
}
eventSource = new EventSource('/sse');
eventSource.onopen = () => {
log('INFO', 'SSE connection opened');
statusEl.textContent = 'Connected, waiting for session ID...';
};
eventSource.onerror = (error) => {
log('ERROR', \`SSE connection error: \${error}\`);
statusEl.textContent = 'Connection error. Reconnecting in 3s...';
setTimeout(connect, 3000);
};
// Handle all SSE events
eventSource.onmessage = (event) => {
log('RAW', event.data);
try {
const data = JSON.parse(event.data);
// The MCP SSE transport sends messages in jsonrpc format
// Check if this is a notification with clientInfo containing sessionId
if (data.method === 'notification' && data.params?.clientInfo?.sessionId) {
sessionId = data.params.clientInfo.sessionId;
statusEl.textContent = \`Connected (Session ID: \${sessionId})\`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', \`Connected with session ID: \${sessionId}\`);
log('INFO', \`Received session ID from MCP notification: \${sessionId}\`);
return;
}
// Check for legacy session_id format (our custom format)
if (data.type === 'session_id') {
sessionId = data.session_id;
statusEl.textContent = \`Connected (Session ID: \${sessionId})\`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', \`Connected with session ID: \${sessionId}\`);
log('INFO', \`Received session ID from legacy format: \${sessionId}\`);
return;
}
// Handle jsonrpc responses
if (data.jsonrpc === '2.0' && data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
userInput.focus();
return;
}
// Handle normal server messages with content
if (data.content) {
appendMessage('server', JSON.stringify(data, null, 2));
userInput.focus();
} else {
log('INFO', \`Received other message: \${JSON.stringify(data)}\`);
}
} catch (error) {
log('ERROR', \`Error parsing SSE message: \${error.message}\`);
appendMessage('system', \`Error parsing message: \${event.data}\`);
}
};
return eventSource;
}
// Send a message to the server
async function sendMessage() {
const text = userInput.value.trim();
if (!text || !sessionId) return;
appendMessage('user', text);
userInput.value = '';
log('INFO', \`Sending message: \${text}\`);
try {
const parts = text.split(' ');
const toolName = parts[0];
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'callTool',
params: {
name: toolName,
arguments: parseArguments(text)
}
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch(\`/api/messages?sessionId=\${sessionId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', \`Error response: \${response.status} \${response.statusText} \${errorText}\`);
appendMessage('system', \`Error: \${response.status} \${response.statusText}\\n\${errorText}\`);
} else {
log('INFO', \`Request sent successfully\`);
// Note: We don't handle the response content here because the response
// will come through the SSE connection, not this fetch response
}
} catch (error) {
log('ERROR', \`Error sending message: \${error.message}\`);
appendMessage('system', \`Error sending message: \${error.message}\`);
}
}
// Try to parse arguments from user input
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
const parts = text.split(' ');
if (parts.length <= 1) return {};
const args = {};
// Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const equalsIndex = part.indexOf('=');
if (equalsIndex > 0) {
const key = part.substring(0, equalsIndex);
const value = part.substring(equalsIndex + 1);
// Try to parse as number or boolean if possible
if (value === 'true') args[key] = true;
else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value);
else args[key] = value;
}
}
return args;
}
// Add a message to the conversation
function appendMessage(sender, text) {
const messageDiv = document.createElement('div');
messageDiv.className = \`message \${sender}\`;
// Format as code block if it looks like JSON
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = text;
pre.appendChild(code);
messageDiv.appendChild(pre);
} else {
messageDiv.textContent = text;
}
conversation.appendChild(messageDiv);
conversation.scrollTop = conversation.scrollHeight;
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// Connect on page load
appendMessage('system', 'Connecting to server...');
connect();
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) eventSource.close();
});
</script>
</body>
</html>`;
}

View File

@ -1,73 +1,114 @@
#!/usr/bin/env node
/**
* OpenAPI to MCP Generator
*
* This tool generates a Model Context Protocol (MCP) server from an OpenAPI specification.
* It creates a Node.js project that implements MCP over stdio to proxy API requests.
*/
import fs from 'fs/promises';
import path from 'path';
import { Command } from 'commander';
import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPIV3 } from 'openapi-types';
import * as fs from 'fs/promises';
import * as path from 'path';
import { OpenAPIV3 } from 'openapi-types';
// Import generators
import {
generateMcpServerCode,
generatePackageJson,
generateTsconfigJson,
generateGitignore
} from './generator.js';
generateGitignore,
generateEslintConfig,
generateJestConfig,
generatePrettierConfig,
generateEnvExample,
generateOAuth2Docs,
generateWebServerCode,
generateTestClientHtml
} from './generator/index.js';
interface CliOptions {
input: string;
output: string;
serverName?: string;
serverVersion?: string;
baseUrl?: string;
}
// Import types
import { CliOptions } from './types/index.js';
// Configure CLI
const program = new Command();
program
.name('openapi-mcp-generator')
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
// Ensure these option definitions are robust
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
.option('-n, --server-name <name>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
.option('-n, --server-name <n>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
.option('-t, --transport <type>', 'Server transport type: "stdio" or "web" (default: "stdio")')
.option('-p, --port <number>', 'Port for web server (used with --transport=web, default: 3000)', (val) => parseInt(val, 10))
.option('--force', 'Overwrite existing files without prompting')
.version('2.0.0'); // Match package.json version
// Parse arguments explicitly from process.argv
// This is generally the most reliable way
program.parse(process.argv);
// Retrieve the options AFTER parsing
const options = program.opts<CliOptions>();
const options = program.opts<CliOptions & { force?: boolean }>();
/**
* Main function to run the generator
*/
async function main() {
// Use the parsed options directly
const outputDir = options.output;
const inputSpec = options.input; // Use the parsed input value
const inputSpec = options.input;
const srcDir = path.join(outputDir, 'src');
const serverFilePath = path.join(srcDir, 'index.ts');
const packageJsonPath = path.join(outputDir, 'package.json');
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
const gitignorePath = path.join(outputDir, '.gitignore');
const eslintPath = path.join(outputDir, '.eslintrc.json');
const prettierPath = path.join(outputDir, '.prettierrc');
const jestConfigPath = path.join(outputDir, 'jest.config.js');
const envExamplePath = path.join(outputDir, '.env.example');
const docsDir = path.join(outputDir, 'docs');
const oauth2DocsPath = path.join(docsDir, 'oauth2-configuration.md');
// Web server files (if requested)
const webServerPath = path.join(srcDir, 'web-server.ts');
const publicDir = path.join(outputDir, 'public');
const indexHtmlPath = path.join(publicDir, 'index.html');
try {
// Use the correct inputSpec variable
// Check if output directory exists and is not empty
if (!options.force) {
try {
const dirExists = await fs.stat(outputDir).catch(() => false);
if (dirExists) {
const files = await fs.readdir(outputDir);
if (files.length > 0) {
console.error(`Error: Output directory ${outputDir} already exists and is not empty.`);
console.error('Use --force to overwrite existing files.');
process.exit(1);
}
}
} catch (err) {
// Directory doesn't exist, which is fine
}
}
// Parse OpenAPI spec
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document;
console.error('OpenAPI spec parsed successfully.');
// Use options directly for name/version/baseUrl determination
const serverNameRaw = options.serverName || api.info.title || 'my-mcp-server';
// Determine server name and version
const serverNameRaw = options.serverName || (api.info?.title || 'my-mcp-server');
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
const serverVersion = options.serverVersion || api.info.version || '0.1.0';
const serverVersion = options.serverVersion || (api.info?.version || '0.1.0');
console.error('Generating server code...');
// Pass inputSpec to generator function if needed for comments, otherwise just options
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
console.error('Generating package.json...');
const packageJsonContent = generatePackageJson(serverName, serverVersion);
const packageJsonContent = generatePackageJson(serverName, serverVersion, options.transport === 'web');
console.error('Generating tsconfig.json...');
const tsconfigJsonContent = generateTsconfigJson();
@ -75,38 +116,111 @@ async function main() {
console.error('Generating .gitignore...');
const gitignoreContent = generateGitignore();
console.error('Generating ESLint config...');
const eslintConfigContent = generateEslintConfig();
console.error('Generating Prettier config...');
const prettierConfigContent = generatePrettierConfig();
console.error('Generating Jest config...');
const jestConfigContent = generateJestConfig();
console.error('Generating .env.example file...');
const envExampleContent = generateEnvExample(api.components?.securitySchemes);
console.error('Generating OAuth2 documentation...');
const oauth2DocsContent = generateOAuth2Docs(api.components?.securitySchemes);
console.error(`Creating project directory structure at: ${outputDir}`);
await fs.mkdir(srcDir, { recursive: true });
await fs.writeFile(serverFilePath, serverTsContent);
console.error(` -> Created ${serverFilePath}`);
await fs.writeFile(packageJsonPath, packageJsonContent);
console.error(` -> Created ${packageJsonPath}`);
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
console.error(` -> Created ${tsconfigPath}`);
await fs.writeFile(gitignorePath, gitignoreContent);
console.error(` -> Created ${gitignorePath}`);
await fs.writeFile(eslintPath, eslintConfigContent);
console.error(` -> Created ${eslintPath}`);
await fs.writeFile(prettierPath, prettierConfigContent);
console.error(` -> Created ${prettierPath}`);
await fs.writeFile(jestConfigPath, jestConfigContent);
console.error(` -> Created ${jestConfigPath}`);
await fs.writeFile(envExamplePath, envExampleContent);
console.error(` -> Created ${envExamplePath}`);
// Only write OAuth2 docs if there are OAuth2 security schemes
if (oauth2DocsContent.includes("No OAuth2 security schemes defined")) {
console.error(` -> No OAuth2 security schemes found, skipping documentation`);
} else {
await fs.mkdir(docsDir, { recursive: true });
await fs.writeFile(oauth2DocsPath, oauth2DocsContent);
console.error(` -> Created ${oauth2DocsPath}`);
}
// Generate web server files if web transport is requested
if (options.transport === 'web') {
console.error('Generating web server files...');
// Generate web server code
const webServerCode = generateWebServerCode(options.port || 3000);
await fs.writeFile(webServerPath, webServerCode);
console.error(` -> Created ${webServerPath}`);
// Create public directory and index.html
await fs.mkdir(publicDir, { recursive: true });
// Generate test client
const indexHtmlContent = generateTestClientHtml(serverName);
await fs.writeFile(indexHtmlPath, indexHtmlContent);
console.error(` -> Created ${indexHtmlPath}`);
}
console.error("\n---");
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
console.error("\nNext steps:");
console.error(`1. Navigate to the directory: cd ${outputDir}`);
console.error(`2. Install dependencies: npm install`);
if (options.transport === 'web') {
console.error(`3. Build the TypeScript code: npm run build`);
console.error(`4. Run the server in web mode: npm run start:web`);
console.error(` (This will start a web server on port ${options.port || 3000})`);
console.error(` Access the test client at: http://localhost:${options.port || 3000}`);
} else {
console.error(`3. Build the TypeScript code: npm run build`);
console.error(`4. Run the server: npm start`);
console.error(" (This runs the built JavaScript code in build/index.js)");
console.error(` (This runs the built JavaScript code in build/index.js)`);
}
console.error("---");
} catch (error) {
console.error('\nError generating MCP server project:', error);
// Only attempt cleanup if the directory exists and force option was used
if (options.force) {
try {
await fs.rm(outputDir, { recursive: true, force: true });
console.error(`Cleaned up partially created directory: ${outputDir}`);
} catch (cleanupError) {
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
}
}
process.exit(1);
}
}
main();
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});

219
src/parser/extract-tools.ts Normal file
View File

@ -0,0 +1,219 @@
/**
* Functions for extracting tools from an OpenAPI specification
*/
import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { generateOperationId } from '../utils/code-gen.js';
import { McpToolDefinition } from '../types/index.js';
/**
* Extracts tool definitions from an OpenAPI document
*
* @param api OpenAPI document
* @returns Array of MCP tool definitions
*/
export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
const tools: McpToolDefinition[] = [];
const usedNames = new Set<string>();
const globalSecurity = api.security || [];
if (!api.paths) return tools;
for (const [path, pathItem] of Object.entries(api.paths)) {
if (!pathItem) continue;
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
const operation = pathItem[method];
if (!operation) continue;
// Generate a unique name for the tool
let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) continue;
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
let finalToolName = baseName;
let counter = 1;
while (usedNames.has(finalToolName)) {
finalToolName = `${baseName}_${counter++}`;
}
usedNames.add(finalToolName);
// Get or create a description
const description = operation.description || operation.summary ||
`Executes ${method.toUpperCase()} ${path}`;
// Generate input schema and extract parameters
const { inputSchema, parameters, requestBodyContentType } = generateInputSchemaAndDetails(operation);
// Extract parameter details for execution
const executionParameters = parameters.map(p => ({ name: p.name, in: p.in }));
// Determine security requirements
const securityRequirements = operation.security === null ?
globalSecurity :
operation.security || globalSecurity;
// Create the tool definition
tools.push({
name: finalToolName,
description,
inputSchema,
method,
pathTemplate: path,
parameters,
executionParameters,
requestBodyContentType,
securityRequirements,
operationId: baseName,
});
}
}
return tools;
}
/**
* Generates input schema and extracts parameter details from an operation
*
* @param operation OpenAPI operation object
* @returns Input schema, parameters, and request body content type
*/
export function generateInputSchemaAndDetails(operation: OpenAPIV3.OperationObject): {
inputSchema: JSONSchema7 | boolean;
parameters: OpenAPIV3.ParameterObject[];
requestBodyContentType?: string;
} {
const properties: { [key: string]: JSONSchema7 | boolean } = {};
const required: string[] = [];
// Process parameters
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
? operation.parameters.map(p => p as OpenAPIV3.ParameterObject)
: [];
allParameters.forEach(param => {
if (!param.name || !param.schema) return;
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
if (typeof paramSchema === 'object') {
paramSchema.description = param.description || paramSchema.description;
}
properties[param.name] = paramSchema;
if (param.required) required.push(param.name);
});
// Process request body (if present)
let requestBodyContentType: string | undefined = undefined;
if (operation.requestBody) {
const opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
const jsonContent = opRequestBody.content?.['application/json'];
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
if (jsonContent?.schema) {
requestBodyContentType = 'application/json';
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
if (typeof bodySchema === 'object') {
bodySchema.description = opRequestBody.description ||
bodySchema.description ||
'The JSON request body.';
}
properties['requestBody'] = bodySchema;
if (opRequestBody.required) required.push('requestBody');
} else if (firstContent) {
const [contentType] = firstContent;
requestBodyContentType = contentType;
properties['requestBody'] = {
type: 'string',
description: opRequestBody.description || `Request body (content type: ${contentType})`
};
if (opRequestBody.required) required.push('requestBody');
}
}
// Combine everything into a JSON Schema
const inputSchema: JSONSchema7 = {
type: 'object',
properties,
...(required.length > 0 && { required })
};
return { inputSchema, parameters: allParameters, requestBodyContentType };
}
/**
* Maps an OpenAPI schema to a JSON Schema
*
* @param schema OpenAPI schema object or reference
* @returns JSON Schema representation
*/
export function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean {
// Handle reference objects
if ('$ref' in schema) {
console.warn(`Unresolved $ref '${schema.$ref}'.`);
return { type: 'object' };
}
// Handle boolean schemas
if (typeof schema === 'boolean') return schema;
// Create a copy of the schema to modify
const jsonSchema: JSONSchema7 = { ...schema } as any;
// Convert integer type to number (JSON Schema compatible)
if (schema.type === 'integer') jsonSchema.type = 'number';
// Remove OpenAPI-specific properties that aren't in JSON Schema
delete (jsonSchema as any).nullable;
delete (jsonSchema as any).example;
delete (jsonSchema as any).xml;
delete (jsonSchema as any).externalDocs;
delete (jsonSchema as any).deprecated;
delete (jsonSchema as any).readOnly;
delete (jsonSchema as any).writeOnly;
// Handle nullable properties by adding null to the type
if (schema.nullable) {
if (Array.isArray(jsonSchema.type)) {
if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null');
}
else if (typeof jsonSchema.type === 'string') {
jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
}
else if (!jsonSchema.type) {
jsonSchema.type = 'null';
}
}
// Recursively process object properties
if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if (typeof propSchema === 'object' && propSchema !== null) {
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
}
else if (typeof propSchema === 'boolean') {
mappedProps[key] = propSchema;
}
}
jsonSchema.properties = mappedProps;
}
// Recursively process array items
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
jsonSchema.items = mapOpenApiSchemaToJsonSchema(
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
);
}
return jsonSchema;
}

4
src/parser/index.ts Normal file
View File

@ -0,0 +1,4 @@
/**
* Parser module exports
*/
export * from './extract-tools.js';

57
src/types/index.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* Core type definitions for the openapi-to-mcp generator
*/
import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
/**
* CLI options for the generator
*/
export interface CliOptions {
/** Path to the OpenAPI specification file */
input: string;
/** Output directory path for generated files */
output: string;
/** Optional server name to override the one in the OpenAPI spec */
serverName?: string;
/** Optional server version to override the one in the OpenAPI spec */
serverVersion?: string;
/** Optional base URL to override the one in the OpenAPI spec */
baseUrl?: string;
/** Server transport type (stdio or web) */
transport?: 'stdio' | 'web';
/** Server port (for web transport) */
port?: number;
}
/**
* MCP Tool Definition describes a tool extracted from an OpenAPI spec
* for use in Model Context Protocol server
*/
export interface McpToolDefinition {
/** Name of the tool, must be unique */
name: string;
/** Human-readable description of the tool */
description: string;
/** JSON Schema that defines the input parameters */
inputSchema: JSONSchema7 | boolean;
/** HTTP method for the operation (get, post, etc.) */
method: string;
/** URL path template with parameter placeholders */
pathTemplate: string;
/** OpenAPI parameter objects for this operation */
parameters: OpenAPIV3.ParameterObject[];
/** Parameter names and locations for execution */
executionParameters: { name: string, in: string }[];
/** Content type for request body, if applicable */
requestBodyContentType?: string;
/** Security requirements for this operation */
securityRequirements: OpenAPIV3.SecurityRequirementObject[];
/** Original operation ID from the OpenAPI spec */
operationId: string;
}
/**
* Helper type for JSON objects
*/
export type JsonObject = Record<string, any>;

View File

@ -1,40 +0,0 @@
export function titleCase(str: string): string {
// Converts snake_case, kebab-case, or path/parts to TitleCase
return str
.toLowerCase()
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
.replace(/^{/, '') // Remove leading { from path params
.replace(/}$/, '') // Remove trailing } from path params
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
}
export function generateOperationId(method: string, path: string): string {
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
const parts = path.split('/').filter(p => p); // Split and remove empty parts
let name = method.toLowerCase(); // Start with method name
parts.forEach((part, index) => {
if (part.startsWith('{') && part.endsWith('}')) {
// Append 'By' + ParamName only for the *last* path parameter segment
if (index === parts.length - 1) {
name += 'By' + titleCase(part);
}
// Potentially include non-terminal params differently if needed, e.g.:
// else { name += 'With' + titleCase(part); }
} else {
// Append the static path part in TitleCase
name += titleCase(part);
}
});
// Simple fallback if name is just the method (e.g., GET /)
if (name === method.toLowerCase()) {
name += 'Root';
}
// Ensure first letter is uppercase after potential lowercase method start
name = name.charAt(0).toUpperCase() + name.slice(1);
return name;
}

154
src/utils/code-gen.ts Normal file
View File

@ -0,0 +1,154 @@
/**
* Code generation utilities for OpenAPI to MCP generator
*/
import { McpToolDefinition } from '../types/index.js';
import { OpenAPIV3 } from 'openapi-types';
import { sanitizeForTemplate } from './helpers.js';
/**
* Generates the tool definition map code
*
* @param tools List of tool definitions
* @param securitySchemes Security schemes from OpenAPI spec
* @returns Generated code for the tool definition map
*/
export function generateToolDefinitionMap(
tools: McpToolDefinition[],
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
): string {
if (tools.length === 0) return "";
return tools.map(tool => {
// Safely stringify complex objects
let schemaString;
try {
schemaString = JSON.stringify(tool.inputSchema);
} catch (e) {
schemaString = '{}';
console.warn(`Failed to stringify schema for tool ${tool.name}: ${e}`);
}
let execParamsString;
try {
execParamsString = JSON.stringify(tool.executionParameters);
} catch (e) {
execParamsString = "[]";
console.warn(`Failed to stringify execution parameters for tool ${tool.name}: ${e}`);
}
let securityReqsString;
try {
securityReqsString = JSON.stringify(tool.securityRequirements);
} catch (e) {
securityReqsString = "[]";
console.warn(`Failed to stringify security requirements for tool ${tool.name}: ${e}`);
}
// Sanitize description for template literal
const escapedDescription = sanitizeForTemplate(tool.description);
// Build the tool definition entry
return `
["${tool.name}", {
name: "${tool.name}",
description: \`${escapedDescription}\`,
inputSchema: ${schemaString},
method: "${tool.method}",
pathTemplate: "${tool.pathTemplate}",
executionParameters: ${execParamsString},
requestBodyContentType: ${tool.requestBodyContentType ? `"${tool.requestBodyContentType}"` : 'undefined'},
securityRequirements: ${securityReqsString}
}],`;
}).join('');
}
/**
* Generates the list tools handler code
*
* @returns Generated code for the list tools handler
*/
export function generateListToolsHandler(): string {
return `
server.setRequestHandler(ListToolsRequestSchema, async () => {
const toolsForClient: Tool[] = Array.from(toolDefinitionMap.values()).map(def => ({
name: def.name,
description: def.description,
inputSchema: def.inputSchema
}));
return { tools: toolsForClient };
});
`;
}
/**
* Generates the call tool handler code
*
* @returns Generated code for the call tool handler
*/
export function generateCallToolHandler(): string {
return `
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
const { name: toolName, arguments: toolArgs } = request.params;
const toolDefinition = toolDefinitionMap.get(toolName);
if (!toolDefinition) {
console.error(\`Error: Unknown tool requested: \${toolName}\`);
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
}
return await executeApiTool(toolName, toolDefinition, toolArgs ?? {}, securitySchemes);
});
`;
}
/**
* Convert a string to title case
*
* @param str String to convert
* @returns Title case string
*/
export function titleCase(str: string): string {
// Converts snake_case, kebab-case, or path/parts to TitleCase
return str
.toLowerCase()
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
.replace(/^{/, '') // Remove leading { from path params
.replace(/}$/, '') // Remove trailing } from path params
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
}
/**
* Generates an operation ID from method and path
*
* @param method HTTP method
* @param path API path
* @returns Generated operation ID
*/
export function generateOperationId(method: string, path: string): string {
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
const parts = path.split('/').filter(p => p); // Split and remove empty parts
let name = method.toLowerCase(); // Start with method name
parts.forEach((part, index) => {
if (part.startsWith('{') && part.endsWith('}')) {
// Append 'By' + ParamName only for the *last* path parameter segment
if (index === parts.length - 1) {
name += 'By' + titleCase(part);
}
// Potentially include non-terminal params differently if needed, e.g.:
// else { name += 'With' + titleCase(part); }
} else {
// Append the static path part in TitleCase
name += titleCase(part);
}
});
// Simple fallback if name is just the method (e.g., GET /)
if (name === method.toLowerCase()) {
name += 'Root';
}
// Ensure first letter is uppercase after potential lowercase method start
name = name.charAt(0).toUpperCase() + name.slice(1);
return name;
}

112
src/utils/helpers.ts Normal file
View File

@ -0,0 +1,112 @@
/**
* General helper utilities for OpenAPI to MCP generator
*/
/**
* Safely stringify a JSON object with proper error handling
*
* @param obj Object to stringify
* @param defaultValue Default value to return if stringify fails
* @returns JSON string or default value
*/
export function safeJsonStringify(obj: any, defaultValue: string = '{}'): string {
try {
return JSON.stringify(obj);
} catch (e) {
console.warn(`Failed to stringify object: ${e}`);
return defaultValue;
}
}
/**
* Sanitizes a string for use in template strings
*
* @param str String to sanitize
* @returns Sanitized string safe for use in template literals
*/
export function sanitizeForTemplate(str: string): string {
return (str || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
}
/**
* Converts a string to camelCase
*
* @param str String to convert
* @returns camelCase string
*/
export function toCamelCase(str: string): string {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
index === 0 ? word.toLowerCase() : word.toUpperCase()
)
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9]/g, '');
}
/**
* Converts a string to PascalCase
*
* @param str String to convert
* @returns PascalCase string
*/
export function toPascalCase(str: string): string {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9]/g, '');
}
/**
* Creates a valid variable name from a string
*
* @param str Input string
* @returns Valid JavaScript variable name
*/
export function toValidVariableName(str: string): string {
// Replace non-alphanumeric characters with underscores
const sanitized = str.replace(/[^a-zA-Z0-9_$]/g, '_');
// Ensure the variable name doesn't start with a number
return sanitized.match(/^[0-9]/) ? '_' + sanitized : sanitized;
}
/**
* Checks if a string is a valid JavaScript identifier
*
* @param str String to check
* @returns True if valid identifier, false otherwise
*/
export function isValidIdentifier(str: string): boolean {
// Check if the string is a valid JavaScript identifier
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
}
/**
* Formats a string for use in code comments
*
* @param str String to format
* @param maxLineLength Maximum line length
* @returns Formatted comment string
*/
export function formatComment(str: string, maxLineLength: number = 80): string {
if (!str) return '';
const words = str.trim().split(/\s+/);
const lines: string[] = [];
let currentLine = '';
words.forEach(word => {
if ((currentLine + ' ' + word).length <= maxLineLength) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines.join('\n * ');
}

7
src/utils/index.ts Normal file
View File

@ -0,0 +1,7 @@
/**
* Utilities module exports
*/
export * from './code-gen.js';
export * from './security.js';
export * from './helpers.js';
export { determineBaseUrl } from './url.js';

584
src/utils/security.ts Normal file
View File

@ -0,0 +1,584 @@
/**
* Security handling utilities for OpenAPI to MCP generator
*/
import { OpenAPIV3 } from 'openapi-types';
/**
* Get environment variable name for a security scheme
*
* @param schemeName Security scheme name
* @param type Type of security credentials
* @returns Environment variable name
*/
export function getEnvVarName(
schemeName: string,
type: 'API_KEY' | 'BEARER_TOKEN' | 'BASIC_USERNAME' | 'BASIC_PASSWORD' | 'OAUTH_CLIENT_ID' | 'OAUTH_CLIENT_SECRET' | 'OAUTH_TOKEN' | 'OAUTH_SCOPES' | 'OPENID_TOKEN'
): string {
const sanitizedName = schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
return `${type}_${sanitizedName}`;
}
/**
* Generates code for handling API key security
*
* @param scheme API key security scheme
* @returns Generated code
*/
export function generateApiKeySecurityCode(scheme: OpenAPIV3.ApiKeySecurityScheme): string {
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
return `
if (scheme?.type === 'apiKey') {
const apiKey = process.env[\`${getEnvVarName(schemeName, 'API_KEY')}\`];
if (apiKey) {
if (scheme.in === 'header') {
headers[scheme.name.toLowerCase()] = apiKey;
}
else if (scheme.in === 'query') {
queryParams[scheme.name] = apiKey;
}
else if (scheme.in === 'cookie') {
headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`;
}
}
}`;
}
/**
* Generates code for handling HTTP security (Bearer/Basic)
*
* @returns Generated code
*/
export function generateHttpSecurityCode(): string {
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
return `
else if (scheme?.type === 'http') {
if (scheme.scheme?.toLowerCase() === 'bearer') {
const token = process.env[\`${getEnvVarName(schemeName, 'BEARER_TOKEN')}\`];
if (token) {
headers['authorization'] = \`Bearer \${token}\`;
}
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
const username = process.env[\`${getEnvVarName(schemeName, 'BASIC_USERNAME')}\`];
const password = process.env[\`${getEnvVarName(schemeName, 'BASIC_PASSWORD')}\`];
if (username && password) {
headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`;
}
}
}`;
}
/**
* Generates code for OAuth2 token acquisition
*
* @returns Generated code for OAuth2 token acquisition
*/
export function generateOAuth2TokenAcquisitionCode(): string {
return `
/**
* Type definition for cached OAuth tokens
*/
interface TokenCacheEntry {
token: string;
expiresAt: number;
}
/**
* Declare global __oauthTokenCache property for TypeScript
*/
declare global {
var __oauthTokenCache: Record<string, TokenCacheEntry> | undefined;
}
/**
* Acquires an OAuth2 token using client credentials flow
*
* @param schemeName Name of the security scheme
* @param scheme OAuth2 security scheme
* @returns Acquired token or null if unable to acquire
*/
async function acquireOAuth2Token(schemeName: string, scheme: any): Promise<string | null | undefined> {
try {
// Check if we have the necessary credentials
const clientId = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_ID')}\`];
const clientSecret = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_SECRET')}\`];
const scopes = process.env[\`${getEnvVarName('schemeName', 'OAUTH_SCOPES')}\`];
if (!clientId || !clientSecret) {
console.error(\`Missing client credentials for OAuth2 scheme '\${schemeName}'\`);
return null;
}
// Initialize token cache if needed
if (typeof global.__oauthTokenCache === 'undefined') {
global.__oauthTokenCache = {};
}
// Check if we have a cached token
const cacheKey = \`\${schemeName}_\${clientId}\`;
const cachedToken = global.__oauthTokenCache[cacheKey];
const now = Date.now();
if (cachedToken && cachedToken.expiresAt > now) {
console.error(\`Using cached OAuth2 token for '\${schemeName}' (expires in \${Math.floor((cachedToken.expiresAt - now) / 1000)} seconds)\`);
return cachedToken.token;
}
// Determine token URL based on flow type
let tokenUrl = '';
if (scheme.flows?.clientCredentials?.tokenUrl) {
tokenUrl = scheme.flows.clientCredentials.tokenUrl;
console.error(\`Using client credentials flow for '\${schemeName}'\`);
} else if (scheme.flows?.password?.tokenUrl) {
tokenUrl = scheme.flows.password.tokenUrl;
console.error(\`Using password flow for '\${schemeName}'\`);
} else {
console.error(\`No supported OAuth2 flow found for '\${schemeName}'\`);
return null;
}
// Prepare the token request
let formData = new URLSearchParams();
formData.append('grant_type', 'client_credentials');
// Add scopes if specified
if (scopes) {
formData.append('scope', scopes);
}
console.error(\`Requesting OAuth2 token from \${tokenUrl}\`);
// Make the token request
const response = await axios({
method: 'POST',
url: tokenUrl,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': \`Basic \${Buffer.from(\`\${clientId}:\${clientSecret}\`).toString('base64')}\`
},
data: formData.toString()
});
// Process the response
if (response.data?.access_token) {
const token = response.data.access_token;
const expiresIn = response.data.expires_in || 3600; // Default to 1 hour
// Cache the token
global.__oauthTokenCache[cacheKey] = {
token,
expiresAt: now + (expiresIn * 1000) - 60000 // Expire 1 minute early
};
console.error(\`Successfully acquired OAuth2 token for '\${schemeName}' (expires in \${expiresIn} seconds)\`);
return token;
} else {
console.error(\`Failed to acquire OAuth2 token for '\${schemeName}': No access_token in response\`);
return null;
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(\`Error acquiring OAuth2 token for '\${schemeName}':\`, errorMessage);
return null;
}
}
`;
}
/**
* Generates code for executing API tools with security handling
*
* @param securitySchemes Security schemes from OpenAPI spec
* @returns Generated code for the execute API tool function
*/
export function generateExecuteApiToolFunction(
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
): string {
// Generate OAuth2 token acquisition function
const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode();
// Generate security handling code for checking, applying security
const securityCode = `
// Apply security requirements if available
// Security requirements use OR between array items and AND within each object
const appliedSecurity = definition.securityRequirements?.find(req => {
// Try each security requirement (combined with OR)
return Object.entries(req).every(([schemeName, scopesArray]) => {
const scheme = allSecuritySchemes[schemeName];
if (!scheme) return false;
// API Key security (header, query, cookie)
if (scheme.type === 'apiKey') {
return !!process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
}
// HTTP security (basic, bearer)
if (scheme.type === 'http') {
if (scheme.scheme?.toLowerCase() === 'bearer') {
return !!process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
return !!process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] &&
!!process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
}
}
// OAuth2 security
if (scheme.type === 'oauth2') {
// Check for pre-existing token
if (process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) {
return true;
}
// Check for client credentials for auto-acquisition
if (process.env[\`OAUTH_CLIENT_ID_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] &&
process.env[\`OAUTH_CLIENT_SECRET_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) {
// Verify we have a supported flow
if (scheme.flows?.clientCredentials || scheme.flows?.password) {
return true;
}
}
return false;
}
// OpenID Connect
if (scheme.type === 'openIdConnect') {
return !!process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
}
return false;
});
});
// If we found matching security scheme(s), apply them
if (appliedSecurity) {
// Apply each security scheme from this requirement (combined with AND)
for (const [schemeName, scopesArray] of Object.entries(appliedSecurity)) {
const scheme = allSecuritySchemes[schemeName];
// API Key security
if (scheme?.type === 'apiKey') {
const apiKey = process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
if (apiKey) {
if (scheme.in === 'header') {
headers[scheme.name.toLowerCase()] = apiKey;
console.error(\`Applied API key '\${schemeName}' in header '\${scheme.name}'\`);
}
else if (scheme.in === 'query') {
queryParams[scheme.name] = apiKey;
console.error(\`Applied API key '\${schemeName}' in query parameter '\${scheme.name}'\`);
}
else if (scheme.in === 'cookie') {
// Add the cookie, preserving other cookies if they exist
headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`;
console.error(\`Applied API key '\${schemeName}' in cookie '\${scheme.name}'\`);
}
}
}
// HTTP security (Bearer or Basic)
else if (scheme?.type === 'http') {
if (scheme.scheme?.toLowerCase() === 'bearer') {
const token = process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
if (token) {
headers['authorization'] = \`Bearer \${token}\`;
console.error(\`Applied Bearer token for '\${schemeName}'\`);
}
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
const username = process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
const password = process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
if (username && password) {
headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`;
console.error(\`Applied Basic authentication for '\${schemeName}'\`);
}
}
}
// OAuth2 security
else if (scheme?.type === 'oauth2') {
// First try to use a pre-provided token
let token = process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
// If no token but we have client credentials, try to acquire a token
if (!token && (scheme.flows?.clientCredentials || scheme.flows?.password)) {
console.error(\`Attempting to acquire OAuth token for '\${schemeName}'\`);
token = (await acquireOAuth2Token(schemeName, scheme)) ?? '';
}
// Apply token if available
if (token) {
headers['authorization'] = \`Bearer \${token}\`;
console.error(\`Applied OAuth2 token for '\${schemeName}'\`);
// List the scopes that were requested, if any
const scopes = scopesArray as string[];
if (scopes && scopes.length > 0) {
console.error(\`Requested scopes: \${scopes.join(', ')}\`);
}
}
}
// OpenID Connect
else if (scheme?.type === 'openIdConnect') {
const token = process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
if (token) {
headers['authorization'] = \`Bearer \${token}\`;
console.error(\`Applied OpenID Connect token for '\${schemeName}'\`);
// List the scopes that were requested, if any
const scopes = scopesArray as string[];
if (scopes && scopes.length > 0) {
console.error(\`Requested scopes: \${scopes.join(', ')}\`);
}
}
}
}
}
// Log warning if security is required but not available
else if (definition.securityRequirements?.length > 0) {
// First generate a more readable representation of the security requirements
const securityRequirementsString = definition.securityRequirements
.map(req => {
const parts = Object.entries(req)
.map(([name, scopesArray]) => {
const scopes = scopesArray as string[];
if (scopes.length === 0) return name;
return \`\${name} (scopes: \${scopes.join(', ')})\`;
})
.join(' AND ');
return \`[\${parts}]\`;
})
.join(' OR ');
console.warn(\`Tool '\${toolName}' requires security: \${securityRequirementsString}, but no suitable credentials found.\`);
}
`;
// Generate complete execute API tool function
return `
${oauth2TokenAcquisitionCode}
/**
* Executes an API tool with the provided arguments
*
* @param toolName Name of the tool to execute
* @param definition Tool definition
* @param toolArgs Arguments provided by the user
* @param allSecuritySchemes Security schemes from the OpenAPI spec
* @returns Call tool result
*/
async function executeApiTool(
toolName: string,
definition: McpToolDefinition,
toolArgs: JsonObject,
allSecuritySchemes: Record<string, any>
): Promise<CallToolResult> {
try {
// Validate arguments against the input schema
let validatedArgs: JsonObject;
try {
const zodSchema = getZodSchemaFromJsonSchema(definition.inputSchema, toolName);
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
validatedArgs = zodSchema.parse(argsToParse);
} catch (error: unknown) {
if (error instanceof ZodError) {
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
return { content: [{ type: 'text', text: validationErrorMessage }] };
} else {
const errorMessage = error instanceof Error ? error.message : String(error);
return { content: [{ type: 'text', text: \`Internal error during validation setup: \${errorMessage}\` }] };
}
}
// Prepare URL, query parameters, headers, and request body
let urlPath = definition.pathTemplate;
const queryParams: Record<string, any> = {};
const headers: Record<string, string> = { 'Accept': 'application/json' };
let requestBodyData: any = undefined;
// Apply parameters to the URL path, query, or headers
definition.executionParameters.forEach((param) => {
const value = validatedArgs[param.name];
if (typeof value !== 'undefined' && value !== null) {
if (param.in === 'path') {
urlPath = urlPath.replace(\`{\${param.name}}\`, encodeURIComponent(String(value)));
}
else if (param.in === 'query') {
queryParams[param.name] = value;
}
else if (param.in === 'header') {
headers[param.name.toLowerCase()] = String(value);
}
}
});
// Ensure all path parameters are resolved
if (urlPath.includes('{')) {
throw new Error(\`Failed to resolve path parameters: \${urlPath}\`);
}
// Construct the full URL
const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;
// Handle request body if needed
if (definition.requestBodyContentType && typeof validatedArgs['requestBody'] !== 'undefined') {
requestBodyData = validatedArgs['requestBody'];
headers['content-type'] = definition.requestBodyContentType;
}
${securityCode}
// Prepare the axios request configuration
const config: AxiosRequestConfig = {
method: definition.method.toUpperCase(),
url: requestUrl,
params: queryParams,
headers: headers,
...(requestBodyData !== undefined && { data: requestBodyData }),
};
// Log request info to stderr (doesn't affect MCP output)
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
// Execute the request
const response = await axios(config);
// Process and format the response
let responseText = '';
const contentType = response.headers['content-type']?.toLowerCase() || '';
// Handle JSON responses
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
try {
responseText = JSON.stringify(response.data, null, 2);
} catch (e) {
responseText = "[Stringify Error]";
}
}
// Handle string responses
else if (typeof response.data === 'string') {
responseText = response.data;
}
// Handle other response types
else if (response.data !== undefined && response.data !== null) {
responseText = String(response.data);
}
// Handle empty responses
else {
responseText = \`(Status: \${response.status} - No body content)\`;
}
// Return formatted response
return {
content: [
{
type: "text",
text: \`API Response (Status: \${response.status}):\\n\${responseText}\`
}
],
};
} catch (error: unknown) {
// Handle errors during execution
let errorMessage: string;
// Format Axios errors specially
if (axios.isAxiosError(error)) {
errorMessage = formatApiError(error);
}
// Handle standard errors
else if (error instanceof Error) {
errorMessage = error.message;
}
// Handle unexpected error types
else {
errorMessage = 'Unexpected error: ' + String(error);
}
// Log error to stderr
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage);
// Return error message to client
return { content: [{ type: "text", text: errorMessage }] };
}
}
`;
}
/**
* Gets security scheme documentation for README
*
* @param securitySchemes Security schemes from OpenAPI spec
* @returns Documentation for security schemes
*/
export function getSecuritySchemesDocs(
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
): string {
if (!securitySchemes) return 'No security schemes defined in the OpenAPI spec.';
let docs = '';
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
if ('$ref' in schemeOrRef) {
docs += `- \`${name}\`: Referenced security scheme (reference not resolved)\n`;
continue;
}
const scheme = schemeOrRef;
if (scheme.type === 'apiKey') {
const envVar = getEnvVarName(name, 'API_KEY');
docs += `- \`${envVar}\`: API key for ${scheme.name} (in ${scheme.in})\n`;
}
else if (scheme.type === 'http') {
if (scheme.scheme?.toLowerCase() === 'bearer') {
const envVar = getEnvVarName(name, 'BEARER_TOKEN');
docs += `- \`${envVar}\`: Bearer token for authentication\n`;
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
const usernameEnvVar = getEnvVarName(name, 'BASIC_USERNAME');
const passwordEnvVar = getEnvVarName(name, 'BASIC_PASSWORD');
docs += `- \`${usernameEnvVar}\`: Username for Basic authentication\n`;
docs += `- \`${passwordEnvVar}\`: Password for Basic authentication\n`;
}
}
else if (scheme.type === 'oauth2') {
const flowTypes = scheme.flows ? Object.keys(scheme.flows) : ['unknown'];
// Add client credentials for OAuth2
const clientIdVar = getEnvVarName(name, 'OAUTH_CLIENT_ID');
const clientSecretVar = getEnvVarName(name, 'OAUTH_CLIENT_SECRET');
docs += `- \`${clientIdVar}\`: Client ID for OAuth2 authentication (${flowTypes.join(', ')} flow)\n`;
docs += `- \`${clientSecretVar}\`: Client secret for OAuth2 authentication\n`;
// Add OAuth token for manual setting
const tokenVar = getEnvVarName(name, 'OAUTH_TOKEN');
docs += `- \`${tokenVar}\`: OAuth2 token (if not using automatic token acquisition)\n`;
// Add scopes env var
const scopesVar = getEnvVarName(name, 'OAUTH_SCOPES');
docs += `- \`${scopesVar}\`: Space-separated list of OAuth2 scopes to request\n`;
// If available, list flow-specific details
if (scheme.flows?.clientCredentials) {
docs += ` Client Credentials Flow Token URL: ${scheme.flows.clientCredentials.tokenUrl}\n`;
// List available scopes if defined
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
docs += ` Available scopes:\n`;
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
docs += ` - \`${scope}\`: ${description}\n`;
}
}
}
}
else if (scheme.type === 'openIdConnect') {
const tokenVar = getEnvVarName(name, 'OPENID_TOKEN');
docs += `- \`${tokenVar}\`: OpenID Connect token\n`;
if (scheme.openIdConnectUrl) {
docs += ` OpenID Connect Discovery URL: ${scheme.openIdConnectUrl}\n`;
}
}
}
return docs;
}

101
src/utils/url.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* URL handling utilities for OpenAPI to MCP generator
*/
import { OpenAPIV3 } from 'openapi-types';
/**
* Determines the base URL from the OpenAPI document or CLI options
*
* @param api OpenAPI document
* @param cmdLineBaseUrl Optional base URL from command line options
* @returns The determined base URL or null if none is available
*/
export function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null {
// Command line option takes precedence
if (cmdLineBaseUrl) {
return normalizeUrl(cmdLineBaseUrl);
}
// Single server in OpenAPI spec
if (api.servers && api.servers.length === 1 && api.servers[0].url) {
return normalizeUrl(api.servers[0].url);
}
// Multiple servers - use first one with warning
if (api.servers && api.servers.length > 1) {
console.warn(`Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
return normalizeUrl(api.servers[0].url);
}
// No server information available
return null;
}
/**
* Normalizes a URL by removing trailing slashes
*
* @param url URL to normalize
* @returns Normalized URL
*/
export function normalizeUrl(url: string): string {
return url.replace(/\/$/, '');
}
/**
* Joins URL segments handling slashes correctly
*
* @param baseUrl Base URL
* @param path Path to append
* @returns Joined URL
*/
export function joinUrl(baseUrl: string, path: string): string {
if (!baseUrl) return path;
if (!path) return baseUrl;
const normalizedBase = normalizeUrl(baseUrl);
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
/**
* Builds a URL with query parameters
*
* @param baseUrl Base URL
* @param queryParams Query parameters
* @returns URL with query parameters
*/
export function buildUrlWithQuery(baseUrl: string, queryParams: Record<string, any>): string {
if (!Object.keys(queryParams).length) return baseUrl;
const url = new URL(baseUrl.startsWith('http') ? baseUrl : `http://localhost${baseUrl.startsWith('/') ? '' : '/'}${baseUrl}`);
for (const [key, value] of Object.entries(queryParams)) {
if (Array.isArray(value)) {
value.forEach(item => url.searchParams.append(key, String(item)));
} else {
url.searchParams.append(key, String(value));
}
}
// Remove http://localhost if we added it
return baseUrl.startsWith('http') ? url.toString() : url.pathname + url.search;
}
/**
* Extracts path parameters from a URL template
*
* @param urlTemplate URL template with {param} placeholders
* @returns Array of parameter names
*/
export function extractPathParams(urlTemplate: string): string[] {
const paramRegex = /{([^}]+)}/g;
const params: string[] = [];
let match;
while ((match = paramRegex.exec(urlTemplate)) !== null) {
params.push(match[1]);
}
return params;
}

View File

@ -1,15 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
"exclude": ["node_modules", "dist", "test"]
}