From 6f645e06f361d700ddf8b2f45fd236ddc91ca4e7 Mon Sep 17 00:00:00 2001 From: harsha-iiiv Date: Sun, 13 Apr 2025 23:32:24 +0530 Subject: [PATCH] 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. --- .eslintrc.json | 18 + .gitignore | 50 ++- .prettierrc | 7 + build/generator.js | 590 --------------------------------- build/index.js | 82 ----- build/utils.js | 35 -- package.json | 97 +++--- src/generator.ts | 604 ---------------------------------- src/generator/config-files.ts | 178 ++++++++++ src/generator/env-file.ts | 96 ++++++ src/generator/index.ts | 9 + src/generator/oauth-docs.ts | 109 ++++++ src/generator/package-json.ts | 65 ++++ src/generator/server-code.ts | 227 +++++++++++++ src/generator/web-server.ts | 507 ++++++++++++++++++++++++++++ src/index.ts | 292 +++++++++++----- src/parser/extract-tools.ts | 219 ++++++++++++ src/parser/index.ts | 4 + src/types/index.ts | 57 ++++ src/utils.ts | 40 --- src/utils/code-gen.ts | 154 +++++++++ src/utils/helpers.ts | 112 +++++++ src/utils/index.ts | 7 + src/utils/security.ts | 584 ++++++++++++++++++++++++++++++++ src/utils/url.ts | 101 ++++++ tsconfig.json | 31 +- 26 files changed, 2778 insertions(+), 1497 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .prettierrc delete mode 100644 build/generator.js delete mode 100755 build/index.js delete mode 100644 build/utils.js delete mode 100644 src/generator.ts create mode 100644 src/generator/config-files.ts create mode 100644 src/generator/env-file.ts create mode 100644 src/generator/index.ts create mode 100644 src/generator/oauth-docs.ts create mode 100644 src/generator/package-json.ts create mode 100644 src/generator/server-code.ts create mode 100644 src/generator/web-server.ts create mode 100644 src/parser/extract-tools.ts create mode 100644 src/parser/index.ts create mode 100644 src/types/index.ts delete mode 100644 src/utils.ts create mode 100644 src/utils/code-gen.ts create mode 100644 src/utils/helpers.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/security.ts create mode 100644 src/utils/url.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..b707bc3 --- /dev/null +++ b/.eslintrc.json @@ -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": "^_" }] + } + } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1dcef2d..102a132 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,48 @@ -node_modules -.env \ No newline at end of file +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Coverage +coverage/ +.nyc_output/ + +# IDEs and editors +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace +*.sublime-workspace +*.sublime-project + +# OS specific +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# misc +.npm +.eslintcache \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0c6a633 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2 + } \ No newline at end of file diff --git a/build/generator.js b/build/generator.js deleted file mode 100644 index fef8d28..0000000 --- a/build/generator.js +++ /dev/null @@ -1,590 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; -import { generateOperationId } from './utils.js'; -/** - * Generates the TypeScript code content for the server's src/index.ts file. - */ -export function generateMcpServerCode(api, options, serverName, serverVersion) { - const tools = extractToolsFromApi(api); - const determinedBaseUrl = determineBaseUrl(api, options.baseUrl); - const listToolsCode = generateListTools(tools); - const callToolCode = generateCallTool(tools, determinedBaseUrl); - // --- Template for src/index.ts --- - return ` -// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion} -// Source OpenAPI spec: ${options.input} -// Generation date: ${new Date().toISOString()} - -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -// Import Schemas and Types from /types subpath with .js extension -import { - CallToolRequestSchema, - ListToolsRequestSchema, - type Tool, - type CallToolResult, - type CallToolRequest // Added type for the request parameter -} from "@modelcontextprotocol/sdk/types.js"; - -// Zod for runtime validation -import { z, ZodError } from 'zod'; -// Library to convert JSON Schema to Zod schema string at runtime -import { jsonSchemaToZod } from 'json-schema-to-zod'; - -// Define JsonObject locally as a utility type -type JsonObject = Record; - -import axios, { type AxiosRequestConfig, type AxiosError } from 'axios'; - -// --- Server Configuration --- -const SERVER_NAME = "${serverName}"; -const SERVER_VERSION = "${serverVersion}"; -const API_BASE_URL = "${determinedBaseUrl || ''}"; - -// --- Server Instance --- -const server = new Server( - { - name: SERVER_NAME, - version: SERVER_VERSION - }, - { - capabilities: { - tools: {} - } - } -); - -// --- Tool Definitions (for ListTools response) --- -// Corrected: Use Tool[] type -const toolsList: Tool[] = [ -${listToolsCode} -]; - -// --- Request Handlers --- - -// 1. List Available Tools Handler -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: toolsList, - }; -}); - -// 2. Call Tool Handler -// Corrected: Added explicit type for 'request' parameter -server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise => { - const { name: toolName, arguments: toolArgs } = request.params; - - const toolDefinition = toolsList.find(t => t.name === toolName); - - if (!toolDefinition) { - console.error(\`Error: Received request for unknown tool: \${toolName}\`); - return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] }; - } - - // --- Tool Execution Logic --- -${callToolCode} // This generated code now includes Zod validation - - // Fallback error - console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`); - return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] }; -}); - - -// --- Main Execution Function --- -async function main() { - try { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`); - } catch (error) { - console.error("Error during server startup:", error); - process.exit(1); - } -} - -// --- Cleanup Function --- -async function cleanup() { - console.error("Shutting down MCP server..."); - process.exit(0); -} - -// Register signal handlers -process.on('SIGINT', cleanup); -process.on('SIGTERM', cleanup); - -// --- Start the Server --- -main().catch((error) => { - console.error("Fatal error in main execution:", error); - process.exit(1); -}); - -// --- Helper Functions (Included in the generated server code) --- -function formatApiError(error: AxiosError): string { - let message = 'API request failed.'; - if (error.response) { - message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`; - const responseData = error.response.data; - const MAX_LEN = 200; - if (typeof responseData === 'string') { - message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`; - } else if (responseData) { - try { - const jsonString = JSON.stringify(responseData); - message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`; - } catch { - message += 'Response: [Could not serialize response data]'; - } - } else { - message += 'No response body received.'; - } - } else if (error.request) { - message = 'API Network Error: No response received from the server. Check network connectivity or server availability.'; - if (error.code) message += \` (Code: \${error.code})\`; - } else { - message = \`API Request Setup Error: \${error.message}\`; - } - return message; -} - -/** - * Attempts to dynamically generate and evaluate a Zod schema from a JSON schema. - * WARNING: Uses eval(), which can be a security risk if the schema input is untrusted. - * In this context, the schema originates from the generator/OpenAPI spec, reducing risk. - * @param jsonSchema The JSON Schema object (or boolean). - * @param toolName For error logging. - * @returns The evaluated Zod schema object. - * @throws If schema conversion or evaluation fails. - */ -function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny { - if (typeof jsonSchema !== 'object' || jsonSchema === null) { - // Handle boolean schemas or invalid input - console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`) - // Fallback to allowing any object - adjust if stricter handling is needed - return z.object({}).passthrough(); - } - try { - // Note: jsonSchemaToZod may require specific configurations or adjustments - // depending on the complexity of the JSON Schemas being converted. - const zodSchemaString = jsonSchemaToZod(jsonSchema); - - // IMPORTANT: Using eval() to execute the generated Zod schema string. - // This is generally discouraged due to security risks with untrusted input. - // Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec). - // The 'z' variable (from imported zod) must be in scope for eval. - const zodSchema = eval(zodSchemaString); - - if (typeof zodSchema?.parse !== 'function') { - throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.'); - } - return zodSchema as z.ZodTypeAny; - } catch (err: any) { - console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err); - // Fallback schema in case of conversion/evaluation error - // This allows any object, effectively skipping validation on error. - // Consider throwing the error if validation is critical. - return z.object({}).passthrough(); - } -} -`; -} -/** - * Generates the content for the package.json file for a buildable project. - * Adds zod and json-schema-to-zod dependencies. - */ -export function generatePackageJson(serverName, serverVersion) { - const packageData = { - name: serverName, - version: serverVersion, - description: `MCP Server generated from OpenAPI spec for ${serverName}`, - private: true, - type: "module", - main: "build/index.js", - files: ["build", "src"], - scripts: { - "start": "node build/index.js", - "build": "tsc && chmod 755 build/index.js", - "typecheck": "tsc --noEmit", - "prestart": "npm run build" - }, - engines: { - "node": ">=18.0.0" - }, - dependencies: { - "@modelcontextprotocol/sdk": "^1.9.0", - "axios": "^1.8.4", - "zod": "^3.24.2", - "json-schema-to-zod": "^2.6.1" - }, - devDependencies: { - "@types/node": "^18.19.0", - "typescript": "^5.4.5" - // Removed ts-node, tsc-watch - } - }; - return JSON.stringify(packageData, null, 2); -} -/** - * Generates the content for the tsconfig.json file for a buildable project. - * Enables stricter type checking. - */ -export function generateTsconfigJson() { - const tsconfigData = { - compilerOptions: { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] - }; - return JSON.stringify(tsconfigData, null, 2); -} -/** - * Generates the content for the .gitignore file. - */ -export function generateGitignore() { - // Content unchanged from previous version - return ` -# Node dependencies -node_modules -# Build output -dist -build - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Coverage directory -coverage -*.lcov -.nyc_output - -# Build artifacts -.grunt -bower_components -# build/Release # Covered by build/ above -jspm_packages/ -web_modules/ -.lock-wscript - -# VS Code files -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Caches -.eslintcache -.node_repl_history -.browserslistcache - -# Environment variables -.env -.env.*.local -.env.local -`; -} -// --- Helper Functions below are mostly unchanged, except generateCallTool --- -function determineBaseUrl(api, cmdLineBaseUrl) { - // Logic unchanged - if (cmdLineBaseUrl) - return cmdLineBaseUrl.replace(/\/$/, ''); - if (api.servers && api.servers.length === 1 && api.servers[0].url) - return api.servers[0].url.replace(/\/$/, ''); - if (api.servers && api.servers.length > 1) { - console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`); - return api.servers[0].url.replace(/\/$/, ''); - } - return null; -} -function extractToolsFromApi(api) { - // Logic unchanged - const tools = []; - const usedNames = new Set(); - if (!api.paths) - return tools; - for (const [path, pathItem] of Object.entries(api.paths)) { - if (!pathItem) - continue; - for (const method of Object.values(OpenAPIV3.HttpMethods)) { - const operation = pathItem[method]; - if (!operation) - continue; - let baseName = operation.operationId || generateOperationId(method, path); - if (!baseName) { - console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`); - continue; - } - let finalToolName = baseName; - let counter = 1; - while (usedNames.has(finalToolName)) - finalToolName = `${baseName}_${counter++}`; - usedNames.add(finalToolName); - const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`; - const { inputSchema, parameters, requestBody } = generateInputSchema(operation); - tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody }); - } - } - return tools; -} -function generateInputSchema(operation) { - // Logic unchanged - const properties = {}; - const required = []; - const allParameters = Array.isArray(operation.parameters) - ? operation.parameters.map(p => p) : []; - allParameters.forEach(param => { - if (!param.name || !param.schema) - return; - const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema); - if (typeof paramSchema === 'object') - paramSchema.description = param.description || paramSchema.description; - properties[param.name] = paramSchema; - if (param.required) - required.push(param.name); - }); - let opRequestBody = undefined; - if (operation.requestBody) { - opRequestBody = operation.requestBody; - const jsonContent = opRequestBody.content?.['application/json']; - if (jsonContent?.schema) { - const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema); - if (typeof bodySchema === 'object') - bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.'; - properties['requestBody'] = bodySchema; - if (opRequestBody.required) - required.push('requestBody'); - } - else { - const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined; - if (firstContent) { - const [contentType] = firstContent; - properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` }; - if (opRequestBody.required) - required.push('requestBody'); - } - } - } - const inputSchema = { type: 'object', properties, ...(required.length > 0 && { required }) }; - return { inputSchema, parameters: allParameters, requestBody: opRequestBody }; -} -function mapOpenApiSchemaToJsonSchema(schema) { - // Logic mostly unchanged, ensure it handles recursion correctly - if ('$ref' in schema) { - console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`); - return { type: 'object', description: `Unresolved: ${schema.$ref}` }; - } - if (typeof schema === 'boolean') - return schema; - const jsonSchema = { ...schema }; - if (schema.type === 'integer') - jsonSchema.type = 'number'; - delete jsonSchema.nullable; - delete jsonSchema.example; - delete jsonSchema.xml; - delete jsonSchema.externalDocs; - delete jsonSchema.deprecated; - delete jsonSchema.readOnly; - delete jsonSchema.writeOnly; - if (schema.nullable) { - if (Array.isArray(jsonSchema.type)) { - if (!jsonSchema.type.includes('null')) - jsonSchema.type.push('null'); - } - else if (typeof jsonSchema.type === 'string') - jsonSchema.type = [jsonSchema.type, 'null']; - else if (!jsonSchema.type) - jsonSchema.type = 'null'; - } - if (jsonSchema.type === 'object' && jsonSchema.properties) { - const mappedProps = {}; - for (const [key, propSchema] of Object.entries(jsonSchema.properties)) { - if (typeof propSchema === 'object' && propSchema !== null) - mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema); - else if (typeof propSchema === 'boolean') - mappedProps[key] = propSchema; - } - jsonSchema.properties = mappedProps; - } - if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) { - jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items); - } - return jsonSchema; -} -function generateListTools(tools) { - // Logic unchanged - if (tools.length === 0) - return " // No tools extracted from the OpenAPI spec."; - return tools.map(tool => { - const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`'); - let schemaString; - try { - schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' '); - } - catch (e) { - schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }'; - } - return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`; - }).join(''); // Changed join to empty string to avoid extra newline -} -/** - * Generates the 'if/else if' block for the CallTool handler. - * Includes runtime Zod validation. - */ -function generateCallTool(tools, baseUrl) { - if (tools.length === 0) - return ' // No tools defined, so no handlers generated.'; - const cases = tools.map(tool => { - const { name, method, path: rawPath, parameters, requestBody } = tool; - const pathParams = parameters.filter(p => p.in === 'path'); - const queryParams = parameters.filter(p => p.in === 'query'); - const headerParams = parameters.filter(p => p.in === 'header'); - // --- Code Generation Snippets --- - // Zod validation block (remains the same) - const argsValidationCode = ` - // --- Argument Validation using Zod --- - let validatedArgs: JsonObject; - try { - const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName); - const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {}; - validatedArgs = zodSchema.parse(argsToParse); - console.error(\`Arguments validated successfully for tool '\${toolName}'.\`); - } catch (error: any) { - if (error instanceof ZodError) { - const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`; - console.error(validationErrorMessage); - return { content: [{ type: 'text', text: validationErrorMessage }] }; - } else { - console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error); - return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] }; - } - } - // --- End Argument Validation --- -`; - // URL Path Construction (uses validatedArgs) - let urlPathCode = ` let urlPath = "${rawPath}";\n`; - pathParams.forEach(p => { - urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash - urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`; - }); - urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`; - urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`; - // Query Parameters Construction (uses validatedArgs) - let queryParamsCode = ' const queryParams: Record = {};\n'; - queryParams.forEach(p => { - queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name - queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`; - }); - // Headers Construction (uses validatedArgs) - let headersCode = ` const headers: Record = { 'Accept': 'application/json' };\n`; - headerParams.forEach(p => { - headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name - headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`; - }); - // **Corrected Request Body Handling** - let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData - let axiosDataProperty = ''; // String part for the Axios config's 'data' property - let requestContentType = 'application/json'; // Default assumption - if (requestBody) { // Only generate body handling if the tool expects one - // Declare the variable *before* config construction - requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`; - // Assign value *after* validation (which sets validatedArgs) - requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`; - requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`; - requestBodyDeclarationCode += ` }\n`; - // Determine Content-Type (must happen before headers are finalized in config) - if (requestBody.content?.['application/json']) { - requestContentType = 'application/json'; - } - else if (requestBody.content) { - const firstType = Object.keys(requestBody.content)[0]; - if (firstType) { - requestContentType = firstType; - } - } - // Add Content-Type header *if* data might exist - headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`; - headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`; - // Set the string for the Axios config 'data' property - axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data'; - } - // --- Assemble the 'if' block for this tool --- - // Ensure correct order: Validation -> Declarations -> Config -> Axios Call - return ` - // Handler for tool: ${name} - if (toolName === "${name}") { - try { -${argsValidationCode} - // --- API Call Preparation --- -${urlPathCode} -${queryParamsCode} -${headersCode} -${requestBodyDeclarationCode} // Declare and assign requestBodyData *here* - - // --- Axios Request Configuration --- - // Now 'requestBodyData' is declared before being referenced here - const config: AxiosRequestConfig = { - method: "${method.toUpperCase()}", - url: requestUrl, - params: queryParams, - headers: headers, - ${axiosDataProperty} // Include data property conditionally - // Add Authentication logic here if needed - }; - - console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`); - - // --- Execute API Call --- - const response = await axios(config); - - // --- Process Successful Response --- - let responseText = ''; - const contentType = response.headers['content-type']?.toLowerCase() || ''; - if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) { - try { responseText = JSON.stringify(response.data, null, 2); } - catch (e) { responseText = "[Error: Failed to stringify JSON response]"; } - } else if (typeof response.data === 'string') { - responseText = response.data; - } else if (response.data !== undefined && response.data !== null) { - responseText = String(response.data); - } else { - responseText = \`(Status: \${response.status} - No body content)\`; - } - return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], }; - - } catch (error: any) { - // --- Handle Errors (Post-Validation) --- - let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`; - if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); } - else if (error instanceof Error) { errorMessage = error.message; } - else { errorMessage = 'An unexpected error occurred: ' + String(error); } - console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack); - return { content: [{ type: "text", text: errorMessage }] }; - } - }`; - }).join(' else '); - return cases || ' // No tools defined, so no handlers generated.'; -} diff --git a/build/index.js b/build/index.js deleted file mode 100755 index 21ee1c3..0000000 --- a/build/index.js +++ /dev/null @@ -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 ', 'Path or URL to the OpenAPI specification file (JSON or YAML)') - .requiredOption('-o, --output ', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)') - .option('-n, --server-name ', 'Name for the generated MCP server package (default: derived from OpenAPI info title)') - .option('-v, --server-version ', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)') - .option('-b, --base-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(); diff --git a/build/utils.js b/build/utils.js deleted file mode 100644 index a48f5b1..0000000 --- a/build/utils.js +++ /dev/null @@ -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; -} diff --git a/package.json b/package.json index d494a80..fb7a302 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,65 @@ { - "name": "openapi-mcp-generator", - "version": "2.0.0", - "description": "Generates MCP server code from OpenAPI specifications", - "license": "MIT", - "author": "Harsha", - "type": "module", - "engines": { - "node": ">=18.0.0" - }, - "bin": { - "openapi-mcp-generator": "./build/index.js" - }, - "files": [ - "build" - ], - "scripts": { - "start": "node build/index.js", - "typecheck": "tsc --noEmit", - "build": "tsc && chmod 755 build/index.js" - }, - "keywords": [ - "openapi", - "mcp", - "model-context-protocol", - "generator", - "llm" - ], - "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": { + "name": "openapi-mcp-generator", + "version": "2.5.0-beta.0", + "description": "Generates MCP server code from OpenAPI specifications", + "license": "MIT", + "author": "Harsha", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "bin": { + "openapi-mcp-generator": "./dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "start": "node dist/index.js", + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "tsc && chmod 755 dist/index.js", + "prepare": "npm run clean && npm run build", + "prepublishOnly": "npm run lint", + "lint": "eslint src --ext .ts", + "format": "prettier --write \"src/**/*.ts\"" + }, + "keywords": [ + "openapi", + "mcp", + "model-context-protocol", + "generator", + "llm", + "ai", + "api" + ], + "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", - "@modelcontextprotocol/sdk": "^1.9.0", - "axios": "^1.8.4", "commander": "^13.1.0", - "zod": "^3.24.2" + "openapi-types": "^12.1.3" }, "devDependencies": { + "@types/node": "^22.14.1", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", "typescript": "^5.8.3" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.9.0" + "@modelcontextprotocol/sdk": "^1.9.0", + "zod": "^3.24.2", + "json-schema-to-zod": "^2.4.1" } -} + } \ No newline at end of file diff --git a/src/generator.ts b/src/generator.ts deleted file mode 100644 index de7767e..0000000 --- a/src/generator.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { OpenAPIV3 } from 'openapi-types'; -import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema'; -import { generateOperationId } from './utils.js'; - -interface CliOptions { - input: string; - output: string; // This is the directory path - serverName?: string; - serverVersion?: string; - baseUrl?: string; -} - -interface McpToolDefinition { - name: string; - description: string; - inputSchema: JSONSchema7 | boolean; - operationId: string; - method: string; - path: string; - parameters: OpenAPIV3.ParameterObject[]; - requestBody?: OpenAPIV3.RequestBodyObject; -} - - -/** - * Generates the TypeScript code content for the server's src/index.ts file. - */ -export function generateMcpServerCode( - api: OpenAPIV3.Document, - options: CliOptions, - serverName: string, - serverVersion: string -): string { - - const tools: McpToolDefinition[] = extractToolsFromApi(api); - const determinedBaseUrl = determineBaseUrl(api, options.baseUrl); - const listToolsCode = generateListTools(tools); - const callToolCode = generateCallTool(tools, determinedBaseUrl); - - // --- Template for src/index.ts --- - return ` -// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion} -// Source OpenAPI spec: ${options.input} -// Generation date: ${new Date().toISOString()} - -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -// Import Schemas and Types from /types subpath with .js extension -import { - CallToolRequestSchema, - ListToolsRequestSchema, - type Tool, - type CallToolResult, - type CallToolRequest // Added type for the request parameter -} from "@modelcontextprotocol/sdk/types.js"; - -// Zod for runtime validation -import { z, ZodError } from 'zod'; -// Library to convert JSON Schema to Zod schema string at runtime -import { jsonSchemaToZod } from 'json-schema-to-zod'; - -// Define JsonObject locally as a utility type -type JsonObject = Record; - -import axios, { type AxiosRequestConfig, type AxiosError } from 'axios'; - -// --- Server Configuration --- -const SERVER_NAME = "${serverName}"; -const SERVER_VERSION = "${serverVersion}"; -const API_BASE_URL = "${determinedBaseUrl || ''}"; - -// --- Server Instance --- -const server = new Server( - { - name: SERVER_NAME, - version: SERVER_VERSION - }, - { - capabilities: { - tools: {} - } - } -); - -// --- Tool Definitions (for ListTools response) --- -// Corrected: Use Tool[] type -const toolsList: Tool[] = [ -${listToolsCode} -]; - -// --- Request Handlers --- - -// 1. List Available Tools Handler -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: toolsList, - }; -}); - -// 2. Call Tool Handler -// Corrected: Added explicit type for 'request' parameter -server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise => { - const { name: toolName, arguments: toolArgs } = request.params; - - const toolDefinition = toolsList.find(t => t.name === toolName); - - if (!toolDefinition) { - console.error(\`Error: Received request for unknown tool: \${toolName}\`); - return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] }; - } - - // --- Tool Execution Logic --- -${callToolCode} // This generated code now includes Zod validation - - // Fallback error - console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`); - return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] }; -}); - - -// --- Main Execution Function --- -async function main() { - try { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`); - } catch (error) { - console.error("Error during server startup:", error); - process.exit(1); - } -} - -// --- Cleanup Function --- -async function cleanup() { - console.error("Shutting down MCP server..."); - process.exit(0); -} - -// Register signal handlers -process.on('SIGINT', cleanup); -process.on('SIGTERM', cleanup); - -// --- Start the Server --- -main().catch((error) => { - console.error("Fatal error in main execution:", error); - process.exit(1); -}); - -// --- Helper Functions (Included in the generated server code) --- -function formatApiError(error: AxiosError): string { - let message = 'API request failed.'; - if (error.response) { - message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`; - const responseData = error.response.data; - const MAX_LEN = 200; - if (typeof responseData === 'string') { - message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`; - } else if (responseData) { - try { - const jsonString = JSON.stringify(responseData); - message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`; - } catch { - message += 'Response: [Could not serialize response data]'; - } - } else { - message += 'No response body received.'; - } - } else if (error.request) { - message = 'API Network Error: No response received from the server. Check network connectivity or server availability.'; - if (error.code) message += \` (Code: \${error.code})\`; - } else { - message = \`API Request Setup Error: \${error.message}\`; - } - return message; -} - -/** - * Attempts to dynamically generate and evaluate a Zod schema from a JSON schema. - * WARNING: Uses eval(), which can be a security risk if the schema input is untrusted. - * In this context, the schema originates from the generator/OpenAPI spec, reducing risk. - * @param jsonSchema The JSON Schema object (or boolean). - * @param toolName For error logging. - * @returns The evaluated Zod schema object. - * @throws If schema conversion or evaluation fails. - */ -function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny { - if (typeof jsonSchema !== 'object' || jsonSchema === null) { - // Handle boolean schemas or invalid input - console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`) - // Fallback to allowing any object - adjust if stricter handling is needed - return z.object({}).passthrough(); - } - try { - // Note: jsonSchemaToZod may require specific configurations or adjustments - // depending on the complexity of the JSON Schemas being converted. - const zodSchemaString = jsonSchemaToZod(jsonSchema); - - // IMPORTANT: Using eval() to execute the generated Zod schema string. - // This is generally discouraged due to security risks with untrusted input. - // Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec). - // The 'z' variable (from imported zod) must be in scope for eval. - const zodSchema = eval(zodSchemaString); - - if (typeof zodSchema?.parse !== 'function') { - throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.'); - } - return zodSchema as z.ZodTypeAny; - } catch (err: any) { - console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err); - // Fallback schema in case of conversion/evaluation error - // This allows any object, effectively skipping validation on error. - // Consider throwing the error if validation is critical. - return z.object({}).passthrough(); - } -} -`; -} - -/** - * Generates the content for the package.json file for a buildable project. - * Adds zod and json-schema-to-zod dependencies. - */ -export function generatePackageJson(serverName: string, serverVersion: string): string { - const packageData = { - name: serverName, - version: serverVersion, - description: `MCP Server generated from OpenAPI spec for ${serverName}`, - private: true, - type: "module", - main: "build/index.js", - files: [ "build", "src" ], - scripts: { - "start": "node build/index.js", - "build": "tsc && chmod 755 build/index.js", - "typecheck": "tsc --noEmit", - "prestart": "npm run build" - }, - engines: { - "node": ">=18.0.0" - }, - dependencies: { - "@modelcontextprotocol/sdk": "^1.9.0", - "axios": "^1.8.4", - "zod": "^3.24.2", - "json-schema-to-zod": "^2.6.1" - }, - devDependencies: { - "@types/node": "^18.19.0", - "typescript": "^5.4.5" - // Removed ts-node, tsc-watch - } - }; - return JSON.stringify(packageData, null, 2); -} - -/** - * Generates the content for the tsconfig.json file for a buildable project. - * Enables stricter type checking. - */ -export function generateTsconfigJson(): string { - const tsconfigData = { - compilerOptions: { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] - }; - return JSON.stringify(tsconfigData, null, 2); -} - -/** - * Generates the content for the .gitignore file. - */ -export function generateGitignore(): string { - // Content unchanged from previous version - return ` -# Node dependencies -node_modules -# Build output -dist -build - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Coverage directory -coverage -*.lcov -.nyc_output - -# Build artifacts -.grunt -bower_components -# build/Release # Covered by build/ above -jspm_packages/ -web_modules/ -.lock-wscript - -# VS Code files -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Caches -.eslintcache -.node_repl_history -.browserslistcache - -# Environment variables -.env -.env.*.local -.env.local -`; -} - - -// --- Helper Functions below are mostly unchanged, except generateCallTool --- - -function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null { - // Logic unchanged - if (cmdLineBaseUrl) return cmdLineBaseUrl.replace(/\/$/, ''); - if (api.servers && api.servers.length === 1 && api.servers[0].url) return api.servers[0].url.replace(/\/$/, ''); - if (api.servers && api.servers.length > 1) { - console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`); - return api.servers[0].url.replace(/\/$/, ''); - } - return null; -} - -function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] { - // Logic unchanged - const tools: McpToolDefinition[] = []; - const usedNames = new Set(); - if (!api.paths) return tools; - for (const [path, pathItem] of Object.entries(api.paths)) { - if (!pathItem) continue; - for (const method of Object.values(OpenAPIV3.HttpMethods)) { - const operation = pathItem[method]; - if (!operation) continue; - let baseName = operation.operationId || generateOperationId(method, path); - if (!baseName) { - console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`); - continue; - } - let finalToolName = baseName; - let counter = 1; - while (usedNames.has(finalToolName)) finalToolName = `${baseName}_${counter++}`; - usedNames.add(finalToolName); - const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`; - const { inputSchema, parameters, requestBody } = generateInputSchema(operation); - tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody }); - } - } - return tools; -} - -function generateInputSchema(operation: OpenAPIV3.OperationObject): { inputSchema: JSONSchema7 | boolean, parameters: OpenAPIV3.ParameterObject[], requestBody?: OpenAPIV3.RequestBodyObject } { - // Logic unchanged - const properties: { [key: string]: JSONSchema7 | boolean } = {}; - const required: string[] = []; - const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters) - ? operation.parameters.map(p => p as OpenAPIV3.ParameterObject) : []; - allParameters.forEach(param => { - if (!param.name || !param.schema) return; - const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject); - if (typeof paramSchema === 'object') paramSchema.description = param.description || paramSchema.description; - properties[param.name] = paramSchema; - if (param.required) required.push(param.name); - }); - let opRequestBody: OpenAPIV3.RequestBodyObject | undefined = undefined; - if (operation.requestBody) { - opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject; - const jsonContent = opRequestBody.content?.['application/json']; - if (jsonContent?.schema) { - const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject); - if (typeof bodySchema === 'object') bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.'; - properties['requestBody'] = bodySchema; - if (opRequestBody.required) required.push('requestBody'); - } else { - const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined; - if(firstContent) { - const [contentType] = firstContent; - properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` }; - if (opRequestBody.required) required.push('requestBody'); - } - } - } - const inputSchema: JSONSchema7 = { type: 'object', properties, ...(required.length > 0 && { required }) }; - return { inputSchema, parameters: allParameters, requestBody: opRequestBody }; -} - -function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean { - // Logic mostly unchanged, ensure it handles recursion correctly - if ('$ref' in schema) { - console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`); - return { type: 'object', description: `Unresolved: ${schema.$ref}` }; - } - if (typeof schema === 'boolean') return schema; - const jsonSchema: JSONSchema7 = { ...schema } as any; - if (schema.type === 'integer') jsonSchema.type = 'number'; - delete (jsonSchema as any).nullable; delete (jsonSchema as any).example; delete (jsonSchema as any).xml; - delete (jsonSchema as any).externalDocs; delete (jsonSchema as any).deprecated; delete (jsonSchema as any).readOnly; delete (jsonSchema as any).writeOnly; - if (schema.nullable) { - if (Array.isArray(jsonSchema.type)) { if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null'); } - else if (typeof jsonSchema.type === 'string') jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null']; - else if (!jsonSchema.type) jsonSchema.type = 'null'; - } - if (jsonSchema.type === 'object' && jsonSchema.properties) { - const mappedProps: { [key: string]: JSONSchema7 | boolean } = {}; - for (const [key, propSchema] of Object.entries(jsonSchema.properties)) { - if (typeof propSchema === 'object' && propSchema !== null) mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject); - else if (typeof propSchema === 'boolean') mappedProps[key] = propSchema; - } - jsonSchema.properties = mappedProps; - } - if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) { - jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject); - } - return jsonSchema; -} - -function generateListTools(tools: McpToolDefinition[]): string { - // Logic unchanged - if (tools.length === 0) return " // No tools extracted from the OpenAPI spec."; - return tools.map(tool => { - const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`'); - let schemaString; try { schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' '); } - catch (e) { schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }'; } - return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`; - }).join(''); // Changed join to empty string to avoid extra newline -} - -/** - * Generates the 'if/else if' block for the CallTool handler. - * Includes runtime Zod validation. - */ -function generateCallTool(tools: McpToolDefinition[], baseUrl: string | null): string { - if (tools.length === 0) return ' // No tools defined, so no handlers generated.'; - - const cases = tools.map(tool => { - const { name, method, path: rawPath, parameters, requestBody } = tool; - const pathParams = parameters.filter(p => p.in === 'path'); - const queryParams = parameters.filter(p => p.in === 'query'); - const headerParams = parameters.filter(p => p.in === 'header'); - - // --- Code Generation Snippets --- - // Zod validation block (remains the same) - const argsValidationCode = ` - // --- Argument Validation using Zod --- - let validatedArgs: JsonObject; - try { - const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName); - const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {}; - validatedArgs = zodSchema.parse(argsToParse); - console.error(\`Arguments validated successfully for tool '\${toolName}'.\`); - } catch (error: any) { - if (error instanceof ZodError) { - const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`; - console.error(validationErrorMessage); - return { content: [{ type: 'text', text: validationErrorMessage }] }; - } else { - console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error); - return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] }; - } - } - // --- End Argument Validation --- -`; - - // URL Path Construction (uses validatedArgs) - let urlPathCode = ` let urlPath = "${rawPath}";\n`; - pathParams.forEach(p => { - urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash - urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`; - }); - urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`; - urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`; - - // Query Parameters Construction (uses validatedArgs) - let queryParamsCode = ' const queryParams: Record = {};\n'; - queryParams.forEach(p => { - queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name - queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`; - }); - - // Headers Construction (uses validatedArgs) - let headersCode = ` const headers: Record = { 'Accept': 'application/json' };\n`; - headerParams.forEach(p => { - headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name - headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`; - }); - - // **Corrected Request Body Handling** - let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData - let axiosDataProperty = ''; // String part for the Axios config's 'data' property - let requestContentType = 'application/json'; // Default assumption - - if (requestBody) { // Only generate body handling if the tool expects one - // Declare the variable *before* config construction - requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`; - // Assign value *after* validation (which sets validatedArgs) - requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`; - requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`; - requestBodyDeclarationCode += ` }\n`; - - // Determine Content-Type (must happen before headers are finalized in config) - if (requestBody.content?.['application/json']) { - requestContentType = 'application/json'; - } else if (requestBody.content) { - const firstType = Object.keys(requestBody.content)[0]; - if (firstType) { requestContentType = firstType; } - } - // Add Content-Type header *if* data might exist - headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`; - headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`; - - // Set the string for the Axios config 'data' property - axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data'; - } - - // --- Assemble the 'if' block for this tool --- - // Ensure correct order: Validation -> Declarations -> Config -> Axios Call - return ` - // Handler for tool: ${name} - if (toolName === "${name}") { - try { -${argsValidationCode} - // --- API Call Preparation --- -${urlPathCode} -${queryParamsCode} -${headersCode} -${requestBodyDeclarationCode} // Declare and assign requestBodyData *here* - - // --- Axios Request Configuration --- - // Now 'requestBodyData' is declared before being referenced here - const config: AxiosRequestConfig = { - method: "${method.toUpperCase()}", - url: requestUrl, - params: queryParams, - headers: headers, - ${axiosDataProperty} // Include data property conditionally - // Add Authentication logic here if needed - }; - - console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`); - - // --- Execute API Call --- - const response = await axios(config); - - // --- Process Successful Response --- - let responseText = ''; - const contentType = response.headers['content-type']?.toLowerCase() || ''; - if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) { - try { responseText = JSON.stringify(response.data, null, 2); } - catch (e) { responseText = "[Error: Failed to stringify JSON response]"; } - } else if (typeof response.data === 'string') { - responseText = response.data; - } else if (response.data !== undefined && response.data !== null) { - responseText = String(response.data); - } else { - responseText = \`(Status: \${response.status} - No body content)\`; - } - return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], }; - - } catch (error: any) { - // --- Handle Errors (Post-Validation) --- - let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`; - if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); } - else if (error instanceof Error) { errorMessage = error.message; } - else { errorMessage = 'An unexpected error occurred: ' + String(error); } - console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack); - return { content: [{ type: "text", text: errorMessage }] }; - } - }`; - }).join(' else '); - - return cases || ' // No tools defined, so no handlers generated.'; -} \ No newline at end of file diff --git a/src/generator/config-files.ts b/src/generator/config-files.ts new file mode 100644 index 0000000..2b6387b --- /dev/null +++ b/src/generator/config-files.ts @@ -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); +} \ No newline at end of file diff --git a/src/generator/env-file.ts b/src/generator/env-file.ts new file mode 100644 index 0000000..9591028 --- /dev/null +++ b/src/generator/env-file.ts @@ -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', +}; +`; +} \ No newline at end of file diff --git a/src/generator/index.ts b/src/generator/index.ts new file mode 100644 index 0000000..7652d90 --- /dev/null +++ b/src/generator/index.ts @@ -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'; \ No newline at end of file diff --git a/src/generator/oauth-docs.ts b/src/generator/oauth-docs.ts new file mode 100644 index 0000000..26dbd94 --- /dev/null +++ b/src/generator/oauth-docs.ts @@ -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; +} \ No newline at end of file diff --git a/src/generator/package-json.ts b/src/generator/package-json.ts new file mode 100644 index 0000000..7fe6a34 --- /dev/null +++ b/src/generator/package-json.ts @@ -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); +} \ No newline at end of file diff --git a/src/generator/server-code.ts b/src/generator/server-code.ts new file mode 100644 index 0000000..88b7dce --- /dev/null +++ b/src/generator/server-code.ts @@ -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; + +/** + * 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 = 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(); + } +} +`; +} \ No newline at end of file diff --git a/src/generator/web-server.ts b/src/generator/web-server.ts new file mode 100644 index 0000000..44e4b9a --- /dev/null +++ b/src/generator/web-server.ts @@ -0,0 +1,507 @@ +/** + * Generator for web server code for the MCP server + */ + +/** + * Generates web server code for the MCP server (using Express and SSE) + * + * @param port Server port (default: 3000) + * @returns Generated code for the web server + */ +export function generateWebServerCode(port: number = 3000): string { + return ` +/** + * Web server setup for HTTP-based MCP communication + */ +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; + +// Import server configuration constants +import { SERVER_NAME, SERVER_VERSION } from './index.js'; + +/** + * Sets up a web server for the MCP server using Server-Sent Events (SSE) + * + * @param server The MCP Server instance + * @param port The port to listen on (default: ${port}) + * @returns The Express app instance + */ +export async function setupWebServer(server: Server, port = ${port}) { + // Create Express app + const app = express(); + + // Enable CORS + app.use(cors()); + + // Parse JSON requests + app.use(express.json()); + + // Add a simple health check endpoint + app.get('/health', (_, res) => { + res.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION }); + }); + + // Store active SSE transports by session ID + const transports: {[sessionId: string]: SSEServerTransport} = {}; + + // SSE endpoint for clients to connect to + app.get("/sse", async (req: Request, res: Response) => { + // Set headers for SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + // Enable CORS for SSE + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); + + // Send initial comment to establish connection + res.write(':\\n\\n'); + + // Create new transport for this client + const transport = new SSEServerTransport('/api/messages', res); + const sessionId = transport.sessionId; + + console.error(\`New SSE connection established: \${sessionId}\`); + transports[sessionId] = transport; + + // Clean up on connection close + req.on('close', () => { + console.error(\`SSE connection closed: \${sessionId}\`); + delete transports[sessionId]; + }); + + // Connect the transport to the MCP server + try { + await server.connect(transport); + } catch (error) { + console.error(\`Error connecting transport for session \${sessionId}:\`, error); + // Don't try to send errors to the client here, as headers may already be sent + } + }); + + // API endpoint for clients to send messages + app.post("/api/messages", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + + if (!sessionId) { + return res.status(400).send('Missing sessionId query parameter'); + } + + const transport = transports[sessionId]; + + if (!transport) { + return res.status(404).send('No active session found with the provided sessionId'); + } + + try { + await transport.handlePostMessage(req, res); + } catch (error) { + console.error(\`Error handling message for session \${sessionId}:\`, error); + + // If the response hasn't been sent yet, send an error response + if (!res.headersSent) { + res.status(500).send('Internal server error processing message'); + } + } + }); + + // Static files for the web client (if any) + app.use(express.static('public')); + + // Start the server + app.listen(port, () => { + console.error(\`MCP Web Server running at http://localhost:\${port}\`); + console.error(\`- SSE Endpoint: http://localhost:\${port}/sse\`); + console.error(\`- Messages Endpoint: http://localhost:\${port}/api/messages?sessionId=YOUR_SESSION_ID\`); + console.error(\`- Health Check: http://localhost:\${port}/health\`); + }); + + return app; +} +`; +} + +/** + * Generates HTML client for testing the MCP server + * + * @param serverName The name of the MCP server + * @returns HTML content for the test client + */ +export function generateTestClientHtml(serverName: string): string { + return ` + + + + + ${serverName} MCP Test Client + + + +

${serverName} MCP Test Client

+

Disconnected

+ +
+
+ +
+ + +
+
+ + + +
+
+

Debug Console

+ +
+
+
+ + + +`; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b253ca9..4d42835 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,112 +1,226 @@ #!/usr/bin/env node - +/** + * OpenAPI to MCP Generator + * + * This tool generates a Model Context Protocol (MCP) server from an OpenAPI specification. + * It creates a Node.js project that implements MCP over stdio to proxy API requests. + */ +import fs from 'fs/promises'; +import path from 'path'; import { Command } from 'commander'; import SwaggerParser from '@apidevtools/swagger-parser'; -import type { OpenAPIV3 } from 'openapi-types'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { +import { OpenAPIV3 } from 'openapi-types'; + +// Import generators +import { generateMcpServerCode, generatePackageJson, - generateTsconfigJson, - generateGitignore -} from './generator.js'; + generateTsconfigJson, + generateGitignore, + generateEslintConfig, + generateJestConfig, + generatePrettierConfig, + generateEnvExample, + generateOAuth2Docs, + generateWebServerCode, + generateTestClientHtml +} from './generator/index.js'; -interface CliOptions { - input: string; - output: string; - serverName?: string; - serverVersion?: string; - baseUrl?: string; -} +// Import types +import { CliOptions } from './types/index.js'; +// Configure CLI const program = new Command(); program - .name('openapi-mcp-generator') - .description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification') - // Ensure these option definitions are robust - .requiredOption('-i, --input ', 'Path or URL to the OpenAPI specification file (JSON or YAML)') - .requiredOption('-o, --output ', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)') - .option('-n, --server-name ', 'Name for the generated MCP server package (default: derived from OpenAPI info title)') - .option('-v, --server-version ', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)') - .option('-b, --base-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 + .name('openapi-mcp-generator') + .description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification') + .requiredOption('-i, --input ', 'Path or URL to the OpenAPI specification file (JSON or YAML)') + .requiredOption('-o, --output ', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)') + .option('-n, --server-name ', 'Name for the generated MCP server package (default: derived from OpenAPI info title)') + .option('-v, --server-version ', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)') + .option('-b, --base-url ', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.') + .option('-t, --transport ', 'Server transport type: "stdio" or "web" (default: "stdio")') + .option('-p, --port ', 'Port for web server (used with --transport=web, default: 3000)', (val) => parseInt(val, 10)) + .option('--force', 'Overwrite existing files without prompting') + .version('2.0.0'); // Match package.json version // Parse arguments explicitly from process.argv -// This is generally the most reliable way program.parse(process.argv); // Retrieve the options AFTER parsing -const options = program.opts(); +const options = program.opts(); +/** + * Main function to run the generator + */ async function main() { - // Use the parsed options directly - const outputDir = options.output; - const inputSpec = options.input; // Use the parsed input value + // Use the parsed options directly + const outputDir = options.output; + const inputSpec = options.input; - const srcDir = path.join(outputDir, 'src'); - const serverFilePath = path.join(srcDir, 'index.ts'); - const packageJsonPath = path.join(outputDir, 'package.json'); - const tsconfigPath = path.join(outputDir, 'tsconfig.json'); - const gitignorePath = path.join(outputDir, '.gitignore'); + const srcDir = path.join(outputDir, 'src'); + const serverFilePath = path.join(srcDir, 'index.ts'); + const packageJsonPath = path.join(outputDir, 'package.json'); + const tsconfigPath = path.join(outputDir, 'tsconfig.json'); + const gitignorePath = path.join(outputDir, '.gitignore'); + const eslintPath = path.join(outputDir, '.eslintrc.json'); + const prettierPath = path.join(outputDir, '.prettierrc'); + const jestConfigPath = path.join(outputDir, 'jest.config.js'); + const envExamplePath = path.join(outputDir, '.env.example'); + const docsDir = path.join(outputDir, 'docs'); + const oauth2DocsPath = path.join(docsDir, 'oauth2-configuration.md'); + + // Web server files (if requested) + const webServerPath = path.join(srcDir, 'web-server.ts'); + const publicDir = path.join(outputDir, 'public'); + const indexHtmlPath = path.join(publicDir, 'index.html'); - try { - // Use the correct inputSpec variable - console.error(`Parsing OpenAPI spec: ${inputSpec}`); - const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document; - console.error('OpenAPI spec parsed successfully.'); - - // Use options directly for name/version/baseUrl determination - const serverNameRaw = options.serverName || api.info.title || 'my-mcp-server'; - 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); + // Check if output directory exists and is not empty + if (!options.force) { + try { + const dirExists = await fs.stat(outputDir).catch(() => false); + if (dirExists) { + const files = await fs.readdir(outputDir); + if (files.length > 0) { + console.error(`Error: Output directory ${outputDir} already exists and is not empty.`); + console.error('Use --force to overwrite existing files.'); + process.exit(1); + } + } + } catch (err) { + // Directory doesn't exist, which is fine + } + } + + // Parse OpenAPI spec + console.error(`Parsing OpenAPI spec: ${inputSpec}`); + const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document; + console.error('OpenAPI spec parsed successfully.'); + + // 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(); \ No newline at end of file +main().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/parser/extract-tools.ts b/src/parser/extract-tools.ts new file mode 100644 index 0000000..248782c --- /dev/null +++ b/src/parser/extract-tools.ts @@ -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(); + 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; +} \ No newline at end of file diff --git a/src/parser/index.ts b/src/parser/index.ts new file mode 100644 index 0000000..0bf5784 --- /dev/null +++ b/src/parser/index.ts @@ -0,0 +1,4 @@ +/** + * Parser module exports + */ +export * from './extract-tools.js'; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..5f25ef9 --- /dev/null +++ b/src/types/index.ts @@ -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; \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 7b3ee00..0000000 --- a/src/utils.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/utils/code-gen.ts b/src/utils/code-gen.ts new file mode 100644 index 0000000..b571bad --- /dev/null +++ b/src/utils/code-gen.ts @@ -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 => { + 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; +} \ No newline at end of file diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..71d672b --- /dev/null +++ b/src/utils/helpers.ts @@ -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 * '); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..b3f9f3c --- /dev/null +++ b/src/utils/index.ts @@ -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'; \ No newline at end of file diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 0000000..2f2f1d1 --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,584 @@ +/** + * Security handling utilities for OpenAPI to MCP generator + */ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * Get environment variable name for a security scheme + * + * @param schemeName Security scheme name + * @param type Type of security credentials + * @returns Environment variable name + */ +export function getEnvVarName( + schemeName: string, + type: 'API_KEY' | 'BEARER_TOKEN' | 'BASIC_USERNAME' | 'BASIC_PASSWORD' | 'OAUTH_CLIENT_ID' | 'OAUTH_CLIENT_SECRET' | 'OAUTH_TOKEN' | 'OAUTH_SCOPES' | 'OPENID_TOKEN' +): string { + const sanitizedName = schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase(); + return `${type}_${sanitizedName}`; +} + +/** + * Generates code for handling API key security + * + * @param scheme API key security scheme + * @returns Generated code + */ +export function generateApiKeySecurityCode(scheme: OpenAPIV3.ApiKeySecurityScheme): string { + const schemeName = 'schemeName'; // Placeholder, will be replaced in template + return ` + if (scheme?.type === 'apiKey') { + const apiKey = process.env[\`${getEnvVarName(schemeName, 'API_KEY')}\`]; + if (apiKey) { + if (scheme.in === 'header') { + headers[scheme.name.toLowerCase()] = apiKey; + } + else if (scheme.in === 'query') { + queryParams[scheme.name] = apiKey; + } + else if (scheme.in === 'cookie') { + headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`; + } + } + }`; +} + +/** + * Generates code for handling HTTP security (Bearer/Basic) + * + * @returns Generated code + */ +export function generateHttpSecurityCode(): string { + const schemeName = 'schemeName'; // Placeholder, will be replaced in template + return ` + else if (scheme?.type === 'http') { + if (scheme.scheme?.toLowerCase() === 'bearer') { + const token = process.env[\`${getEnvVarName(schemeName, 'BEARER_TOKEN')}\`]; + if (token) { + headers['authorization'] = \`Bearer \${token}\`; + } + } + else if (scheme.scheme?.toLowerCase() === 'basic') { + const username = process.env[\`${getEnvVarName(schemeName, 'BASIC_USERNAME')}\`]; + const password = process.env[\`${getEnvVarName(schemeName, 'BASIC_PASSWORD')}\`]; + if (username && password) { + headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`; + } + } + }`; +} + +/** + * Generates code for OAuth2 token acquisition + * + * @returns Generated code for OAuth2 token acquisition + */ +export function generateOAuth2TokenAcquisitionCode(): string { + return ` +/** + * Type definition for cached OAuth tokens + */ +interface TokenCacheEntry { + token: string; + expiresAt: number; +} + +/** + * Declare global __oauthTokenCache property for TypeScript + */ +declare global { + var __oauthTokenCache: Record | undefined; +} + +/** + * Acquires an OAuth2 token using client credentials flow + * + * @param schemeName Name of the security scheme + * @param scheme OAuth2 security scheme + * @returns Acquired token or null if unable to acquire + */ +async function acquireOAuth2Token(schemeName: string, scheme: any): Promise { + try { + // Check if we have the necessary credentials + const clientId = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_ID')}\`]; + const clientSecret = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_SECRET')}\`]; + const scopes = process.env[\`${getEnvVarName('schemeName', 'OAUTH_SCOPES')}\`]; + + if (!clientId || !clientSecret) { + console.error(\`Missing client credentials for OAuth2 scheme '\${schemeName}'\`); + return null; + } + + // Initialize token cache if needed + if (typeof global.__oauthTokenCache === 'undefined') { + global.__oauthTokenCache = {}; + } + + // Check if we have a cached token + const cacheKey = \`\${schemeName}_\${clientId}\`; + const cachedToken = global.__oauthTokenCache[cacheKey]; + const now = Date.now(); + + if (cachedToken && cachedToken.expiresAt > now) { + console.error(\`Using cached OAuth2 token for '\${schemeName}' (expires in \${Math.floor((cachedToken.expiresAt - now) / 1000)} seconds)\`); + return cachedToken.token; + } + + // Determine token URL based on flow type + let tokenUrl = ''; + if (scheme.flows?.clientCredentials?.tokenUrl) { + tokenUrl = scheme.flows.clientCredentials.tokenUrl; + console.error(\`Using client credentials flow for '\${schemeName}'\`); + } else if (scheme.flows?.password?.tokenUrl) { + tokenUrl = scheme.flows.password.tokenUrl; + console.error(\`Using password flow for '\${schemeName}'\`); + } else { + console.error(\`No supported OAuth2 flow found for '\${schemeName}'\`); + return null; + } + + // Prepare the token request + let formData = new URLSearchParams(); + formData.append('grant_type', 'client_credentials'); + + // Add scopes if specified + if (scopes) { + formData.append('scope', scopes); + } + + console.error(\`Requesting OAuth2 token from \${tokenUrl}\`); + + // Make the token request + const response = await axios({ + method: 'POST', + url: tokenUrl, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': \`Basic \${Buffer.from(\`\${clientId}:\${clientSecret}\`).toString('base64')}\` + }, + data: formData.toString() + }); + + // Process the response + if (response.data?.access_token) { + const token = response.data.access_token; + const expiresIn = response.data.expires_in || 3600; // Default to 1 hour + + // Cache the token + global.__oauthTokenCache[cacheKey] = { + token, + expiresAt: now + (expiresIn * 1000) - 60000 // Expire 1 minute early + }; + + console.error(\`Successfully acquired OAuth2 token for '\${schemeName}' (expires in \${expiresIn} seconds)\`); + return token; + } else { + console.error(\`Failed to acquire OAuth2 token for '\${schemeName}': No access_token in response\`); + return null; + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(\`Error acquiring OAuth2 token for '\${schemeName}':\`, errorMessage); + return null; + } +} +`; +} + +/** + * Generates code for executing API tools with security handling + * + * @param securitySchemes Security schemes from OpenAPI spec + * @returns Generated code for the execute API tool function + */ +export function generateExecuteApiToolFunction( + securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes'] +): string { + // Generate OAuth2 token acquisition function + const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode(); + + // Generate security handling code for checking, applying security + const securityCode = ` + // Apply security requirements if available + // Security requirements use OR between array items and AND within each object + const appliedSecurity = definition.securityRequirements?.find(req => { + // Try each security requirement (combined with OR) + return Object.entries(req).every(([schemeName, scopesArray]) => { + const scheme = allSecuritySchemes[schemeName]; + if (!scheme) return false; + + // API Key security (header, query, cookie) + if (scheme.type === 'apiKey') { + return !!process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + } + + // HTTP security (basic, bearer) + if (scheme.type === 'http') { + if (scheme.scheme?.toLowerCase() === 'bearer') { + return !!process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + } + else if (scheme.scheme?.toLowerCase() === 'basic') { + return !!process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] && + !!process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + } + } + + // OAuth2 security + if (scheme.type === 'oauth2') { + // Check for pre-existing token + if (process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) { + return true; + } + + // Check for client credentials for auto-acquisition + if (process.env[\`OAUTH_CLIENT_ID_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] && + process.env[\`OAUTH_CLIENT_SECRET_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) { + // Verify we have a supported flow + if (scheme.flows?.clientCredentials || scheme.flows?.password) { + return true; + } + } + + return false; + } + + // OpenID Connect + if (scheme.type === 'openIdConnect') { + return !!process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + } + + return false; + }); + }); + + // If we found matching security scheme(s), apply them + if (appliedSecurity) { + // Apply each security scheme from this requirement (combined with AND) + for (const [schemeName, scopesArray] of Object.entries(appliedSecurity)) { + const scheme = allSecuritySchemes[schemeName]; + + // API Key security + if (scheme?.type === 'apiKey') { + const apiKey = process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + if (apiKey) { + if (scheme.in === 'header') { + headers[scheme.name.toLowerCase()] = apiKey; + console.error(\`Applied API key '\${schemeName}' in header '\${scheme.name}'\`); + } + else if (scheme.in === 'query') { + queryParams[scheme.name] = apiKey; + console.error(\`Applied API key '\${schemeName}' in query parameter '\${scheme.name}'\`); + } + else if (scheme.in === 'cookie') { + // Add the cookie, preserving other cookies if they exist + headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`; + console.error(\`Applied API key '\${schemeName}' in cookie '\${scheme.name}'\`); + } + } + } + // HTTP security (Bearer or Basic) + else if (scheme?.type === 'http') { + if (scheme.scheme?.toLowerCase() === 'bearer') { + const token = process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + if (token) { + headers['authorization'] = \`Bearer \${token}\`; + console.error(\`Applied Bearer token for '\${schemeName}'\`); + } + } + else if (scheme.scheme?.toLowerCase() === 'basic') { + const username = process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + const password = process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + if (username && password) { + headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`; + console.error(\`Applied Basic authentication for '\${schemeName}'\`); + } + } + } + // OAuth2 security + else if (scheme?.type === 'oauth2') { + // First try to use a pre-provided token + let token = process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + + // If no token but we have client credentials, try to acquire a token + if (!token && (scheme.flows?.clientCredentials || scheme.flows?.password)) { + console.error(\`Attempting to acquire OAuth token for '\${schemeName}'\`); + token = (await acquireOAuth2Token(schemeName, scheme)) ?? ''; + } + + // Apply token if available + if (token) { + headers['authorization'] = \`Bearer \${token}\`; + console.error(\`Applied OAuth2 token for '\${schemeName}'\`); + + // List the scopes that were requested, if any + const scopes = scopesArray as string[]; + if (scopes && scopes.length > 0) { + console.error(\`Requested scopes: \${scopes.join(', ')}\`); + } + } + } + // OpenID Connect + else if (scheme?.type === 'openIdConnect') { + const token = process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]; + if (token) { + headers['authorization'] = \`Bearer \${token}\`; + console.error(\`Applied OpenID Connect token for '\${schemeName}'\`); + + // List the scopes that were requested, if any + const scopes = scopesArray as string[]; + if (scopes && scopes.length > 0) { + console.error(\`Requested scopes: \${scopes.join(', ')}\`); + } + } + } + } + } + // Log warning if security is required but not available + else if (definition.securityRequirements?.length > 0) { + // First generate a more readable representation of the security requirements + const securityRequirementsString = definition.securityRequirements + .map(req => { + const parts = Object.entries(req) + .map(([name, scopesArray]) => { + const scopes = scopesArray as string[]; + if (scopes.length === 0) return name; + return \`\${name} (scopes: \${scopes.join(', ')})\`; + }) + .join(' AND '); + return \`[\${parts}]\`; + }) + .join(' OR '); + + console.warn(\`Tool '\${toolName}' requires security: \${securityRequirementsString}, but no suitable credentials found.\`); + } + `; + + // Generate complete execute API tool function + return ` +${oauth2TokenAcquisitionCode} + +/** + * Executes an API tool with the provided arguments + * + * @param toolName Name of the tool to execute + * @param definition Tool definition + * @param toolArgs Arguments provided by the user + * @param allSecuritySchemes Security schemes from the OpenAPI spec + * @returns Call tool result + */ +async function executeApiTool( + toolName: string, + definition: McpToolDefinition, + toolArgs: JsonObject, + allSecuritySchemes: Record +): Promise { + try { + // Validate arguments against the input schema + let validatedArgs: JsonObject; + try { + const zodSchema = getZodSchemaFromJsonSchema(definition.inputSchema, toolName); + const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {}; + validatedArgs = zodSchema.parse(argsToParse); + } catch (error: unknown) { + if (error instanceof ZodError) { + const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`; + return { content: [{ type: 'text', text: validationErrorMessage }] }; + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + return { content: [{ type: 'text', text: \`Internal error during validation setup: \${errorMessage}\` }] }; + } + } + + // Prepare URL, query parameters, headers, and request body + let urlPath = definition.pathTemplate; + const queryParams: Record = {}; + const headers: Record = { 'Accept': 'application/json' }; + let requestBodyData: any = undefined; + + // Apply parameters to the URL path, query, or headers + definition.executionParameters.forEach((param) => { + const value = validatedArgs[param.name]; + if (typeof value !== 'undefined' && value !== null) { + if (param.in === 'path') { + urlPath = urlPath.replace(\`{\${param.name}}\`, encodeURIComponent(String(value))); + } + else if (param.in === 'query') { + queryParams[param.name] = value; + } + else if (param.in === 'header') { + headers[param.name.toLowerCase()] = String(value); + } + } + }); + + // Ensure all path parameters are resolved + if (urlPath.includes('{')) { + throw new Error(\`Failed to resolve path parameters: \${urlPath}\`); + } + + // Construct the full URL + const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath; + + // Handle request body if needed + if (definition.requestBodyContentType && typeof validatedArgs['requestBody'] !== 'undefined') { + requestBodyData = validatedArgs['requestBody']; + headers['content-type'] = definition.requestBodyContentType; + } + +${securityCode} + + // Prepare the axios request configuration + const config: AxiosRequestConfig = { + method: definition.method.toUpperCase(), + url: requestUrl, + params: queryParams, + headers: headers, + ...(requestBodyData !== undefined && { data: requestBodyData }), + }; + + // Log request info to stderr (doesn't affect MCP output) + console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`); + + // Execute the request + const response = await axios(config); + + // Process and format the response + let responseText = ''; + const contentType = response.headers['content-type']?.toLowerCase() || ''; + + // Handle JSON responses + if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) { + try { + responseText = JSON.stringify(response.data, null, 2); + } catch (e) { + responseText = "[Stringify Error]"; + } + } + // Handle string responses + else if (typeof response.data === 'string') { + responseText = response.data; + } + // Handle other response types + else if (response.data !== undefined && response.data !== null) { + responseText = String(response.data); + } + // Handle empty responses + else { + responseText = \`(Status: \${response.status} - No body content)\`; + } + + // Return formatted response + return { + content: [ + { + type: "text", + text: \`API Response (Status: \${response.status}):\\n\${responseText}\` + } + ], + }; + + } catch (error: unknown) { + // Handle errors during execution + let errorMessage: string; + + // Format Axios errors specially + if (axios.isAxiosError(error)) { + errorMessage = formatApiError(error); + } + // Handle standard errors + else if (error instanceof Error) { + errorMessage = error.message; + } + // Handle unexpected error types + else { + errorMessage = 'Unexpected error: ' + String(error); + } + + // Log error to stderr + console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage); + + // Return error message to client + return { content: [{ type: "text", text: errorMessage }] }; + } +} +`; +} + +/** + * Gets security scheme documentation for README + * + * @param securitySchemes Security schemes from OpenAPI spec + * @returns Documentation for security schemes + */ +export function getSecuritySchemesDocs( + securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes'] +): string { + if (!securitySchemes) return 'No security schemes defined in the OpenAPI spec.'; + + let docs = ''; + + for (const [name, schemeOrRef] of Object.entries(securitySchemes)) { + if ('$ref' in schemeOrRef) { + docs += `- \`${name}\`: Referenced security scheme (reference not resolved)\n`; + continue; + } + + const scheme = schemeOrRef; + + if (scheme.type === 'apiKey') { + const envVar = getEnvVarName(name, 'API_KEY'); + docs += `- \`${envVar}\`: API key for ${scheme.name} (in ${scheme.in})\n`; + } + else if (scheme.type === 'http') { + if (scheme.scheme?.toLowerCase() === 'bearer') { + const envVar = getEnvVarName(name, 'BEARER_TOKEN'); + docs += `- \`${envVar}\`: Bearer token for authentication\n`; + } + else if (scheme.scheme?.toLowerCase() === 'basic') { + const usernameEnvVar = getEnvVarName(name, 'BASIC_USERNAME'); + const passwordEnvVar = getEnvVarName(name, 'BASIC_PASSWORD'); + docs += `- \`${usernameEnvVar}\`: Username for Basic authentication\n`; + docs += `- \`${passwordEnvVar}\`: Password for Basic authentication\n`; + } + } + else if (scheme.type === 'oauth2') { + const flowTypes = scheme.flows ? Object.keys(scheme.flows) : ['unknown']; + + // Add client credentials for OAuth2 + const clientIdVar = getEnvVarName(name, 'OAUTH_CLIENT_ID'); + const clientSecretVar = getEnvVarName(name, 'OAUTH_CLIENT_SECRET'); + docs += `- \`${clientIdVar}\`: Client ID for OAuth2 authentication (${flowTypes.join(', ')} flow)\n`; + docs += `- \`${clientSecretVar}\`: Client secret for OAuth2 authentication\n`; + + // Add OAuth token for manual setting + const tokenVar = getEnvVarName(name, 'OAUTH_TOKEN'); + docs += `- \`${tokenVar}\`: OAuth2 token (if not using automatic token acquisition)\n`; + + // Add scopes env var + const scopesVar = getEnvVarName(name, 'OAUTH_SCOPES'); + docs += `- \`${scopesVar}\`: Space-separated list of OAuth2 scopes to request\n`; + + // If available, list flow-specific details + if (scheme.flows?.clientCredentials) { + docs += ` Client Credentials Flow Token URL: ${scheme.flows.clientCredentials.tokenUrl}\n`; + + // List available scopes if defined + if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) { + docs += ` Available scopes:\n`; + for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) { + docs += ` - \`${scope}\`: ${description}\n`; + } + } + } + } + else if (scheme.type === 'openIdConnect') { + const tokenVar = getEnvVarName(name, 'OPENID_TOKEN'); + docs += `- \`${tokenVar}\`: OpenID Connect token\n`; + if (scheme.openIdConnectUrl) { + docs += ` OpenID Connect Discovery URL: ${scheme.openIdConnectUrl}\n`; + } + } + } + + return docs; +} \ No newline at end of file diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..16dfd4a --- /dev/null +++ b/src/utils/url.ts @@ -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 { + 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; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 28933fb..6ab61fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,18 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} \ No newline at end of file + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] + } \ No newline at end of file