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
.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
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@ -1,112 +1,226 @@
#!/usr/bin/env node #!/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 { Command } from 'commander';
import SwaggerParser from '@apidevtools/swagger-parser'; import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
import * as fs from 'fs/promises';
import * as path from 'path'; // Import generators
import { import {
generateMcpServerCode, generateMcpServerCode,
generatePackageJson, generatePackageJson,
generateTsconfigJson, generateTsconfigJson,
generateGitignore generateGitignore,
} from './generator.js'; generateEslintConfig,
generateJestConfig,
generatePrettierConfig,
generateEnvExample,
generateOAuth2Docs,
generateWebServerCode,
generateTestClientHtml
} from './generator/index.js';
interface CliOptions { // Import types
input: string; import { CliOptions } from './types/index.js';
output: string;
serverName?: string;
serverVersion?: string;
baseUrl?: string;
}
// Configure CLI
const program = new Command(); const program = new Command();
program program
.name('openapi-mcp-generator') .name('openapi-mcp-generator')
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification') .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('-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)')
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)') .option('-n, --server-name <n>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
.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('-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('-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")')
.version('2.0.0'); // Match package.json version .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 // Parse arguments explicitly from process.argv
// This is generally the most reliable way
program.parse(process.argv); program.parse(process.argv);
// Retrieve the options AFTER parsing // 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() { async function main() {
// Use the parsed options directly // Use the parsed options directly
const outputDir = options.output; const outputDir = options.output;
const inputSpec = options.input; // Use the parsed input value const inputSpec = options.input;
const srcDir = path.join(outputDir, 'src'); const srcDir = path.join(outputDir, 'src');
const serverFilePath = path.join(srcDir, 'index.ts'); const serverFilePath = path.join(srcDir, 'index.ts');
const packageJsonPath = path.join(outputDir, 'package.json'); const packageJsonPath = path.join(outputDir, 'package.json');
const tsconfigPath = path.join(outputDir, 'tsconfig.json'); const tsconfigPath = path.join(outputDir, 'tsconfig.json');
const gitignorePath = path.join(outputDir, '.gitignore'); 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');
try { // Web server files (if requested)
// Use the correct inputSpec variable const webServerPath = path.join(srcDir, 'web-server.ts');
console.error(`Parsing OpenAPI spec: ${inputSpec}`); const publicDir = path.join(outputDir, 'public');
const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document; const indexHtmlPath = path.join(publicDir, 'index.html');
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 { try {
await fs.rm(outputDir, { recursive: true, force: true }); // Check if output directory exists and is not empty
console.error(`Cleaned up partially created directory: ${outputDir}`); if (!options.force) {
} catch (cleanupError) { try {
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError); 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.');
// 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');
console.error('Generating server code...');
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
console.error('Generating package.json...');
const packageJsonContent = generatePackageJson(serverName, serverVersion, options.transport === 'web');
console.error('Generating tsconfig.json...');
const tsconfigJsonContent = generateTsconfigJson();
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("---");
} 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);
} }
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

File diff suppressed because it is too large Load Diff

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": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "Node16", "module": "NodeNext",
"moduleResolution": "Node16", "moduleResolution": "NodeNext",
"outDir": "./build", "esModuleInterop": true,
"rootDir": "./src", "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"esModuleInterop": true, "skipLibCheck": true,
"skipLibCheck": true, "declaration": true,
"forceConsistentCasingInFileNames": true "sourceMap": true,
}, "outDir": "./dist",
"include": ["src/**/*"], "rootDir": "./src",
"exclude": ["node_modules"] "resolveJsonModule": true
} },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}