commit 335643b45bc8632cd9e6b1da2b962f7c30a72913 Author: Harshavardhan Surisetty <31560965+harsha-iiiv@users.noreply.github.com> Date: Sun Mar 9 14:09:24 2025 +0530 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee14a80 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# OpenAPI to MCP + +A command-line tool that generates Model Context Protocol (MCP) server code from OpenAPI specifications. This tool helps you quickly create an MCP server that acts as a bridge between LLMs (Large Language Models) and your API. + +## Features + +- **Automatic Tool Generation**: Converts each API endpoint in your OpenAPI spec into an MCP tool +- **Multiple Transport Options**: Supports stdio, WebSocket, and HTTP transport methods +- **Complete Project Setup**: Generates all necessary files to run an MCP server +- **TypeScript Support**: Includes TypeScript definitions and configuration +- **Easy Configuration**: Simple environment-based configuration for the generated server + +## Installation + +```bash +# Clone this repository +git clone https://github.com/yourusername/openapi-to-mcp.git + +# Navigate to the project directory +cd openapi-to-mcp + +# Install dependencies +npm install + +# Make the script executable +chmod +x index.js + +# Optionally, install globally +npm install -g . +``` + +## Usage + +Generate an MCP server from an OpenAPI specification: + +```bash +./index.js --openapi path/to/openapi.json --output ./my-mcp-server +``` + +Or if installed globally: + +```bash +openapi-to-mcp --openapi path/to/openapi.json --output ./my-mcp-server +``` + +### Command Line Options + +| Option | Alias | Description | Default | +|--------|-------|-------------|---------| +| `--openapi` | `-o` | Path or URL to OpenAPI specification | (required) | +| `--output` | `-d` | Output directory for generated files | `./mcp-server` | +| `--name` | `-n` | Name for the MCP server | `openapi-mcp-server` | +| `--version` | `-v` | Version for the MCP server | `1.0.0` | +| `--transport` | `-t` | Transport mechanism (stdio, websocket, http) | `stdio` | +| `--port` | `-p` | Port for websocket or HTTP server | `3000` | +| `--help` | `-h` | Show help information | | + +### Examples + +Generate from a local OpenAPI file: + +```bash +./index.js --openapi ./specs/petstore.json --output ./petstore-mcp +``` + +Generate from a remote OpenAPI URL: + +```bash +./index.js --openapi https://petstore3.swagger.io/api/v3/openapi.json --output ./petstore-mcp +``` + +Specify a WebSocket transport: + +```bash +./index.js --openapi ./specs/petstore.json --transport websocket --port 8080 +``` + +## Generated Files + +The tool generates the following files in the output directory: + +- `server.js` - The main MCP server implementation +- `package.json` - Dependencies and scripts +- `README.md` - Documentation for the generated server +- `.env.example` - Template for environment variables +- `types.d.ts` - TypeScript type definitions for the API +- `tsconfig.json` - TypeScript configuration + +## Using the Generated Server + +After generating your MCP server: + +1. Navigate to the generated directory: + ```bash + cd my-mcp-server + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Create an environment file: + ```bash + cp .env.example .env + ``` + +4. Edit `.env` to set your API base URL and any required headers: + ``` + API_BASE_URL=https://api.example.com + API_HEADERS=Authorization:Bearer your-token-here + ``` + +5. Start the server: + ```bash + npm start + ``` + +## Requirements + +- Node.js 16.x or higher +- npm 7.x or higher + +## License + +MIT \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa5882d --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "openapi-mcp-generator", + "version": "1.0.0", + "description": "Generate MCP server code from OpenAPI specifications", + "type": "module", + "main": "src/index.js", + "bin": { + "openapi-mcp-generator": "./src/index.js" + }, + "scripts": { + "start": "node src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "openapi", + "mcp", + "model-context-protocol", + "generator", + "llm" + ], + "author": "", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "minimist": "^1.2.8" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/src/config-generator.js b/src/config-generator.js new file mode 100644 index 0000000..991eb70 --- /dev/null +++ b/src/config-generator.js @@ -0,0 +1,323 @@ +/** + * Generate .env.example with enhanced auth examples + */ +function generateEnvExample(config, securitySchemes) { + console.log('Generating .env.example file...'); + let authExamples = ''; + if (securitySchemes && Object.keys(securitySchemes).length > 0) { + authExamples = `# --- Authorization Configuration --- \n`; + for (const [schemeName, schemeDef] of Object.entries(securitySchemes)) { + if (schemeDef.type === 'apiKey') { + authExamples += `# Example for API Key "${schemeName}" (${schemeDef.in || 'header'}: ${schemeDef.name})\n`; + authExamples += `${schemeName.toUpperCase()}_${schemeDef.name.toUpperCase()}=YOUR_API_KEY_VALUE\n`; + } else if (schemeDef.type === 'http' && schemeDef.scheme === 'bearer') { + authExamples += `# Example for HTTP Bearer Token "${schemeName}"\n`; + authExamples += `${schemeName.toUpperCase()}_BEARERTOKEN=YOUR_BEARER_TOKEN_VALUE\n`; + } else if (schemeDef.type === 'http' && schemeDef.scheme === 'basic') { + authExamples += `# Example for HTTP Basic Auth "${schemeName}"\n`; + authExamples += `${schemeName.toUpperCase()}_USERNAME=YOUR_USERNAME\n`; + authExamples += `${schemeName.toUpperCase()}_PASSWORD=YOUR_PASSWORD\n`; + } + } + } + + return `# API Configuration +API_BASE_URL=https://api.example.com +API_HEADERS= # (Less common now, use specific auth env vars below instead) + +# Server Configuration +SERVER_NAME=${config.name} +SERVER_VERSION=${config.version} +TRANSPORT=stdio # Fixed to stdio + +# Debug +DEBUG=false + +${authExamples} +`; +} + +/** + * Generate README.md with enhanced auth instructions + */ +function generateReadme(config, spec, tools, hasSecuritySchemes) { + console.log('Generating README.md file...'); + const readme = `# ${config.name} + +Model Context Protocol (MCP) server for ${spec.info?.title || 'OpenAPI'} API. + +## Description + +${spec.info?.description || 'This server provides a Model Context Protocol (MCP) interface to the API.'} + +## Installation + +1. Install dependencies: + +\`\`\`bash +npm install +\`\`\` + +2. Create a \`.env\` file based on \`.env.example\`: + +\`\`\`bash +cp .env.example .env +\`\`\` + +3. Edit the \`.env\` file to add your API configuration and authorization details. + +## Configuration + +The following environment variables can be configured in the \`.env\` file: + +- \`API_BASE_URL\`: Base URL for the API (required) +- \`SERVER_NAME\`: Name of the MCP server (default: "${config.name}") +- \`SERVER_VERSION\`: Version of the MCP server (default: "${config.version}") +- \`DEBUG\`: Enable debug logging (true/false) (default: false) + +${hasSecuritySchemes ? ` +### Authorization Configuration + +This server supports the following authorization schemes defined in the OpenAPI specification: + +${Object.entries(spec.components?.securitySchemes || {}).map(([schemeName, schemeDef]) => { + let configDetails = ''; + if (schemeDef.type === 'apiKey') { + configDetails = `- **${schemeName} (API Key)**: Set environment variable \`${schemeName.toUpperCase()}_${schemeDef.name.toUpperCase()}\` with your API key. The key will be sent in the \`${schemeDef.name}\` ${schemeDef.in || 'header'}.`; + } else if (schemeDef.type === 'http' && schemeDef.scheme === 'bearer') { + configDetails = `- **${schemeName} (HTTP Bearer)**: Set environment variable \`${schemeName.toUpperCase()}_BEARERTOKEN\` with your Bearer token. The token will be sent in the \`Authorization\` header.`; + } else if (schemeDef.type === 'http' && schemeDef.scheme === 'basic') { + configDetails = `- **${schemeName} (HTTP Basic)**: Set environment variables \`${schemeName.toUpperCase()}_USERNAME\` and \`${schemeName.toUpperCase()}_PASSWORD\` with your credentials. These will be encoded and sent in the \`Authorization\` header.`; + } else { + configDetails = `- **${schemeName} (${schemeDef.type})**: Configuration details for this scheme type are not fully described in this template. Refer to the OpenAPI specification and update \`.env.example\` and server code manually if needed.`; + } + return configDetails; + }).join('\n\n')} + +`: ''} + +## Usage + +### Running the Server + +The server is provided as both JavaScript and TypeScript versions: + +\`\`\`bash +# Run JavaScript version +npm start + +# Or run TypeScript version (compiles on the fly) +npm run start:ts +\`\`\` + +### Building the TypeScript Version + +\`\`\`bash +npm run build +cd dist +node server.js +\`\`\` + +## Using as an MCP Tool Provider + +This server implements the Model Context Protocol (MCP) and can be used with any MCP-compatible consumer, like Claude.js client or other MCP consumers. + +Example of connecting to this server from a Claude.js client: + +\`\`\`javascript +import { MCP } from "claude-js"; +import { createStdio } from "claude-js/mcp"; + +// Create stdin/stdout transport +const transport = createStdio({ command: "node path/to/server.js" }); + +// Connect to the MCP server +const mcp = new MCP({ transport }); +await mcp.connect(); + +// List available tools +const { tools } = await mcp.listTools(); +console.log("Available tools:", tools); + +// Call a tool +const result = await mcp.callTool({ + id: "TOOL-ID", + arguments: { param1: "value1" } +}); +console.log("Tool result:", result); +\`\`\` + +## Available Tools + +This MCP server provides the following tools: + +${tools.map(tool => `### ${tool.name} + +- **ID**: \`${tool.id}\` +- **Description**: ${tool.description || 'No description provided'} +- **Method**: \`${tool.method}\` +- **Path**: \`${tool.path}\` + +${Object.keys(tool.inputSchema.properties).length > 0 ? '**Parameters**:\n\n' + + Object.entries(tool.inputSchema.properties).map(([name, prop]) => + `- \`${name}\`: ${prop.description || name} ${tool.inputSchema.required?.includes(name) ? '(required)' : ''}` + ).join('\n') : 'No parameters required.'}`).join('\n\n')} + +## License + +MIT +`; + return readme; +} + +/** + * Generate package.json for the MCP server + */ +function generatePackageJson(config, spec) { + console.log('Generating package.json file...'); + const packageJson = { + name: config.name.toLowerCase().replace(/\s+/g, '-'), + version: config.version, + description: `MCP server for ${spec.info?.title || 'OpenAPI'} API`, + type: 'module', + main: 'server.js', + scripts: { + start: 'node server.js', + build: 'node build.js', + "start:ts": "npx tsc && node dist/server.js" + }, + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + 'axios': '^1.6.0', + 'dotenv': '^16.0.0', + }, + devDependencies: { + '@types/node': '^20.11.0', + 'typescript': '^5.3.3' + }, + engines: { + 'node': '>=16.0.0' + } + }; + + return JSON.stringify(packageJson, null, 2); +} + +/** + * Generate a TypeScript declaration file + */ +function generateTypeDefinitions(tools) { + console.log('Generating types.d.ts file...'); + return `/** + * Type definitions for the API endpoints + * Auto-generated from OpenAPI specification + */ + +export interface APITools { +${tools.map(tool => ` /** + * ${tool.description || tool.name} + */ + "${tool.id}": { + params: { +${Object.entries(tool.inputSchema.properties).map(([name, prop]) => + ` /** + * ${prop.description || name} + */ + ${name}${tool.inputSchema.required?.includes(name) ? '' : '?'}: ${prop.type === 'integer' ? 'number' : prop.type};` + ).join('\n')} + }; + response: any; // Response structure will depend on the API + };`).join('\n\n')} +} +`; +} + +/** + * Generate the tsconfig.json file + */ +function generateTsConfig() { + console.log('Generating tsconfig.json file...'); + return `{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["*.ts", "*.js"], + "exclude": ["node_modules"] +} +`; +} + +/** + * Generate build.js script for TypeScript compilation + */ +function generateBuildScript() { + console.log('Generating build.js file...'); + return `#!/usr/bin/env node + +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get proper paths for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Ensure dist directory exists +if (!fs.existsSync('./dist')) { + fs.mkdirSync('./dist'); +} + +// Run TypeScript compiler +console.log('Compiling TypeScript...'); +exec('npx tsc', (error, stdout, stderr) => { + if (error) { + console.error('Error compiling TypeScript:', error); + console.error(stderr); + process.exit(1); + } + + if (stdout) { + console.log(stdout); + } + + console.log('TypeScript compilation successful'); + + // Copy .env.example to dist + try { + if (fs.existsSync('./.env.example')) { + fs.copyFileSync('./.env.example', './dist/.env.example'); + console.log('Copied .env.example to dist directory'); + } + + // Create package.json in dist + const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8')); + packageJson.main = 'server.js'; + fs.writeFileSync('./dist/package.json', JSON.stringify(packageJson, null, 2)); + console.log('Created package.json in dist directory'); + + console.log('Build completed successfully'); + } catch (err) { + console.error('Error copying files:', err); + process.exit(1); + } +}); +`; +} + + +export { + generatePackageJson, + generateReadme, + generateEnvExample, + generateTypeDefinitions, + generateTsConfig, + generateBuildScript +}; \ No newline at end of file diff --git a/src/file-utils.js b/src/file-utils.js new file mode 100644 index 0000000..5df292e --- /dev/null +++ b/src/file-utils.js @@ -0,0 +1,22 @@ +import { writeFile } from 'fs/promises'; +import path from 'path'; + +/** + * Copy the required MCP server template file if it doesn't exist in the output + */ +async function copyTemplateFile(file, content, outputDir, verbose = false) { + const outputPath = path.join(outputDir, file); + try { + await writeFile(outputPath, content); + console.log(`āœ“ Created ${outputPath}`); + if (verbose) { + console.log(` File size: ${content.length} bytes`); + } + return true; + } catch (error) { + console.error(`āœ— Failed to create ${outputPath}: ${error.message}`); + throw error; + } +} + +export { copyTemplateFile }; \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..15b6e23 --- /dev/null +++ b/src/index.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +// Basic imports using ES module syntax +import minimist from 'minimist'; +import fs from 'fs'; +import { mkdir } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { loadOpenAPISpec } from './openapi-loader.js'; +import { generateTools } from './tool-generator.js'; +import { generateServerFile, generateServerTS } from './server-generator.js'; +import { + generatePackageJson, + generateReadme, + generateEnvExample, + generateTypeDefinitions, + generateTsConfig, + generateBuildScript +} from './config-generator.js'; +import { copyTemplateFile } from './file-utils.js'; + +// Get proper paths for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +console.log('Script started'); + +/** + * Main function to drive the entire process + */ +async function main() { + try { + console.log('šŸš€ OpenAPI to MCP Server Generator'); + + // Parse command-line arguments with minimist + const argv = minimist(process.argv.slice(2), { + string: ['openapi', 'output', 'name', 'version', 'transport'], + number: ['port'], + alias: { + o: 'openapi', + d: 'output', + n: 'name', + v: 'version', + t: 'transport', + p: 'port', + V: 'verbose' + }, + default: { + output: './mcp-server', + name: 'openapi-mcp-server', + version: '1.0.0', + transport: 'stdio', + port: 3000, + verbose: false + } + }); + + // Check required parameters + if (!argv.openapi) { + console.error('Error: --openapi parameter is required'); + console.error('Usage: ./index.js --openapi [--output ]'); + process.exit(1); + } + + // Create configuration object + const config = { + openApiSpec: argv.openapi, + outputDir: argv.output, + name: argv.name, + version: argv.version, + transport: argv.transport, + port: argv.port, + verbose: argv.verbose + }; + + console.log(`Configuration:`); + console.log(`- OpenAPI Spec: ${config.openApiSpec}`); + console.log(`- Output Directory: ${config.outputDir}`); + console.log(`- Server Name: ${config.name}`); + console.log(`- Transport: ${config.transport}`); + + // Load OpenAPI spec + const spec = await loadOpenAPISpec(config.openApiSpec, config.verbose); + + if (!spec) { + throw new Error("Failed to load or parse the OpenAPI specification"); + } + + // Check if it's a valid OpenAPI spec + if (!spec.openapi && !spec.swagger) { + console.warn("Warning: The loaded specification might not be a valid OpenAPI document. Missing 'openapi' or 'swagger' version field."); + } + + // Generate tools from spec + const { tools, toolMap, securitySchemes } = generateTools(spec, config.verbose); + const hasSecuritySchemes = Object.keys(securitySchemes).length > 0; + + + if (tools.length === 0) { + console.warn("Warning: No API tools were generated from the specification. The spec might not contain valid paths/operations."); + } + + // Create output directory if it doesn't exist + if (!fs.existsSync(config.outputDir)) { + console.log(`Creating output directory: ${config.outputDir}`); + await mkdir(config.outputDir, { recursive: true }); + } + + // Generate all the files + console.log("Generating server files..."); + const serverCode = generateServerFile(config, spec, toolMap, securitySchemes); + const serverTSCode = generateServerTS(config, spec, toolMap, securitySchemes); + const packageJson = generatePackageJson(config, spec); + const readme = generateReadme(config, spec, tools, hasSecuritySchemes); + const envExample = generateEnvExample(config, securitySchemes); + const typeDefinitions = generateTypeDefinitions(tools); + const tsConfig = generateTsConfig(); + const buildScript = generateBuildScript(); + + // Write all files + console.log("Writing files to output directory..."); + const results = await Promise.all([ + copyTemplateFile('server.js', serverCode, config.outputDir, config.verbose), + copyTemplateFile('server.ts', serverTSCode, config.outputDir, config.verbose), + copyTemplateFile('package.json', packageJson, config.outputDir, config.verbose), + copyTemplateFile('README.md', readme, config.outputDir, config.verbose), + copyTemplateFile('.env.example', envExample, config.outputDir, config.verbose), + copyTemplateFile('types.d.ts', typeDefinitions, config.outputDir, config.verbose), + copyTemplateFile('tsconfig.json', tsConfig, config.outputDir, config.verbose), + copyTemplateFile('build.js', buildScript, config.outputDir, config.verbose) + ]); + + const success = results.every(Boolean); + + if (success) { + console.log(`\nāœ… MCP server generated successfully in "${config.outputDir}"`); + console.log(`šŸ“š Generated ${tools.length} tools from OpenAPI spec`); + console.log('\nNext steps:'); + console.log('1. cd ' + config.outputDir); + console.log('2. npm install'); + console.log('3. cp .env.example .env (and edit with your API details)'); + console.log('4. Run the server:'); + console.log(' - JavaScript version: npm start'); + console.log(' - TypeScript version: npm run start:ts'); + } else { + console.error("āŒ Some files failed to generate. Check the errors above."); + } + + return success; + } catch (error) { + console.error('āŒ Error generating MCP server:', error.message); + if (error.stack) { + console.error(error.stack); + } + return false; + } +} + +// Run the program +main().then(success => { + process.exit(success ? 0 : 1); +}).catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/openapi-loader.js b/src/openapi-loader.js new file mode 100644 index 0000000..8768915 --- /dev/null +++ b/src/openapi-loader.js @@ -0,0 +1,49 @@ +import fs from 'fs'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import axios from 'axios'; + +/** + * Load OpenAPI specification from file or URL + */ +async function loadOpenAPISpec(specPath, verbose = false) { + try { + if (specPath.startsWith('http')) { + // Load from URL + console.log(`Loading OpenAPI spec from URL: ${specPath}`); + const response = await axios.get(specPath); + if (verbose) { + console.log(`Successfully loaded OpenAPI spec from URL (${Object.keys(response.data).length} keys in spec)`); + } + return response.data; + } else { + // Load from local file + const resolvedPath = path.resolve(specPath); + console.log(`Loading OpenAPI spec from file: ${resolvedPath}`); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + + const content = await readFile(resolvedPath, 'utf-8'); + try { + const parsed = JSON.parse(content); + if (verbose) { + console.log(`Successfully loaded OpenAPI spec from file (${Object.keys(parsed).length} keys in spec)`); + } + return parsed; + } catch (parseError) { + throw new Error(`Failed to parse JSON from ${resolvedPath}: ${parseError.message}`); + } + } + } catch (error) { + console.error(`Failed to load OpenAPI spec: ${error.message}`); + if (error.response) { + console.error(`HTTP Status: ${error.response.status}`); + console.error(`Response: ${JSON.stringify(error.response.data)}`); + } + throw error; + } +} + +export { loadOpenAPISpec }; \ No newline at end of file diff --git a/src/server-generator.js b/src/server-generator.js new file mode 100644 index 0000000..d48072d --- /dev/null +++ b/src/server-generator.js @@ -0,0 +1,766 @@ +/** + * Generate the main server.js file with fixes for MCP compatibility + */ +function generateServerFile(config, spec, toolMap, securitySchemes) { + console.log('Generating server.js file...'); + const toolsArray = Object.values(toolMap); + const hasSecuritySchemes = Object.keys(securitySchemes).length > 0; + + // Create JavaScript version with fixes for MCP compatibility + const serverCode = `#!/usr/bin/env node + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import axios from "axios"; +import { config as dotenvConfig } from "dotenv"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Load environment variables +dotenvConfig(); + +// Define tool schemas +const TOOLS = ${JSON.stringify(toolsArray, null, 2)}; +const SECURITY_SCHEMES = ${JSON.stringify(securitySchemes, null, 2)}; + +/** + * MCP Server for ${spec.info?.title || 'OpenAPI'} API + * Generated from OpenAPI spec version ${spec.info?.version || 'unknown'} + * Generated on ${new Date().toISOString()} + */ +class MCPServer { + constructor() { + // Initialize class properties + this.server = null; + this.tools = new Map(); + this.debug = process.env.DEBUG === "true"; + this.baseUrl = process.env.API_BASE_URL || ""; + this.headers = this.parseHeaders(process.env.API_HEADERS || ""); + + // Initialize tools map - do this before creating server + this.initializeTools(); + + // Create MCP server with correct capabilities + this.server = new Server( + { + name: process.env.SERVER_NAME || "${config.name}", + version: process.env.SERVER_VERSION || "${config.version}", + }, + { + capabilities: { + tools: true, // Enable tools capability + }, + } + ); + + // Set up request handlers - don't log here + this.setupHandlers(); + } + + /** + * Parse headers from string + */ + parseHeaders(headerStr) { + const headers = {}; + if (headerStr) { + headerStr.split(",").forEach((header) => { + const [key, value] = header.split(":"); + if (key && value) headers[key.trim()] = value.trim(); + }); + } + return headers; + } + + /** + * Initialize tools map from OpenAPI spec + * This runs before the server is connected, so don't log here + */ + initializeTools() { + // Initialize each tool in the tools map + for (const tool of TOOLS) { + this.tools.set(tool.id, { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + // Don't include security at the tool level + }); + } + + // Don't log here, we're not connected yet + console.error(\`Initialized \${this.tools.size} tools\`); + } + + /** + * Set up request handlers + */ + setupHandlers() { + // Handle tool listing requests + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.log('debug', "Handling ListTools request"); + // Return tools in the format expected by MCP SDK + return { + tools: Array.from(this.tools.entries()).map(([id, tool]) => ({ + id, + ...tool, + })), + }; + }); + + // Handle tool execution requests + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { id, name, arguments: params } = request.params; + this.log('debug', "Handling CallTool request", { id, name, params }); + + let toolId; + let toolDetails; + + try { + // Find the requested tool + toolId = id; + if (!toolId && name) { + for (const [tid, tool] of this.tools.entries()) { + if (tool.name === name) { + toolId = tid; + break; + } + } + } + + if (!toolId) { + throw new Error(\`Tool not found: \${id || name}\`); + } + + toolDetails = TOOLS.find(t => t.id === toolId); + if (!toolDetails) { + throw new Error(\`Tool details not found for ID: \${toolId}\`); + } + + this.log('info', \`Executing tool: \${toolId}\`); + + // Execute the API call + const result = await this.executeApiCall(toolDetails, params || {}); + + // Return the result in the correct MCP format + return { + content: [ + { + type: "application/json", + data: result + } + ] + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.log('error', \`Error executing tool \${toolId || name}: \${errorMessage}\`); + + // Format error according to MCP SDK expectations + return { + error: { + message: errorMessage, + details: error.response?.data + ? JSON.stringify(error.response.data) + : undefined + } + }; + } + }); + } + + /** + * Execute an API call for a tool + */ + async executeApiCall(tool, params) { + // Get method and path from tool + const method = tool.method; + let path = tool.path; + + // Clone params to avoid modifying the original + const requestParams = { ...params }; + + // Replace path parameters with values from params + Object.entries(requestParams).forEach(([key, value]) => { + const placeholder = \`{\${key}}\`; + if (path.includes(placeholder)) { + path = path.replace(placeholder, encodeURIComponent(String(value))); + delete requestParams[key]; // Remove used parameter + } + }); + + // Build the full URL + const baseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : \`\${this.baseUrl}/\`; + const cleanPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(cleanPath, baseUrl).toString(); + + this.log('debug', \`API Request: \${method} \${url}\`); + + try { + // Configure the request + const config = { + method: method.toLowerCase(), + url, + headers: { ...this.headers }, + }; + + // Apply security headers based on tool security requirements + if (tool.security && Array.isArray(tool.security)) { + for (const requirement of tool.security) { + for (const securitySchemeName of Object.keys(requirement)) { + const securityDefinition = SECURITY_SCHEMES[securitySchemeName]; + + if (securityDefinition) { + const authType = securityDefinition.type; + + // Handle API key + if (authType === 'apiKey') { + const apiKeyName = securityDefinition.name; + const envVarName = \`\${securitySchemeName.toUpperCase()}_\${apiKeyName.toUpperCase()}\`; + const apiKeyValue = process.env[envVarName]; + + if (apiKeyValue) { + if (securityDefinition.in === 'header') { + config.headers[apiKeyName] = apiKeyValue; + } else if (securityDefinition.in === 'query') { + config.params = config.params || {}; + config.params[apiKeyName] = apiKeyValue; + } + } else { + this.log('warning', \`API Key environment variable not found: \${envVarName}\`); + } + } + // Handle bearer token + else if (authType === 'http' && securityDefinition.scheme === 'bearer') { + const envVarName = \`\${securitySchemeName.toUpperCase()}_BEARERTOKEN\`; + const bearerToken = process.env[envVarName]; + + if (bearerToken) { + config.headers['Authorization'] = \`Bearer \${bearerToken}\`; + } else { + this.log('warning', \`Bearer Token environment variable not found: \${envVarName}\`); + } + } + // Handle basic auth + else if (authType === 'http' && securityDefinition.scheme === 'basic') { + const username = process.env[\`\${securitySchemeName.toUpperCase()}_USERNAME\`]; + const password = process.env[\`\${securitySchemeName.toUpperCase()}_PASSWORD\`]; + + if (username && password) { + const auth = Buffer.from(\`\${username}:\${password}\`).toString('base64'); + config.headers['Authorization'] = \`Basic \${auth}\`; + } else { + this.log('warning', \`Basic auth credentials not found for \${securitySchemeName}\`); + } + } + } + } + } + } + + // Add parameters based on request method + if (["GET", "DELETE"].includes(method)) { + // For GET/DELETE, send params as query string + config.params = { ...(config.params || {}), ...requestParams }; + } else { + // For POST/PUT/PATCH, send params as JSON body + config.data = requestParams; + config.headers["Content-Type"] = "application/json"; + } + + this.log('debug', "Request config:", { + url: config.url, + method: config.method, + params: config.params, + headers: Object.keys(config.headers) + }); + + // Execute the request + const response = await axios(config); + this.log('debug', \`Response status: \${response.status}\`); + + return response.data; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.log('error', \`API request failed: \${errorMessage}\`); + + if (axios.isAxiosError(error)) { + const responseData = error.response?.data; + const responseStatus = error.response?.status; + + this.log('error', 'API Error Details:', { + status: responseStatus, + data: typeof responseData === 'object' ? JSON.stringify(responseData) : responseData + }); + + // Rethrow with more context for better error handling + const detailedError = new Error(\`API request failed with status \${responseStatus}: \${errorMessage}\`); + detailedError.response = error.response; + throw detailedError; + } + + throw error; + } + } + + /** + * Log messages with appropriate level + * Only sends to MCP if we're connected + */ + log(level, message, data) { + // Always log to stderr for visibility + console.error(\`[\${level.toUpperCase()}] \${message}\${data ? ': ' + JSON.stringify(data) : ''}\`); + + // Only try to send via MCP if we're in debug mode or it's important + if (this.debug || level !== 'debug') { + try { + // Only send if server exists and is connected + if (this.server && this.server.isConnected) { + this.server.sendLoggingMessage({ + level, + data: \`[MCP Server] \${message}\${data ? ': ' + JSON.stringify(data) : ''}\` + }); + } + } catch (e) { + // If logging fails, log to stderr + console.error('Failed to send log via MCP:', e.message); + } + } + } + + /** + * Start the server + */ + async start() { + try { + // Create stdio transport + const transport = new StdioServerTransport(); + console.error("MCP Server starting on stdio transport"); + + // Connect to the transport + await this.server.connect(transport); + + // Now we can safely log via MCP + console.error(\`Registered \${this.tools.size} tools\`); + this.log('info', \`MCP Server started successfully with \${this.tools.size} tools\`); + } catch (error) { + console.error("Failed to start MCP server:", error); + process.exit(1); + } + } +} + +// Start the server +async function main() { + try { + const server = new MCPServer(); + await server.start(); + } catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } +} + +main(); +`; + + return serverCode; +} + +/** + * Generate server.ts for TypeScript support with MCP compatibility fixes + */ +function generateServerTS(config, spec, toolMap, securitySchemes) { + console.log('Generating server.ts file...'); + const toolsArray = Object.values(toolMap); + const hasSecuritySchemes = Object.keys(securitySchemes).length > 0; + + const serverCode = `#!/usr/bin/env node + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import axios, { AxiosRequestConfig, AxiosError } from "axios"; +import { config as dotenvConfig } from "dotenv"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + Tool, + JsonSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Load environment variables +dotenvConfig(); + +// Define tool and security scheme types +interface OpenApiTool extends Tool { + method: string; + path: string; + security: any[]; +} + +interface SecurityScheme { + type: string; + name?: string; + in?: string; + scheme?: string; +} + +// Define tool schemas +const TOOLS: OpenApiTool[] = ${JSON.stringify(toolsArray, null, 2)}; +const SECURITY_SCHEMES: Record = ${JSON.stringify(securitySchemes, null, 2)}; + +/** + * MCP Server for ${spec.info?.title || 'OpenAPI'} API + * Generated from OpenAPI spec version ${spec.info?.version || 'unknown'} + * Generated on ${new Date().toISOString()} + */ +class MCPServer { + private server: Server; + private tools: Map = new Map(); + private debug: boolean; + private baseUrl: string; + private headers: Record; + + constructor() { + // Initialize properties + this.debug = process.env.DEBUG === "true"; + this.baseUrl = process.env.API_BASE_URL || ""; + this.headers = this.parseHeaders(process.env.API_HEADERS || ""); + + // Initialize tools map - do this before creating server + this.initializeTools(); + + // Create MCP server with correct capabilities + this.server = new Server( + { + name: process.env.SERVER_NAME || "${config.name}", + version: process.env.SERVER_VERSION || "${config.version}", + }, + { + capabilities: { + tools: true, // Enable tools capability + }, + } + ); + + // Set up request handlers - don't log here + this.setupHandlers(); + } + + /** + * Parse headers from string + */ + private parseHeaders(headerStr: string): Record { + const headers: Record = {}; + if (headerStr) { + headerStr.split(",").forEach((header) => { + const [key, value] = header.split(":"); + if (key && value) headers[key.trim()] = value.trim(); + }); + } + return headers; + } + + /** + * Initialize tools map from OpenAPI spec + * This runs before the server is connected, so don't log here + */ + private initializeTools(): void { + // Initialize each tool in the tools map + for (const tool of TOOLS) { + this.tools.set(tool.id, { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as JsonSchema, + // Don't include security at the tool level + }); + } + + // Don't log here, we're not connected yet + console.error(\`Initialized \${this.tools.size} tools\`); + } + + /** + * Set up request handlers + */ + private setupHandlers(): void { + // Handle tool listing requests + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + this.log('debug', "Handling ListTools request"); + // Return tools in the format expected by MCP SDK + return { + tools: Array.from(this.tools.entries()).map(([id, tool]) => ({ + id, + ...tool, + })), + }; + }); + + // Handle tool execution requests + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { id, name, arguments: params } = request.params; + this.log('debug', "Handling CallTool request", { id, name, params }); + + let toolId: string | undefined; + let toolDetails: OpenApiTool | undefined; + + try { + // Find the requested tool + toolId = id; + if (!toolId && name) { + for (const [tid, tool] of this.tools.entries()) { + if (tool.name === name) { + toolId = tid; + break; + } + } + } + + if (!toolId) { + throw new Error(\`Tool not found: \${id || name}\`); + } + + toolDetails = TOOLS.find(t => t.id === toolId); + if (!toolDetails) { + throw new Error(\`Tool details not found for ID: \${toolId}\`); + } + + this.log('info', \`Executing tool: \${toolId}\`); + + // Execute the API call + const result = await this.executeApiCall(toolDetails, params || {}); + + // Return the result in correct MCP format + return { + content: [ + { + type: "application/json", + data: result + } + ] + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.log('error', \`Error executing tool \${toolId || name}: \${errorMessage}\`); + + // Format error according to MCP SDK expectations + return { + error: { + message: errorMessage, + details: error instanceof Error && 'response' in error + ? JSON.stringify((error as any).response?.data) + : undefined + } + }; + } + }); + } + + /** + * Execute an API call for a tool + */ + private async executeApiCall(tool: OpenApiTool, params: Record): Promise { + // Get method and path from tool + const method = tool.method; + let path = tool.path; + + // Clone params to avoid modifying the original + const requestParams = { ...params }; + + // Replace path parameters with values from params + Object.entries(requestParams).forEach(([key, value]) => { + const placeholder = \`{\${key}}\`; + if (path.includes(placeholder)) { + path = path.replace(placeholder, encodeURIComponent(String(value))); + delete requestParams[key]; // Remove used parameter + } + }); + + // Build the full URL + const baseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : \`\${this.baseUrl}/\`; + const cleanPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(cleanPath, baseUrl).toString(); + + this.log('debug', \`API Request: \${method} \${url}\`); + + try { + // Configure the request + const config: AxiosRequestConfig = { + method: method.toLowerCase(), + url, + headers: { ...this.headers }, + }; + + // Apply security headers based on tool security requirements + if (tool.security && Array.isArray(tool.security)) { + for (const requirement of tool.security) { + for (const securitySchemeName of Object.keys(requirement)) { + const securityDefinition = SECURITY_SCHEMES[securitySchemeName]; + + if (securityDefinition) { + const authType = securityDefinition.type; + + // Handle API key + if (authType === 'apiKey') { + const apiKeyName = securityDefinition.name || ''; + const envVarName = \`\${securitySchemeName.toUpperCase()}_\${apiKeyName.toUpperCase()}\`; + const apiKeyValue = process.env[envVarName]; + + if (apiKeyValue) { + if (securityDefinition.in === 'header') { + config.headers = config.headers || {}; + config.headers[apiKeyName] = apiKeyValue; + } else if (securityDefinition.in === 'query') { + config.params = config.params || {}; + config.params[apiKeyName] = apiKeyValue; + } + } else { + this.log('warning', \`API Key environment variable not found: \${envVarName}\`); + } + } + // Handle bearer token + else if (authType === 'http' && securityDefinition.scheme === 'bearer') { + const envVarName = \`\${securitySchemeName.toUpperCase()}_BEARERTOKEN\`; + const bearerToken = process.env[envVarName]; + + if (bearerToken) { + config.headers = config.headers || {}; + config.headers['Authorization'] = \`Bearer \${bearerToken}\`; + } else { + this.log('warning', \`Bearer Token environment variable not found: \${envVarName}\`); + } + } + // Handle basic auth + else if (authType === 'http' && securityDefinition.scheme === 'basic') { + const username = process.env[\`\${securitySchemeName.toUpperCase()}_USERNAME\`]; + const password = process.env[\`\${securitySchemeName.toUpperCase()}_PASSWORD\`]; + + if (username && password) { + const auth = Buffer.from(\`\${username}:\${password}\`).toString('base64'); + config.headers = config.headers || {}; + config.headers['Authorization'] = \`Basic \${auth}\`; + } else { + this.log('warning', \`Basic auth credentials not found for \${securitySchemeName}\`); + } + } + } + } + } + } + + // Add parameters based on request method + if (["GET", "DELETE"].includes(method)) { + // For GET/DELETE, send params as query string + config.params = { ...(config.params || {}), ...requestParams }; + } else { + // For POST/PUT/PATCH, send params as JSON body + config.data = requestParams; + if (config.headers) { + config.headers["Content-Type"] = "application/json"; + } + } + + this.log('debug', "Request config:", { + url: config.url, + method: config.method, + params: config.params, + headers: config.headers ? Object.keys(config.headers) : [] + }); + + // Execute the request + const response = await axios(config); + this.log('debug', \`Response status: \${response.status}\`); + + return response.data; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.log('error', \`API request failed: \${errorMessage}\`); + + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data; + const responseStatus = axiosError.response?.status; + + this.log('error', 'API Error Details:', { + status: responseStatus, + data: typeof responseData === 'object' ? JSON.stringify(responseData) : String(responseData) + }); + + // Rethrow with more context for better error handling + const detailedError = new Error(\`API request failed with status \${responseStatus}: \${errorMessage}\`); + (detailedError as any).response = axiosError.response; + throw detailedError; + } + + throw error; + } + } + + /** + * Log messages with appropriate level + * Only sends to MCP if we're connected + */ + private log(level: 'debug' | 'info' | 'warning' | 'error', message: string, data?: any): void { + // Always log to stderr for visibility + console.error(\`[\${level.toUpperCase()}] \${message}\${data ? ': ' + JSON.stringify(data) : ''}\`); + + // Only try to send via MCP if we're in debug mode or it's important + if (this.debug || level !== 'debug') { + try { + // Only send if server exists and is connected + if (this.server && (this.server as any).isConnected) { + this.server.sendLoggingMessage({ + level, + data: \`[MCP Server] \${message}\${data ? ': ' + JSON.stringify(data) : ''}\` + }); + } + } catch (e) { + // If logging fails, log to stderr + console.error('Failed to send log via MCP:', (e as Error).message); + } + } + } + + /** + * Start the server + */ + async start(): Promise { + try { + // Create stdio transport + const transport = new StdioServerTransport(); + console.error("MCP Server starting on stdio transport"); + + // Connect to the transport + await this.server.connect(transport); + + // Now we can safely log via MCP + console.error(\`Registered \${this.tools.size} tools\`); + this.log('info', \`MCP Server started successfully with \${this.tools.size} tools\`); + } catch (error) { + console.error("Failed to start MCP server:", error); + process.exit(1); + } + } +} + +// Start the server +async function main(): Promise { + try { + const server = new MCPServer(); + await server.start(); + } catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } +} + +main(); +`; + + return serverCode; +} + +export { generateServerFile, generateServerTS }; \ No newline at end of file diff --git a/src/tool-generator.js b/src/tool-generator.js new file mode 100644 index 0000000..7b4c4cb --- /dev/null +++ b/src/tool-generator.js @@ -0,0 +1,124 @@ +/** + * Generate a clean tool ID from an API path and method + */ +function generateToolId(method, path) { + // Remove leading slash and parameters + const cleanPath = path.replace(/^\//, '').replace(/[{}]/g, ''); + // Create a clean tool ID + return `${method.toUpperCase()}-${cleanPath}`.replace(/[^a-zA-Z0-9-]/g, '-'); +} + +/** + * Generate tool definitions from OpenAPI paths + */ +function generateTools(spec, verbose = false) { + const toolList = []; + const toolMapObj = {}; + const securitySchemes = spec.components?.securitySchemes || {}; + + // Check if spec.paths exists + if (!spec.paths) { + console.warn("Warning: No paths found in OpenAPI specification"); + return { tools: toolList, toolMap: toolMapObj, securitySchemes }; + } + + console.log(`Processing ${Object.keys(spec.paths).length} API paths...`); + + for (const [path, pathItem] of Object.entries(spec.paths)) { + if (!pathItem) continue; + + for (const [method, operation] of Object.entries(pathItem)) { + if (method === 'parameters' || !operation || typeof method !== 'string') continue; + + // Skip if not a valid HTTP method + const validMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']; + if (!validMethods.includes(method.toLowerCase())) continue; + + const op = operation; + // Get a unique ID for this tool + const toolId = generateToolId(method, path); + // Create a friendly name + const toolName = op.operationId || op.summary || `${method.toUpperCase()} ${path}`; + + if (verbose) { + console.log(`Processing endpoint: ${method.toUpperCase()} ${path} -> Tool ID: ${toolId}`); + } + + const tool = { + id: toolId, + name: toolName, + description: op.description || `Make a ${method.toUpperCase()} request to ${path}`, + method: method.toUpperCase(), + path: path, + inputSchema: { + type: 'object', + properties: {}, + required: [] + }, + security: op.security || spec.security || [] // Get security requirements for the operation or spec + }; + + // Add parameters from operation + if (op.parameters) { + for (const param of op.parameters) { + if ('name' in param && 'in' in param) { + const paramSchema = param.schema; + + // Add parameter to the schema + tool.inputSchema.properties[param.name] = { + type: paramSchema?.type || 'string', + description: param.description || `${param.name} parameter`, + }; + + // Add enum values if present + if (paramSchema?.enum) { + tool.inputSchema.properties[param.name].enum = paramSchema.enum; + } + + // Add required flag if needed + if (param.required) { + tool.inputSchema.required.push(param.name); + } + } + } + } + + // Handle request body for POST/PUT/PATCH methods + if (['post', 'put', 'patch'].includes(method.toLowerCase()) && op.requestBody) { + const contentType = op.requestBody.content?.['application/json']; + + if (contentType && contentType.schema) { + const bodySchema = contentType.schema; + + // Add body properties to the tool's input schema + if (bodySchema.properties) { + for (const [propName, propSchema] of Object.entries(bodySchema.properties)) { + tool.inputSchema.properties[propName] = { + type: propSchema.type || 'string', + description: propSchema.description || `${propName} property`, + }; + + // Add enum values if present + if (propSchema.enum) { + tool.inputSchema.properties[propName].enum = propSchema.enum; + } + } + + // Add required properties + if (bodySchema.required && Array.isArray(bodySchema.required)) { + tool.inputSchema.required.push(...bodySchema.required); + } + } + } + } + + toolList.push(tool); + toolMapObj[toolId] = tool; + } + } + + console.log(`Generated ${toolList.length} MCP tools from the OpenAPI spec`); + return { tools: toolList, toolMap: toolMapObj, securitySchemes }; // return securitySchemes as well +} + +export { generateToolId, generateTools }; \ No newline at end of file