feat: Add parser and type definitions for OpenAPI to MCP generator
- Introduced a new parser module with exports from extract-tools. - Created core type definitions for CLI options and MCP tool definitions. - Removed outdated utility functions and replaced them with new code generation utilities. - Implemented security handling utilities for API key, HTTP, and OAuth2 authentication. - Added URL handling utilities for base URL determination and query parameter management. - Updated TypeScript configuration for improved module resolution and output settings.
This commit is contained in:
parent
7e5c34bc1f
commit
6f645e06f3
18
.eslintrc.json
Normal file
18
.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
50
.gitignore
vendored
50
.gitignore
vendored
@ -1,2 +1,48 @@
|
|||||||
node_modules
|
# Dependencies
|
||||||
.env
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@ -1,590 +0,0 @@
|
|||||||
import { OpenAPIV3 } from 'openapi-types';
|
|
||||||
import { generateOperationId } from './utils.js';
|
|
||||||
/**
|
|
||||||
* Generates the TypeScript code content for the server's src/index.ts file.
|
|
||||||
*/
|
|
||||||
export function generateMcpServerCode(api, options, serverName, serverVersion) {
|
|
||||||
const tools = extractToolsFromApi(api);
|
|
||||||
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
|
|
||||||
const listToolsCode = generateListTools(tools);
|
|
||||||
const callToolCode = generateCallTool(tools, determinedBaseUrl);
|
|
||||||
// --- Template for src/index.ts ---
|
|
||||||
return `
|
|
||||||
// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion}
|
|
||||||
// Source OpenAPI spec: ${options.input}
|
|
||||||
// Generation date: ${new Date().toISOString()}
|
|
||||||
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
|
|
||||||
// Import Schemas and Types from /types subpath with .js extension
|
|
||||||
import {
|
|
||||||
CallToolRequestSchema,
|
|
||||||
ListToolsRequestSchema,
|
|
||||||
type Tool,
|
|
||||||
type CallToolResult,
|
|
||||||
type CallToolRequest // Added type for the request parameter
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
|
|
||||||
// Zod for runtime validation
|
|
||||||
import { z, ZodError } from 'zod';
|
|
||||||
// Library to convert JSON Schema to Zod schema string at runtime
|
|
||||||
import { jsonSchemaToZod } from 'json-schema-to-zod';
|
|
||||||
|
|
||||||
// Define JsonObject locally as a utility type
|
|
||||||
type JsonObject = Record<string, any>;
|
|
||||||
|
|
||||||
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
|
|
||||||
|
|
||||||
// --- Server Configuration ---
|
|
||||||
const SERVER_NAME = "${serverName}";
|
|
||||||
const SERVER_VERSION = "${serverVersion}";
|
|
||||||
const API_BASE_URL = "${determinedBaseUrl || ''}";
|
|
||||||
|
|
||||||
// --- Server Instance ---
|
|
||||||
const server = new Server(
|
|
||||||
{
|
|
||||||
name: SERVER_NAME,
|
|
||||||
version: SERVER_VERSION
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
tools: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Tool Definitions (for ListTools response) ---
|
|
||||||
// Corrected: Use Tool[] type
|
|
||||||
const toolsList: Tool[] = [
|
|
||||||
${listToolsCode}
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Request Handlers ---
|
|
||||||
|
|
||||||
// 1. List Available Tools Handler
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return {
|
|
||||||
tools: toolsList,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Call Tool Handler
|
|
||||||
// Corrected: Added explicit type for 'request' parameter
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
|
|
||||||
const { name: toolName, arguments: toolArgs } = request.params;
|
|
||||||
|
|
||||||
const toolDefinition = toolsList.find(t => t.name === toolName);
|
|
||||||
|
|
||||||
if (!toolDefinition) {
|
|
||||||
console.error(\`Error: Received request for unknown tool: \${toolName}\`);
|
|
||||||
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tool Execution Logic ---
|
|
||||||
${callToolCode} // This generated code now includes Zod validation
|
|
||||||
|
|
||||||
// Fallback error
|
|
||||||
console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`);
|
|
||||||
return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Main Execution Function ---
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during server startup:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Cleanup Function ---
|
|
||||||
async function cleanup() {
|
|
||||||
console.error("Shutting down MCP server...");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register signal handlers
|
|
||||||
process.on('SIGINT', cleanup);
|
|
||||||
process.on('SIGTERM', cleanup);
|
|
||||||
|
|
||||||
// --- Start the Server ---
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("Fatal error in main execution:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Helper Functions (Included in the generated server code) ---
|
|
||||||
function formatApiError(error: AxiosError): string {
|
|
||||||
let message = 'API request failed.';
|
|
||||||
if (error.response) {
|
|
||||||
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
|
|
||||||
const responseData = error.response.data;
|
|
||||||
const MAX_LEN = 200;
|
|
||||||
if (typeof responseData === 'string') {
|
|
||||||
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
|
|
||||||
} else if (responseData) {
|
|
||||||
try {
|
|
||||||
const jsonString = JSON.stringify(responseData);
|
|
||||||
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
|
|
||||||
} catch {
|
|
||||||
message += 'Response: [Could not serialize response data]';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message += 'No response body received.';
|
|
||||||
}
|
|
||||||
} else if (error.request) {
|
|
||||||
message = 'API Network Error: No response received from the server. Check network connectivity or server availability.';
|
|
||||||
if (error.code) message += \` (Code: \${error.code})\`;
|
|
||||||
} else {
|
|
||||||
message = \`API Request Setup Error: \${error.message}\`;
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to dynamically generate and evaluate a Zod schema from a JSON schema.
|
|
||||||
* WARNING: Uses eval(), which can be a security risk if the schema input is untrusted.
|
|
||||||
* In this context, the schema originates from the generator/OpenAPI spec, reducing risk.
|
|
||||||
* @param jsonSchema The JSON Schema object (or boolean).
|
|
||||||
* @param toolName For error logging.
|
|
||||||
* @returns The evaluated Zod schema object.
|
|
||||||
* @throws If schema conversion or evaluation fails.
|
|
||||||
*/
|
|
||||||
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
|
|
||||||
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
|
|
||||||
// Handle boolean schemas or invalid input
|
|
||||||
console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`)
|
|
||||||
// Fallback to allowing any object - adjust if stricter handling is needed
|
|
||||||
return z.object({}).passthrough();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Note: jsonSchemaToZod may require specific configurations or adjustments
|
|
||||||
// depending on the complexity of the JSON Schemas being converted.
|
|
||||||
const zodSchemaString = jsonSchemaToZod(jsonSchema);
|
|
||||||
|
|
||||||
// IMPORTANT: Using eval() to execute the generated Zod schema string.
|
|
||||||
// This is generally discouraged due to security risks with untrusted input.
|
|
||||||
// Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec).
|
|
||||||
// The 'z' variable (from imported zod) must be in scope for eval.
|
|
||||||
const zodSchema = eval(zodSchemaString);
|
|
||||||
|
|
||||||
if (typeof zodSchema?.parse !== 'function') {
|
|
||||||
throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.');
|
|
||||||
}
|
|
||||||
return zodSchema as z.ZodTypeAny;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err);
|
|
||||||
// Fallback schema in case of conversion/evaluation error
|
|
||||||
// This allows any object, effectively skipping validation on error.
|
|
||||||
// Consider throwing the error if validation is critical.
|
|
||||||
return z.object({}).passthrough();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generates the content for the package.json file for a buildable project.
|
|
||||||
* Adds zod and json-schema-to-zod dependencies.
|
|
||||||
*/
|
|
||||||
export function generatePackageJson(serverName, serverVersion) {
|
|
||||||
const packageData = {
|
|
||||||
name: serverName,
|
|
||||||
version: serverVersion,
|
|
||||||
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
|
|
||||||
private: true,
|
|
||||||
type: "module",
|
|
||||||
main: "build/index.js",
|
|
||||||
files: ["build", "src"],
|
|
||||||
scripts: {
|
|
||||||
"start": "node build/index.js",
|
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"prestart": "npm run build"
|
|
||||||
},
|
|
||||||
engines: {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
dependencies: {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
||||||
"axios": "^1.8.4",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"json-schema-to-zod": "^2.6.1"
|
|
||||||
},
|
|
||||||
devDependencies: {
|
|
||||||
"@types/node": "^18.19.0",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
// Removed ts-node, tsc-watch
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return JSON.stringify(packageData, null, 2);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generates the content for the tsconfig.json file for a buildable project.
|
|
||||||
* Enables stricter type checking.
|
|
||||||
*/
|
|
||||||
export function generateTsconfigJson() {
|
|
||||||
const tsconfigData = {
|
|
||||||
compilerOptions: {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "Node16",
|
|
||||||
"moduleResolution": "Node16",
|
|
||||||
"outDir": "./build",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
};
|
|
||||||
return JSON.stringify(tsconfigData, null, 2);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generates the content for the .gitignore file.
|
|
||||||
*/
|
|
||||||
export function generateGitignore() {
|
|
||||||
// Content unchanged from previous version
|
|
||||||
return `
|
|
||||||
# Node dependencies
|
|
||||||
node_modules
|
|
||||||
# Build output
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage directory
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
.grunt
|
|
||||||
bower_components
|
|
||||||
# build/Release # Covered by build/ above
|
|
||||||
jspm_packages/
|
|
||||||
web_modules/
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# VS Code files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# Caches
|
|
||||||
.eslintcache
|
|
||||||
.node_repl_history
|
|
||||||
.browserslistcache
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.*.local
|
|
||||||
.env.local
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
// --- Helper Functions below are mostly unchanged, except generateCallTool ---
|
|
||||||
function determineBaseUrl(api, cmdLineBaseUrl) {
|
|
||||||
// Logic unchanged
|
|
||||||
if (cmdLineBaseUrl)
|
|
||||||
return cmdLineBaseUrl.replace(/\/$/, '');
|
|
||||||
if (api.servers && api.servers.length === 1 && api.servers[0].url)
|
|
||||||
return api.servers[0].url.replace(/\/$/, '');
|
|
||||||
if (api.servers && api.servers.length > 1) {
|
|
||||||
console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
|
|
||||||
return api.servers[0].url.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function extractToolsFromApi(api) {
|
|
||||||
// Logic unchanged
|
|
||||||
const tools = [];
|
|
||||||
const usedNames = new Set();
|
|
||||||
if (!api.paths)
|
|
||||||
return tools;
|
|
||||||
for (const [path, pathItem] of Object.entries(api.paths)) {
|
|
||||||
if (!pathItem)
|
|
||||||
continue;
|
|
||||||
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
|
||||||
const operation = pathItem[method];
|
|
||||||
if (!operation)
|
|
||||||
continue;
|
|
||||||
let baseName = operation.operationId || generateOperationId(method, path);
|
|
||||||
if (!baseName) {
|
|
||||||
console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let finalToolName = baseName;
|
|
||||||
let counter = 1;
|
|
||||||
while (usedNames.has(finalToolName))
|
|
||||||
finalToolName = `${baseName}_${counter++}`;
|
|
||||||
usedNames.add(finalToolName);
|
|
||||||
const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`;
|
|
||||||
const { inputSchema, parameters, requestBody } = generateInputSchema(operation);
|
|
||||||
tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
function generateInputSchema(operation) {
|
|
||||||
// Logic unchanged
|
|
||||||
const properties = {};
|
|
||||||
const required = [];
|
|
||||||
const allParameters = Array.isArray(operation.parameters)
|
|
||||||
? operation.parameters.map(p => p) : [];
|
|
||||||
allParameters.forEach(param => {
|
|
||||||
if (!param.name || !param.schema)
|
|
||||||
return;
|
|
||||||
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema);
|
|
||||||
if (typeof paramSchema === 'object')
|
|
||||||
paramSchema.description = param.description || paramSchema.description;
|
|
||||||
properties[param.name] = paramSchema;
|
|
||||||
if (param.required)
|
|
||||||
required.push(param.name);
|
|
||||||
});
|
|
||||||
let opRequestBody = undefined;
|
|
||||||
if (operation.requestBody) {
|
|
||||||
opRequestBody = operation.requestBody;
|
|
||||||
const jsonContent = opRequestBody.content?.['application/json'];
|
|
||||||
if (jsonContent?.schema) {
|
|
||||||
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema);
|
|
||||||
if (typeof bodySchema === 'object')
|
|
||||||
bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.';
|
|
||||||
properties['requestBody'] = bodySchema;
|
|
||||||
if (opRequestBody.required)
|
|
||||||
required.push('requestBody');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
|
|
||||||
if (firstContent) {
|
|
||||||
const [contentType] = firstContent;
|
|
||||||
properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` };
|
|
||||||
if (opRequestBody.required)
|
|
||||||
required.push('requestBody');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const inputSchema = { type: 'object', properties, ...(required.length > 0 && { required }) };
|
|
||||||
return { inputSchema, parameters: allParameters, requestBody: opRequestBody };
|
|
||||||
}
|
|
||||||
function mapOpenApiSchemaToJsonSchema(schema) {
|
|
||||||
// Logic mostly unchanged, ensure it handles recursion correctly
|
|
||||||
if ('$ref' in schema) {
|
|
||||||
console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`);
|
|
||||||
return { type: 'object', description: `Unresolved: ${schema.$ref}` };
|
|
||||||
}
|
|
||||||
if (typeof schema === 'boolean')
|
|
||||||
return schema;
|
|
||||||
const jsonSchema = { ...schema };
|
|
||||||
if (schema.type === 'integer')
|
|
||||||
jsonSchema.type = 'number';
|
|
||||||
delete jsonSchema.nullable;
|
|
||||||
delete jsonSchema.example;
|
|
||||||
delete jsonSchema.xml;
|
|
||||||
delete jsonSchema.externalDocs;
|
|
||||||
delete jsonSchema.deprecated;
|
|
||||||
delete jsonSchema.readOnly;
|
|
||||||
delete jsonSchema.writeOnly;
|
|
||||||
if (schema.nullable) {
|
|
||||||
if (Array.isArray(jsonSchema.type)) {
|
|
||||||
if (!jsonSchema.type.includes('null'))
|
|
||||||
jsonSchema.type.push('null');
|
|
||||||
}
|
|
||||||
else if (typeof jsonSchema.type === 'string')
|
|
||||||
jsonSchema.type = [jsonSchema.type, 'null'];
|
|
||||||
else if (!jsonSchema.type)
|
|
||||||
jsonSchema.type = 'null';
|
|
||||||
}
|
|
||||||
if (jsonSchema.type === 'object' && jsonSchema.properties) {
|
|
||||||
const mappedProps = {};
|
|
||||||
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
|
|
||||||
if (typeof propSchema === 'object' && propSchema !== null)
|
|
||||||
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema);
|
|
||||||
else if (typeof propSchema === 'boolean')
|
|
||||||
mappedProps[key] = propSchema;
|
|
||||||
}
|
|
||||||
jsonSchema.properties = mappedProps;
|
|
||||||
}
|
|
||||||
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
|
|
||||||
jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items);
|
|
||||||
}
|
|
||||||
return jsonSchema;
|
|
||||||
}
|
|
||||||
function generateListTools(tools) {
|
|
||||||
// Logic unchanged
|
|
||||||
if (tools.length === 0)
|
|
||||||
return " // No tools extracted from the OpenAPI spec.";
|
|
||||||
return tools.map(tool => {
|
|
||||||
const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
|
||||||
let schemaString;
|
|
||||||
try {
|
|
||||||
schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' ');
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }';
|
|
||||||
}
|
|
||||||
return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`;
|
|
||||||
}).join(''); // Changed join to empty string to avoid extra newline
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generates the 'if/else if' block for the CallTool handler.
|
|
||||||
* Includes runtime Zod validation.
|
|
||||||
*/
|
|
||||||
function generateCallTool(tools, baseUrl) {
|
|
||||||
if (tools.length === 0)
|
|
||||||
return ' // No tools defined, so no handlers generated.';
|
|
||||||
const cases = tools.map(tool => {
|
|
||||||
const { name, method, path: rawPath, parameters, requestBody } = tool;
|
|
||||||
const pathParams = parameters.filter(p => p.in === 'path');
|
|
||||||
const queryParams = parameters.filter(p => p.in === 'query');
|
|
||||||
const headerParams = parameters.filter(p => p.in === 'header');
|
|
||||||
// --- Code Generation Snippets ---
|
|
||||||
// Zod validation block (remains the same)
|
|
||||||
const argsValidationCode = `
|
|
||||||
// --- Argument Validation using Zod ---
|
|
||||||
let validatedArgs: JsonObject;
|
|
||||||
try {
|
|
||||||
const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName);
|
|
||||||
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
|
|
||||||
validatedArgs = zodSchema.parse(argsToParse);
|
|
||||||
console.error(\`Arguments validated successfully for tool '\${toolName}'.\`);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
|
|
||||||
console.error(validationErrorMessage);
|
|
||||||
return { content: [{ type: 'text', text: validationErrorMessage }] };
|
|
||||||
} else {
|
|
||||||
console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error);
|
|
||||||
return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- End Argument Validation ---
|
|
||||||
`;
|
|
||||||
// URL Path Construction (uses validatedArgs)
|
|
||||||
let urlPathCode = ` let urlPath = "${rawPath}";\n`;
|
|
||||||
pathParams.forEach(p => {
|
|
||||||
urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash
|
|
||||||
urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`;
|
|
||||||
});
|
|
||||||
urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`;
|
|
||||||
urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`;
|
|
||||||
// Query Parameters Construction (uses validatedArgs)
|
|
||||||
let queryParamsCode = ' const queryParams: Record<string, any> = {};\n';
|
|
||||||
queryParams.forEach(p => {
|
|
||||||
queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
|
|
||||||
queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`;
|
|
||||||
});
|
|
||||||
// Headers Construction (uses validatedArgs)
|
|
||||||
let headersCode = ` const headers: Record<string, string> = { 'Accept': 'application/json' };\n`;
|
|
||||||
headerParams.forEach(p => {
|
|
||||||
headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
|
|
||||||
headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`;
|
|
||||||
});
|
|
||||||
// **Corrected Request Body Handling**
|
|
||||||
let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData
|
|
||||||
let axiosDataProperty = ''; // String part for the Axios config's 'data' property
|
|
||||||
let requestContentType = 'application/json'; // Default assumption
|
|
||||||
if (requestBody) { // Only generate body handling if the tool expects one
|
|
||||||
// Declare the variable *before* config construction
|
|
||||||
requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`;
|
|
||||||
// Assign value *after* validation (which sets validatedArgs)
|
|
||||||
requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`;
|
|
||||||
requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`;
|
|
||||||
requestBodyDeclarationCode += ` }\n`;
|
|
||||||
// Determine Content-Type (must happen before headers are finalized in config)
|
|
||||||
if (requestBody.content?.['application/json']) {
|
|
||||||
requestContentType = 'application/json';
|
|
||||||
}
|
|
||||||
else if (requestBody.content) {
|
|
||||||
const firstType = Object.keys(requestBody.content)[0];
|
|
||||||
if (firstType) {
|
|
||||||
requestContentType = firstType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add Content-Type header *if* data might exist
|
|
||||||
headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`;
|
|
||||||
headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`;
|
|
||||||
// Set the string for the Axios config 'data' property
|
|
||||||
axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data';
|
|
||||||
}
|
|
||||||
// --- Assemble the 'if' block for this tool ---
|
|
||||||
// Ensure correct order: Validation -> Declarations -> Config -> Axios Call
|
|
||||||
return `
|
|
||||||
// Handler for tool: ${name}
|
|
||||||
if (toolName === "${name}") {
|
|
||||||
try {
|
|
||||||
${argsValidationCode}
|
|
||||||
// --- API Call Preparation ---
|
|
||||||
${urlPathCode}
|
|
||||||
${queryParamsCode}
|
|
||||||
${headersCode}
|
|
||||||
${requestBodyDeclarationCode} // Declare and assign requestBodyData *here*
|
|
||||||
|
|
||||||
// --- Axios Request Configuration ---
|
|
||||||
// Now 'requestBodyData' is declared before being referenced here
|
|
||||||
const config: AxiosRequestConfig = {
|
|
||||||
method: "${method.toUpperCase()}",
|
|
||||||
url: requestUrl,
|
|
||||||
params: queryParams,
|
|
||||||
headers: headers,
|
|
||||||
${axiosDataProperty} // Include data property conditionally
|
|
||||||
// Add Authentication logic here if needed
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
|
|
||||||
|
|
||||||
// --- Execute API Call ---
|
|
||||||
const response = await axios(config);
|
|
||||||
|
|
||||||
// --- Process Successful Response ---
|
|
||||||
let responseText = '';
|
|
||||||
const contentType = response.headers['content-type']?.toLowerCase() || '';
|
|
||||||
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
|
|
||||||
try { responseText = JSON.stringify(response.data, null, 2); }
|
|
||||||
catch (e) { responseText = "[Error: Failed to stringify JSON response]"; }
|
|
||||||
} else if (typeof response.data === 'string') {
|
|
||||||
responseText = response.data;
|
|
||||||
} else if (response.data !== undefined && response.data !== null) {
|
|
||||||
responseText = String(response.data);
|
|
||||||
} else {
|
|
||||||
responseText = \`(Status: \${response.status} - No body content)\`;
|
|
||||||
}
|
|
||||||
return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], };
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
// --- Handle Errors (Post-Validation) ---
|
|
||||||
let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`;
|
|
||||||
if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); }
|
|
||||||
else if (error instanceof Error) { errorMessage = error.message; }
|
|
||||||
else { errorMessage = 'An unexpected error occurred: ' + String(error); }
|
|
||||||
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack);
|
|
||||||
return { content: [{ type: "text", text: errorMessage }] };
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
}).join(' else ');
|
|
||||||
return cases || ' // No tools defined, so no handlers generated.';
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { generateMcpServerCode, generatePackageJson, generateTsconfigJson, generateGitignore } from './generator.js';
|
|
||||||
const program = new Command();
|
|
||||||
program
|
|
||||||
.name('openapi-mcp-generator')
|
|
||||||
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
|
|
||||||
// Ensure these option definitions are robust
|
|
||||||
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
|
|
||||||
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
|
|
||||||
.option('-n, --server-name <name>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
|
|
||||||
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
|
|
||||||
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
|
|
||||||
.version('2.0.0'); // Match package.json version
|
|
||||||
// Parse arguments explicitly from process.argv
|
|
||||||
// This is generally the most reliable way
|
|
||||||
program.parse(process.argv);
|
|
||||||
// Retrieve the options AFTER parsing
|
|
||||||
const options = program.opts();
|
|
||||||
async function main() {
|
|
||||||
// Use the parsed options directly
|
|
||||||
const outputDir = options.output;
|
|
||||||
const inputSpec = options.input; // Use the parsed input value
|
|
||||||
const srcDir = path.join(outputDir, 'src');
|
|
||||||
const serverFilePath = path.join(srcDir, 'index.ts');
|
|
||||||
const packageJsonPath = path.join(outputDir, 'package.json');
|
|
||||||
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
|
|
||||||
const gitignorePath = path.join(outputDir, '.gitignore');
|
|
||||||
try {
|
|
||||||
// Use the correct inputSpec variable
|
|
||||||
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
|
|
||||||
const api = await SwaggerParser.dereference(inputSpec);
|
|
||||||
console.error('OpenAPI spec parsed successfully.');
|
|
||||||
// Use options directly for name/version/baseUrl determination
|
|
||||||
const serverNameRaw = options.serverName || api.info.title || 'my-mcp-server';
|
|
||||||
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
||||||
const serverVersion = options.serverVersion || api.info.version || '0.1.0';
|
|
||||||
console.error('Generating server code...');
|
|
||||||
// Pass inputSpec to generator function if needed for comments, otherwise just options
|
|
||||||
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
|
|
||||||
console.error('Generating package.json...');
|
|
||||||
const packageJsonContent = generatePackageJson(serverName, serverVersion);
|
|
||||||
console.error('Generating tsconfig.json...');
|
|
||||||
const tsconfigJsonContent = generateTsconfigJson();
|
|
||||||
console.error('Generating .gitignore...');
|
|
||||||
const gitignoreContent = generateGitignore();
|
|
||||||
console.error(`Creating project directory structure at: ${outputDir}`);
|
|
||||||
await fs.mkdir(srcDir, { recursive: true });
|
|
||||||
await fs.writeFile(serverFilePath, serverTsContent);
|
|
||||||
console.error(` -> Created ${serverFilePath}`);
|
|
||||||
await fs.writeFile(packageJsonPath, packageJsonContent);
|
|
||||||
console.error(` -> Created ${packageJsonPath}`);
|
|
||||||
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
|
|
||||||
console.error(` -> Created ${tsconfigPath}`);
|
|
||||||
await fs.writeFile(gitignorePath, gitignoreContent);
|
|
||||||
console.error(` -> Created ${gitignorePath}`);
|
|
||||||
console.error("\n---");
|
|
||||||
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
|
|
||||||
console.error("\nNext steps:");
|
|
||||||
console.error(`1. Navigate to the directory: cd ${outputDir}`);
|
|
||||||
console.error(`2. Install dependencies: npm install`);
|
|
||||||
console.error(`3. Build the TypeScript code: npm run build`);
|
|
||||||
console.error(`4. Run the server: npm start`);
|
|
||||||
console.error(" (This runs the built JavaScript code in build/index.js)");
|
|
||||||
console.error("---");
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('\nError generating MCP server project:', error);
|
|
||||||
try {
|
|
||||||
await fs.rm(outputDir, { recursive: true, force: true });
|
|
||||||
console.error(`Cleaned up partially created directory: ${outputDir}`);
|
|
||||||
}
|
|
||||||
catch (cleanupError) {
|
|
||||||
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
main();
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
97
package.json
97
package.json
@ -1,50 +1,65 @@
|
|||||||
{
|
{
|
||||||
"name": "openapi-mcp-generator",
|
"name": "openapi-mcp-generator",
|
||||||
"version": "2.0.0",
|
"version": "2.5.0-beta.0",
|
||||||
"description": "Generates MCP server code from OpenAPI specifications",
|
"description": "Generates MCP server code from OpenAPI specifications",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Harsha",
|
"author": "Harsha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"openapi-mcp-generator": "./build/index.js"
|
"openapi-mcp-generator": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"dist",
|
||||||
],
|
"README.md",
|
||||||
"scripts": {
|
"LICENSE"
|
||||||
"start": "node build/index.js",
|
],
|
||||||
"typecheck": "tsc --noEmit",
|
"scripts": {
|
||||||
"build": "tsc && chmod 755 build/index.js"
|
"start": "node dist/index.js",
|
||||||
},
|
"clean": "rimraf dist",
|
||||||
"keywords": [
|
"typecheck": "tsc --noEmit",
|
||||||
"openapi",
|
"build": "tsc && chmod 755 dist/index.js",
|
||||||
"mcp",
|
"prepare": "npm run clean && npm run build",
|
||||||
"model-context-protocol",
|
"prepublishOnly": "npm run lint",
|
||||||
"generator",
|
"lint": "eslint src --ext .ts",
|
||||||
"llm"
|
"format": "prettier --write \"src/**/*.ts\""
|
||||||
],
|
},
|
||||||
"repository": {
|
"keywords": [
|
||||||
"type": "git",
|
"openapi",
|
||||||
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
|
"mcp",
|
||||||
},
|
"model-context-protocol",
|
||||||
"bugs": {
|
"generator",
|
||||||
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
|
"llm",
|
||||||
},
|
"ai",
|
||||||
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
|
"api"
|
||||||
"dependencies": {
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
|
||||||
|
"dependencies": {
|
||||||
"@apidevtools/swagger-parser": "^10.1.1",
|
"@apidevtools/swagger-parser": "^10.1.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
||||||
"axios": "^1.8.4",
|
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"zod": "^3.24.2"
|
"openapi-types": "^12.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||||
|
"@typescript-eslint/parser": "^8.29.1",
|
||||||
|
"eslint": "^9.24.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0"
|
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
"json-schema-to-zod": "^2.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
604
src/generator.ts
604
src/generator.ts
@ -1,604 +0,0 @@
|
|||||||
import { OpenAPIV3 } from 'openapi-types';
|
|
||||||
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
|
|
||||||
import { generateOperationId } from './utils.js';
|
|
||||||
|
|
||||||
interface CliOptions {
|
|
||||||
input: string;
|
|
||||||
output: string; // This is the directory path
|
|
||||||
serverName?: string;
|
|
||||||
serverVersion?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface McpToolDefinition {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: JSONSchema7 | boolean;
|
|
||||||
operationId: string;
|
|
||||||
method: string;
|
|
||||||
path: string;
|
|
||||||
parameters: OpenAPIV3.ParameterObject[];
|
|
||||||
requestBody?: OpenAPIV3.RequestBodyObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the TypeScript code content for the server's src/index.ts file.
|
|
||||||
*/
|
|
||||||
export function generateMcpServerCode(
|
|
||||||
api: OpenAPIV3.Document,
|
|
||||||
options: CliOptions,
|
|
||||||
serverName: string,
|
|
||||||
serverVersion: string
|
|
||||||
): string {
|
|
||||||
|
|
||||||
const tools: McpToolDefinition[] = extractToolsFromApi(api);
|
|
||||||
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
|
|
||||||
const listToolsCode = generateListTools(tools);
|
|
||||||
const callToolCode = generateCallTool(tools, determinedBaseUrl);
|
|
||||||
|
|
||||||
// --- Template for src/index.ts ---
|
|
||||||
return `
|
|
||||||
// Generated by openapi-to-mcp-generator for ${serverName} v${serverVersion}
|
|
||||||
// Source OpenAPI spec: ${options.input}
|
|
||||||
// Generation date: ${new Date().toISOString()}
|
|
||||||
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
|
|
||||||
// Import Schemas and Types from /types subpath with .js extension
|
|
||||||
import {
|
|
||||||
CallToolRequestSchema,
|
|
||||||
ListToolsRequestSchema,
|
|
||||||
type Tool,
|
|
||||||
type CallToolResult,
|
|
||||||
type CallToolRequest // Added type for the request parameter
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
|
|
||||||
// Zod for runtime validation
|
|
||||||
import { z, ZodError } from 'zod';
|
|
||||||
// Library to convert JSON Schema to Zod schema string at runtime
|
|
||||||
import { jsonSchemaToZod } from 'json-schema-to-zod';
|
|
||||||
|
|
||||||
// Define JsonObject locally as a utility type
|
|
||||||
type JsonObject = Record<string, any>;
|
|
||||||
|
|
||||||
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
|
|
||||||
|
|
||||||
// --- Server Configuration ---
|
|
||||||
const SERVER_NAME = "${serverName}";
|
|
||||||
const SERVER_VERSION = "${serverVersion}";
|
|
||||||
const API_BASE_URL = "${determinedBaseUrl || ''}";
|
|
||||||
|
|
||||||
// --- Server Instance ---
|
|
||||||
const server = new Server(
|
|
||||||
{
|
|
||||||
name: SERVER_NAME,
|
|
||||||
version: SERVER_VERSION
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
tools: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Tool Definitions (for ListTools response) ---
|
|
||||||
// Corrected: Use Tool[] type
|
|
||||||
const toolsList: Tool[] = [
|
|
||||||
${listToolsCode}
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Request Handlers ---
|
|
||||||
|
|
||||||
// 1. List Available Tools Handler
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return {
|
|
||||||
tools: toolsList,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Call Tool Handler
|
|
||||||
// Corrected: Added explicit type for 'request' parameter
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
|
|
||||||
const { name: toolName, arguments: toolArgs } = request.params;
|
|
||||||
|
|
||||||
const toolDefinition = toolsList.find(t => t.name === toolName);
|
|
||||||
|
|
||||||
if (!toolDefinition) {
|
|
||||||
console.error(\`Error: Received request for unknown tool: \${toolName}\`);
|
|
||||||
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tool Execution Logic ---
|
|
||||||
${callToolCode} // This generated code now includes Zod validation
|
|
||||||
|
|
||||||
// Fallback error
|
|
||||||
console.error(\`Error: Handler logic missing for tool: \${toolName}. This indicates an issue in the generator.\`);
|
|
||||||
return { content: [{ type: "text", text: \`Error: Internal server error - handler not implemented for tool: \${toolName}\` }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Main Execution Function ---
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during server startup:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Cleanup Function ---
|
|
||||||
async function cleanup() {
|
|
||||||
console.error("Shutting down MCP server...");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register signal handlers
|
|
||||||
process.on('SIGINT', cleanup);
|
|
||||||
process.on('SIGTERM', cleanup);
|
|
||||||
|
|
||||||
// --- Start the Server ---
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("Fatal error in main execution:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Helper Functions (Included in the generated server code) ---
|
|
||||||
function formatApiError(error: AxiosError): string {
|
|
||||||
let message = 'API request failed.';
|
|
||||||
if (error.response) {
|
|
||||||
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
|
|
||||||
const responseData = error.response.data;
|
|
||||||
const MAX_LEN = 200;
|
|
||||||
if (typeof responseData === 'string') {
|
|
||||||
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
|
|
||||||
} else if (responseData) {
|
|
||||||
try {
|
|
||||||
const jsonString = JSON.stringify(responseData);
|
|
||||||
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
|
|
||||||
} catch {
|
|
||||||
message += 'Response: [Could not serialize response data]';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message += 'No response body received.';
|
|
||||||
}
|
|
||||||
} else if (error.request) {
|
|
||||||
message = 'API Network Error: No response received from the server. Check network connectivity or server availability.';
|
|
||||||
if (error.code) message += \` (Code: \${error.code})\`;
|
|
||||||
} else {
|
|
||||||
message = \`API Request Setup Error: \${error.message}\`;
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to dynamically generate and evaluate a Zod schema from a JSON schema.
|
|
||||||
* WARNING: Uses eval(), which can be a security risk if the schema input is untrusted.
|
|
||||||
* In this context, the schema originates from the generator/OpenAPI spec, reducing risk.
|
|
||||||
* @param jsonSchema The JSON Schema object (or boolean).
|
|
||||||
* @param toolName For error logging.
|
|
||||||
* @returns The evaluated Zod schema object.
|
|
||||||
* @throws If schema conversion or evaluation fails.
|
|
||||||
*/
|
|
||||||
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
|
|
||||||
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
|
|
||||||
// Handle boolean schemas or invalid input
|
|
||||||
console.warn(\`Cannot generate Zod schema for non-object JSON schema for tool '\${toolName}'. Input type: \${typeof jsonSchema}\`)
|
|
||||||
// Fallback to allowing any object - adjust if stricter handling is needed
|
|
||||||
return z.object({}).passthrough();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Note: jsonSchemaToZod may require specific configurations or adjustments
|
|
||||||
// depending on the complexity of the JSON Schemas being converted.
|
|
||||||
const zodSchemaString = jsonSchemaToZod(jsonSchema);
|
|
||||||
|
|
||||||
// IMPORTANT: Using eval() to execute the generated Zod schema string.
|
|
||||||
// This is generally discouraged due to security risks with untrusted input.
|
|
||||||
// Ensure the JSON schemas processed here are from trusted sources (like your OpenAPI spec).
|
|
||||||
// The 'z' variable (from imported zod) must be in scope for eval.
|
|
||||||
const zodSchema = eval(zodSchemaString);
|
|
||||||
|
|
||||||
if (typeof zodSchema?.parse !== 'function') {
|
|
||||||
throw new Error('Generated Zod schema string did not evaluate to a valid Zod schema object.');
|
|
||||||
}
|
|
||||||
return zodSchema as z.ZodTypeAny;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(\`Failed to generate or evaluate Zod schema for tool '\${toolName}':\`, err);
|
|
||||||
// Fallback schema in case of conversion/evaluation error
|
|
||||||
// This allows any object, effectively skipping validation on error.
|
|
||||||
// Consider throwing the error if validation is critical.
|
|
||||||
return z.object({}).passthrough();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the content for the package.json file for a buildable project.
|
|
||||||
* Adds zod and json-schema-to-zod dependencies.
|
|
||||||
*/
|
|
||||||
export function generatePackageJson(serverName: string, serverVersion: string): string {
|
|
||||||
const packageData = {
|
|
||||||
name: serverName,
|
|
||||||
version: serverVersion,
|
|
||||||
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
|
|
||||||
private: true,
|
|
||||||
type: "module",
|
|
||||||
main: "build/index.js",
|
|
||||||
files: [ "build", "src" ],
|
|
||||||
scripts: {
|
|
||||||
"start": "node build/index.js",
|
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"prestart": "npm run build"
|
|
||||||
},
|
|
||||||
engines: {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
dependencies: {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
||||||
"axios": "^1.8.4",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"json-schema-to-zod": "^2.6.1"
|
|
||||||
},
|
|
||||||
devDependencies: {
|
|
||||||
"@types/node": "^18.19.0",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
// Removed ts-node, tsc-watch
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return JSON.stringify(packageData, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the content for the tsconfig.json file for a buildable project.
|
|
||||||
* Enables stricter type checking.
|
|
||||||
*/
|
|
||||||
export function generateTsconfigJson(): string {
|
|
||||||
const tsconfigData = {
|
|
||||||
compilerOptions: {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "Node16",
|
|
||||||
"moduleResolution": "Node16",
|
|
||||||
"outDir": "./build",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
};
|
|
||||||
return JSON.stringify(tsconfigData, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the content for the .gitignore file.
|
|
||||||
*/
|
|
||||||
export function generateGitignore(): string {
|
|
||||||
// Content unchanged from previous version
|
|
||||||
return `
|
|
||||||
# Node dependencies
|
|
||||||
node_modules
|
|
||||||
# Build output
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage directory
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
.grunt
|
|
||||||
bower_components
|
|
||||||
# build/Release # Covered by build/ above
|
|
||||||
jspm_packages/
|
|
||||||
web_modules/
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# VS Code files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# Caches
|
|
||||||
.eslintcache
|
|
||||||
.node_repl_history
|
|
||||||
.browserslistcache
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.*.local
|
|
||||||
.env.local
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Helper Functions below are mostly unchanged, except generateCallTool ---
|
|
||||||
|
|
||||||
function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null {
|
|
||||||
// Logic unchanged
|
|
||||||
if (cmdLineBaseUrl) return cmdLineBaseUrl.replace(/\/$/, '');
|
|
||||||
if (api.servers && api.servers.length === 1 && api.servers[0].url) return api.servers[0].url.replace(/\/$/, '');
|
|
||||||
if (api.servers && api.servers.length > 1) {
|
|
||||||
console.warn(`⚠️ Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
|
|
||||||
return api.servers[0].url.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
|
|
||||||
// Logic unchanged
|
|
||||||
const tools: McpToolDefinition[] = [];
|
|
||||||
const usedNames = new Set<string>();
|
|
||||||
if (!api.paths) return tools;
|
|
||||||
for (const [path, pathItem] of Object.entries(api.paths)) {
|
|
||||||
if (!pathItem) continue;
|
|
||||||
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
|
||||||
const operation = pathItem[method];
|
|
||||||
if (!operation) continue;
|
|
||||||
let baseName = operation.operationId || generateOperationId(method, path);
|
|
||||||
if (!baseName) {
|
|
||||||
console.warn(`⚠️ Skipping ${method.toUpperCase()} ${path}: missing operationId.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let finalToolName = baseName;
|
|
||||||
let counter = 1;
|
|
||||||
while (usedNames.has(finalToolName)) finalToolName = `${baseName}_${counter++}`;
|
|
||||||
usedNames.add(finalToolName);
|
|
||||||
const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`;
|
|
||||||
const { inputSchema, parameters, requestBody } = generateInputSchema(operation);
|
|
||||||
tools.push({ name: finalToolName, description, inputSchema, operationId: baseName, method, path, parameters, requestBody });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateInputSchema(operation: OpenAPIV3.OperationObject): { inputSchema: JSONSchema7 | boolean, parameters: OpenAPIV3.ParameterObject[], requestBody?: OpenAPIV3.RequestBodyObject } {
|
|
||||||
// Logic unchanged
|
|
||||||
const properties: { [key: string]: JSONSchema7 | boolean } = {};
|
|
||||||
const required: string[] = [];
|
|
||||||
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
|
|
||||||
? operation.parameters.map(p => p as OpenAPIV3.ParameterObject) : [];
|
|
||||||
allParameters.forEach(param => {
|
|
||||||
if (!param.name || !param.schema) return;
|
|
||||||
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
|
|
||||||
if (typeof paramSchema === 'object') paramSchema.description = param.description || paramSchema.description;
|
|
||||||
properties[param.name] = paramSchema;
|
|
||||||
if (param.required) required.push(param.name);
|
|
||||||
});
|
|
||||||
let opRequestBody: OpenAPIV3.RequestBodyObject | undefined = undefined;
|
|
||||||
if (operation.requestBody) {
|
|
||||||
opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
|
||||||
const jsonContent = opRequestBody.content?.['application/json'];
|
|
||||||
if (jsonContent?.schema) {
|
|
||||||
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
|
|
||||||
if (typeof bodySchema === 'object') bodySchema.description = opRequestBody.description || bodySchema.description || 'The JSON request body.';
|
|
||||||
properties['requestBody'] = bodySchema;
|
|
||||||
if (opRequestBody.required) required.push('requestBody');
|
|
||||||
} else {
|
|
||||||
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
|
|
||||||
if(firstContent) {
|
|
||||||
const [contentType] = firstContent;
|
|
||||||
properties['requestBody'] = { type: 'string', description: opRequestBody.description || `Request body (content type: ${contentType})` };
|
|
||||||
if (opRequestBody.required) required.push('requestBody');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const inputSchema: JSONSchema7 = { type: 'object', properties, ...(required.length > 0 && { required }) };
|
|
||||||
return { inputSchema, parameters: allParameters, requestBody: opRequestBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean {
|
|
||||||
// Logic mostly unchanged, ensure it handles recursion correctly
|
|
||||||
if ('$ref' in schema) {
|
|
||||||
console.warn(`⚠️ Unresolved $ref '${schema.$ref}'. Schema may be incomplete.`);
|
|
||||||
return { type: 'object', description: `Unresolved: ${schema.$ref}` };
|
|
||||||
}
|
|
||||||
if (typeof schema === 'boolean') return schema;
|
|
||||||
const jsonSchema: JSONSchema7 = { ...schema } as any;
|
|
||||||
if (schema.type === 'integer') jsonSchema.type = 'number';
|
|
||||||
delete (jsonSchema as any).nullable; delete (jsonSchema as any).example; delete (jsonSchema as any).xml;
|
|
||||||
delete (jsonSchema as any).externalDocs; delete (jsonSchema as any).deprecated; delete (jsonSchema as any).readOnly; delete (jsonSchema as any).writeOnly;
|
|
||||||
if (schema.nullable) {
|
|
||||||
if (Array.isArray(jsonSchema.type)) { if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null'); }
|
|
||||||
else if (typeof jsonSchema.type === 'string') jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
|
|
||||||
else if (!jsonSchema.type) jsonSchema.type = 'null';
|
|
||||||
}
|
|
||||||
if (jsonSchema.type === 'object' && jsonSchema.properties) {
|
|
||||||
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
|
|
||||||
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
|
|
||||||
if (typeof propSchema === 'object' && propSchema !== null) mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
|
|
||||||
else if (typeof propSchema === 'boolean') mappedProps[key] = propSchema;
|
|
||||||
}
|
|
||||||
jsonSchema.properties = mappedProps;
|
|
||||||
}
|
|
||||||
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
|
|
||||||
jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject);
|
|
||||||
}
|
|
||||||
return jsonSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateListTools(tools: McpToolDefinition[]): string {
|
|
||||||
// Logic unchanged
|
|
||||||
if (tools.length === 0) return " // No tools extracted from the OpenAPI spec.";
|
|
||||||
return tools.map(tool => {
|
|
||||||
const escapedDescription = (tool.description || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
|
||||||
let schemaString; try { schemaString = JSON.stringify(tool.inputSchema, null, 4).replace(/^/gm, ' '); }
|
|
||||||
catch (e) { schemaString = ' { "type": "object", "description": "Error: Could not stringify schema" }'; }
|
|
||||||
return `\n // Tool: ${tool.name} (${tool.method.toUpperCase()} ${tool.path})\n {\n name: "${tool.name}",\n description: \`${escapedDescription}\`,\n inputSchema: ${schemaString}\n },`;
|
|
||||||
}).join(''); // Changed join to empty string to avoid extra newline
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the 'if/else if' block for the CallTool handler.
|
|
||||||
* Includes runtime Zod validation.
|
|
||||||
*/
|
|
||||||
function generateCallTool(tools: McpToolDefinition[], baseUrl: string | null): string {
|
|
||||||
if (tools.length === 0) return ' // No tools defined, so no handlers generated.';
|
|
||||||
|
|
||||||
const cases = tools.map(tool => {
|
|
||||||
const { name, method, path: rawPath, parameters, requestBody } = tool;
|
|
||||||
const pathParams = parameters.filter(p => p.in === 'path');
|
|
||||||
const queryParams = parameters.filter(p => p.in === 'query');
|
|
||||||
const headerParams = parameters.filter(p => p.in === 'header');
|
|
||||||
|
|
||||||
// --- Code Generation Snippets ---
|
|
||||||
// Zod validation block (remains the same)
|
|
||||||
const argsValidationCode = `
|
|
||||||
// --- Argument Validation using Zod ---
|
|
||||||
let validatedArgs: JsonObject;
|
|
||||||
try {
|
|
||||||
const zodSchema = getZodSchemaFromJsonSchema(toolDefinition.inputSchema, toolName);
|
|
||||||
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
|
|
||||||
validatedArgs = zodSchema.parse(argsToParse);
|
|
||||||
console.error(\`Arguments validated successfully for tool '\${toolName}'.\`);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
|
|
||||||
console.error(validationErrorMessage);
|
|
||||||
return { content: [{ type: 'text', text: validationErrorMessage }] };
|
|
||||||
} else {
|
|
||||||
console.error(\`Unexpected error during argument validation setup for tool '\${toolName}':\`, error);
|
|
||||||
return { content: [{ type: 'text', text: \`Internal server error during argument validation setup for tool '\${toolName}'.\` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- End Argument Validation ---
|
|
||||||
`;
|
|
||||||
|
|
||||||
// URL Path Construction (uses validatedArgs)
|
|
||||||
let urlPathCode = ` let urlPath = "${rawPath}";\n`;
|
|
||||||
pathParams.forEach(p => {
|
|
||||||
urlPathCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name to avoid clash
|
|
||||||
urlPathCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) { urlPath = urlPath.replace("{${p.name}}", encodeURIComponent(String(${p.name}_val))); }\n`;
|
|
||||||
});
|
|
||||||
urlPathCode += ` if (urlPath.includes('{')) { throw new Error(\`Validation passed but failed to resolve path parameters in URL: \${urlPath}. Check schema/validation logic.\`); }\n`;
|
|
||||||
urlPathCode += ` const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;`;
|
|
||||||
|
|
||||||
// Query Parameters Construction (uses validatedArgs)
|
|
||||||
let queryParamsCode = ' const queryParams: Record<string, any> = {};\n';
|
|
||||||
queryParams.forEach(p => {
|
|
||||||
queryParamsCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
|
|
||||||
queryParamsCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) queryParams['${p.name}'] = ${p.name}_val;\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Headers Construction (uses validatedArgs)
|
|
||||||
let headersCode = ` const headers: Record<string, string> = { 'Accept': 'application/json' };\n`;
|
|
||||||
headerParams.forEach(p => {
|
|
||||||
headersCode += ` const ${p.name}_val = validatedArgs['${p.name}'];\n`; // Use distinct name
|
|
||||||
headersCode += ` if (typeof ${p.name}_val !== 'undefined' && ${p.name}_val !== null) headers['${p.name.toLowerCase()}'] = String(${p.name}_val);\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// **Corrected Request Body Handling**
|
|
||||||
let requestBodyDeclarationCode = ''; // Code to declare and assign requestBodyData
|
|
||||||
let axiosDataProperty = ''; // String part for the Axios config's 'data' property
|
|
||||||
let requestContentType = 'application/json'; // Default assumption
|
|
||||||
|
|
||||||
if (requestBody) { // Only generate body handling if the tool expects one
|
|
||||||
// Declare the variable *before* config construction
|
|
||||||
requestBodyDeclarationCode = ` let requestBodyData: any = undefined;\n`;
|
|
||||||
// Assign value *after* validation (which sets validatedArgs)
|
|
||||||
requestBodyDeclarationCode += ` if (validatedArgs && typeof validatedArgs['requestBody'] !== 'undefined') {\n`;
|
|
||||||
requestBodyDeclarationCode += ` requestBodyData = validatedArgs['requestBody'];\n`;
|
|
||||||
requestBodyDeclarationCode += ` }\n`;
|
|
||||||
|
|
||||||
// Determine Content-Type (must happen before headers are finalized in config)
|
|
||||||
if (requestBody.content?.['application/json']) {
|
|
||||||
requestContentType = 'application/json';
|
|
||||||
} else if (requestBody.content) {
|
|
||||||
const firstType = Object.keys(requestBody.content)[0];
|
|
||||||
if (firstType) { requestContentType = firstType; }
|
|
||||||
}
|
|
||||||
// Add Content-Type header *if* data might exist
|
|
||||||
headersCode += ` // Set Content-Type based on OpenAPI spec (or fallback)\n`;
|
|
||||||
headersCode += ` if (typeof validatedArgs?.['requestBody'] !== 'undefined') { headers['content-type'] = '${requestContentType}'; }\n`;
|
|
||||||
|
|
||||||
// Set the string for the Axios config 'data' property
|
|
||||||
axiosDataProperty = 'data: requestBodyData, // Pass the prepared request body data';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Assemble the 'if' block for this tool ---
|
|
||||||
// Ensure correct order: Validation -> Declarations -> Config -> Axios Call
|
|
||||||
return `
|
|
||||||
// Handler for tool: ${name}
|
|
||||||
if (toolName === "${name}") {
|
|
||||||
try {
|
|
||||||
${argsValidationCode}
|
|
||||||
// --- API Call Preparation ---
|
|
||||||
${urlPathCode}
|
|
||||||
${queryParamsCode}
|
|
||||||
${headersCode}
|
|
||||||
${requestBodyDeclarationCode} // Declare and assign requestBodyData *here*
|
|
||||||
|
|
||||||
// --- Axios Request Configuration ---
|
|
||||||
// Now 'requestBodyData' is declared before being referenced here
|
|
||||||
const config: AxiosRequestConfig = {
|
|
||||||
method: "${method.toUpperCase()}",
|
|
||||||
url: requestUrl,
|
|
||||||
params: queryParams,
|
|
||||||
headers: headers,
|
|
||||||
${axiosDataProperty} // Include data property conditionally
|
|
||||||
// Add Authentication logic here if needed
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
|
|
||||||
|
|
||||||
// --- Execute API Call ---
|
|
||||||
const response = await axios(config);
|
|
||||||
|
|
||||||
// --- Process Successful Response ---
|
|
||||||
let responseText = '';
|
|
||||||
const contentType = response.headers['content-type']?.toLowerCase() || '';
|
|
||||||
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
|
|
||||||
try { responseText = JSON.stringify(response.data, null, 2); }
|
|
||||||
catch (e) { responseText = "[Error: Failed to stringify JSON response]"; }
|
|
||||||
} else if (typeof response.data === 'string') {
|
|
||||||
responseText = response.data;
|
|
||||||
} else if (response.data !== undefined && response.data !== null) {
|
|
||||||
responseText = String(response.data);
|
|
||||||
} else {
|
|
||||||
responseText = \`(Status: \${response.status} - No body content)\`;
|
|
||||||
}
|
|
||||||
return { content: [ { type: "text", text: \`API Response (Status: \${response.status}):\\n\${responseText}\` } ], };
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
// --- Handle Errors (Post-Validation) ---
|
|
||||||
let errorMessage = \`Error executing tool '\${toolName}': \${error.message}\`;
|
|
||||||
if (axios.isAxiosError(error)) { errorMessage = formatApiError(error); }
|
|
||||||
else if (error instanceof Error) { errorMessage = error.message; }
|
|
||||||
else { errorMessage = 'An unexpected error occurred: ' + String(error); }
|
|
||||||
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage, error.stack);
|
|
||||||
return { content: [{ type: "text", text: errorMessage }] };
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
}).join(' else ');
|
|
||||||
|
|
||||||
return cases || ' // No tools defined, so no handlers generated.';
|
|
||||||
}
|
|
||||||
178
src/generator/config-files.ts
Normal file
178
src/generator/config-files.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Generator for configuration files for MCP servers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of tsconfig.json for the MCP server
|
||||||
|
*
|
||||||
|
* @returns JSON string for tsconfig.json
|
||||||
|
*/
|
||||||
|
export function generateTsconfigJson(): string {
|
||||||
|
const tsconfigData = {
|
||||||
|
compilerOptions: {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./build",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "build", "**/*.test.ts"]
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(tsconfigData, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of .gitignore for the MCP server
|
||||||
|
*
|
||||||
|
* @returns Content for .gitignore
|
||||||
|
*/
|
||||||
|
export function generateGitignore(): string {
|
||||||
|
return `# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Reports
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
.grunt
|
||||||
|
bower_components
|
||||||
|
jspm_packages/
|
||||||
|
web_modules/
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Editor settings
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
.idea
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
.node_repl_history
|
||||||
|
.browserslistcache
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of .eslintrc.json for the MCP server
|
||||||
|
*
|
||||||
|
* @returns JSON string for .eslintrc.json
|
||||||
|
*/
|
||||||
|
export function generateEslintConfig(): string {
|
||||||
|
const eslintConfig = {
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": ["error", { "allow": ["error", "warn"] }],
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(eslintConfig, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of jest.config.js for the MCP server
|
||||||
|
*
|
||||||
|
* @returns Content for jest.config.js
|
||||||
|
*/
|
||||||
|
export function generateJestConfig(): string {
|
||||||
|
return `export default {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of .prettierrc for the MCP server
|
||||||
|
*
|
||||||
|
* @returns JSON string for .prettierrc
|
||||||
|
*/
|
||||||
|
export function generatePrettierConfig(): string {
|
||||||
|
const prettierConfig = {
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(prettierConfig, null, 2);
|
||||||
|
}
|
||||||
96
src/generator/env-file.ts
Normal file
96
src/generator/env-file.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Generator for .env file and .env.example file
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import { getEnvVarName } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the content of .env.example file for the MCP server
|
||||||
|
*
|
||||||
|
* @param securitySchemes Security schemes from the OpenAPI spec
|
||||||
|
* @returns Content for .env.example file
|
||||||
|
*/
|
||||||
|
export function generateEnvExample(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
|
||||||
|
let content = `# MCP Server Environment Variables
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add security scheme environment variables with examples
|
||||||
|
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
|
||||||
|
content += `# API Authentication\n`;
|
||||||
|
|
||||||
|
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||||
|
if ('$ref' in schemeOrRef) {
|
||||||
|
content += `# ${name} - Referenced security scheme (reference not resolved)\n`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = schemeOrRef;
|
||||||
|
|
||||||
|
if (scheme.type === 'apiKey') {
|
||||||
|
const varName = getEnvVarName(name, 'API_KEY');
|
||||||
|
content += `${varName}=your_api_key_here\n`;
|
||||||
|
}
|
||||||
|
else if (scheme.type === 'http') {
|
||||||
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||||
|
const varName = getEnvVarName(name, 'BEARER_TOKEN');
|
||||||
|
content += `${varName}=your_bearer_token_here\n`;
|
||||||
|
}
|
||||||
|
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||||
|
const usernameVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||||
|
const passwordVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||||
|
content += `${usernameVar}=your_username_here\n`;
|
||||||
|
content += `${passwordVar}=your_password_here\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scheme.type === 'oauth2') {
|
||||||
|
content += `# OAuth2 authentication (${scheme.flows ? Object.keys(scheme.flows).join(', ') : 'unknown'} flow)\n`;
|
||||||
|
const varName = `OAUTH_TOKEN_${name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`;
|
||||||
|
content += `${varName}=your_oauth_token_here\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content += `# No API authentication required\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `\n# Add any other environment variables your API might need\n`;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates dotenv configuration code for the MCP server
|
||||||
|
*
|
||||||
|
* @returns Code for loading environment variables
|
||||||
|
*/
|
||||||
|
export function generateDotenvConfig(): string {
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Load environment variables from .env file
|
||||||
|
*/
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
const result = dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.warn('Warning: No .env file found or error loading .env file.');
|
||||||
|
console.warn('Using default environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: process.env.PORT || '3000',
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
9
src/generator/index.ts
Normal file
9
src/generator/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generator module exports
|
||||||
|
*/
|
||||||
|
export * from './server-code.js';
|
||||||
|
export * from './package-json.js';
|
||||||
|
export * from './config-files.js';
|
||||||
|
export * from './env-file.js';
|
||||||
|
export * from './oauth-docs.js';
|
||||||
|
export * from './web-server.js';
|
||||||
109
src/generator/oauth-docs.ts
Normal file
109
src/generator/oauth-docs.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Generator for OAuth2 documentation
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates documentation about OAuth2 configuration
|
||||||
|
*
|
||||||
|
* @param securitySchemes Security schemes from OpenAPI spec
|
||||||
|
* @returns Markdown documentation about OAuth2 configuration
|
||||||
|
*/
|
||||||
|
export function generateOAuth2Docs(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
|
||||||
|
if (!securitySchemes) {
|
||||||
|
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauth2Schemes: {name: string, scheme: OpenAPIV3.OAuth2SecurityScheme}[] = [];
|
||||||
|
|
||||||
|
// Find OAuth2 schemes
|
||||||
|
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||||
|
if ('$ref' in schemeOrRef) continue;
|
||||||
|
|
||||||
|
if (schemeOrRef.type === 'oauth2') {
|
||||||
|
oauth2Schemes.push({
|
||||||
|
name,
|
||||||
|
scheme: schemeOrRef
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauth2Schemes.length === 0) {
|
||||||
|
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = `# OAuth2 Configuration
|
||||||
|
|
||||||
|
This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authentication in the following ways:
|
||||||
|
|
||||||
|
1. **Using a pre-acquired token**: You provide a token you've already obtained
|
||||||
|
2. **Using client credentials flow**: The server automatically acquires a token using your client ID and secret
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Document each OAuth2 scheme
|
||||||
|
for (const {name, scheme} of oauth2Schemes) {
|
||||||
|
content += `### ${name}\n\n`;
|
||||||
|
|
||||||
|
if (scheme.description) {
|
||||||
|
content += `${scheme.description}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "**Configuration Variables:**\n\n";
|
||||||
|
|
||||||
|
const envVarPrefix = name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||||
|
|
||||||
|
content += `- \`OAUTH_CLIENT_ID_${envVarPrefix}\`: Your OAuth client ID\n`;
|
||||||
|
content += `- \`OAUTH_CLIENT_SECRET_${envVarPrefix}\`: Your OAuth client secret\n`;
|
||||||
|
|
||||||
|
if (scheme.flows?.clientCredentials) {
|
||||||
|
content += `- \`OAUTH_SCOPES_${envVarPrefix}\`: Space-separated list of scopes to request (optional)\n`;
|
||||||
|
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (optional if using client credentials)\n\n`;
|
||||||
|
|
||||||
|
content += "**Client Credentials Flow:**\n\n";
|
||||||
|
content += `- Token URL: \`${scheme.flows.clientCredentials.tokenUrl}\`\n`;
|
||||||
|
|
||||||
|
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
|
||||||
|
content += "\n**Available Scopes:**\n\n";
|
||||||
|
|
||||||
|
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
|
||||||
|
content += `- \`${scope}\`: ${description}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.flows?.authorizationCode) {
|
||||||
|
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (required for authorization code flow)\n\n`;
|
||||||
|
|
||||||
|
content += "**Authorization Code Flow:**\n\n";
|
||||||
|
content += `- Authorization URL: \`${scheme.flows.authorizationCode.authorizationUrl}\`\n`;
|
||||||
|
content += `- Token URL: \`${scheme.flows.authorizationCode.tokenUrl}\`\n`;
|
||||||
|
|
||||||
|
if (scheme.flows.authorizationCode.scopes && Object.keys(scheme.flows.authorizationCode.scopes).length > 0) {
|
||||||
|
content += "\n**Available Scopes:**\n\n";
|
||||||
|
|
||||||
|
for (const [scope, description] of Object.entries(scheme.flows.authorizationCode.scopes)) {
|
||||||
|
content += `- \`${scope}\`: ${description}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `## Token Caching
|
||||||
|
|
||||||
|
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the \`expires_in\` parameter in the token response) minus 60 seconds as a safety margin.
|
||||||
|
|
||||||
|
When making API requests, the server will:
|
||||||
|
1. Check for a cached token that's still valid
|
||||||
|
2. Use the cached token if available
|
||||||
|
3. Request a new token if no valid cached token exists
|
||||||
|
`;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
65
src/generator/package-json.ts
Normal file
65
src/generator/package-json.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Generates the content of package.json for the MCP server
|
||||||
|
*
|
||||||
|
* @param serverName Server name
|
||||||
|
* @param serverVersion Server version
|
||||||
|
* @param includeWebDeps Whether to include web server dependencies
|
||||||
|
* @returns JSON string for package.json
|
||||||
|
*/
|
||||||
|
export function generatePackageJson(
|
||||||
|
serverName: string,
|
||||||
|
serverVersion: string,
|
||||||
|
includeWebDeps: boolean = false
|
||||||
|
): string {
|
||||||
|
const packageData: any = {
|
||||||
|
name: serverName,
|
||||||
|
version: serverVersion,
|
||||||
|
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
main: "build/index.js",
|
||||||
|
files: [ "build", "src" ],
|
||||||
|
scripts: {
|
||||||
|
"start": "node build/index.js",
|
||||||
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prestart": "npm run build"
|
||||||
|
},
|
||||||
|
engines: {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
"json-schema-to-zod": "^2.4.1"
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
"@types/node": "^18.19.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add web server dependencies if needed
|
||||||
|
if (includeWebDeps) {
|
||||||
|
packageData.dependencies = {
|
||||||
|
...packageData.dependencies,
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
packageData.devDependencies = {
|
||||||
|
...packageData.devDependencies,
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/uuid": "^10.0.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a script to start in web mode
|
||||||
|
packageData.scripts["start:web"] = "node build/index.js --transport=web";
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(packageData, null, 2);
|
||||||
|
}
|
||||||
227
src/generator/server-code.ts
Normal file
227
src/generator/server-code.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import { CliOptions } from '../types/index.js';
|
||||||
|
import { extractToolsFromApi } from '../parser/extract-tools.js';
|
||||||
|
import { determineBaseUrl } from '../utils/index.js';
|
||||||
|
import {
|
||||||
|
generateToolDefinitionMap,
|
||||||
|
generateCallToolHandler,
|
||||||
|
generateListToolsHandler
|
||||||
|
} from '../utils/code-gen.js';
|
||||||
|
import { generateExecuteApiToolFunction } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the TypeScript code for the MCP server
|
||||||
|
*
|
||||||
|
* @param api OpenAPI document
|
||||||
|
* @param options CLI options
|
||||||
|
* @param serverName Server name
|
||||||
|
* @param serverVersion Server version
|
||||||
|
* @returns Generated TypeScript code
|
||||||
|
*/
|
||||||
|
export function generateMcpServerCode(
|
||||||
|
api: OpenAPIV3.Document,
|
||||||
|
options: CliOptions,
|
||||||
|
serverName: string,
|
||||||
|
serverVersion: string
|
||||||
|
): string {
|
||||||
|
// Extract tools from API
|
||||||
|
const tools = extractToolsFromApi(api);
|
||||||
|
|
||||||
|
// Determine base URL
|
||||||
|
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
|
||||||
|
|
||||||
|
// Generate code for tool definition map
|
||||||
|
const toolDefinitionMapCode = generateToolDefinitionMap(tools, api.components?.securitySchemes);
|
||||||
|
|
||||||
|
// Generate code for API tool execution
|
||||||
|
const executeApiToolFunctionCode = generateExecuteApiToolFunction(api.components?.securitySchemes);
|
||||||
|
|
||||||
|
// Generate code for request handlers
|
||||||
|
const callToolHandlerCode = generateCallToolHandler();
|
||||||
|
const listToolsHandlerCode = generateListToolsHandler();
|
||||||
|
|
||||||
|
// Determine if we should include web server code
|
||||||
|
const includeWebServer = options.transport === 'web';
|
||||||
|
const webServerImport = includeWebServer
|
||||||
|
? `\nimport { setupWebServer } from "./web-server.js";`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Define transport based on options
|
||||||
|
const transportCode = includeWebServer
|
||||||
|
? `// Set up Web Server transport
|
||||||
|
try {
|
||||||
|
await setupWebServer(server, ${options.port || 3000});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting up web server:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}`
|
||||||
|
: `// Set up stdio transport
|
||||||
|
try {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error(\`\${SERVER_NAME} MCP Server (v\${SERVER_VERSION}) running on stdio\${API_BASE_URL ? \`, proxying API at \${API_BASE_URL}\` : ''}\`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during server startup:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Generate the full server code
|
||||||
|
return `#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* MCP Server generated from OpenAPI spec for ${serverName} v${serverVersion}
|
||||||
|
* Generated on: ${new Date().toISOString()}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
type Tool,
|
||||||
|
type CallToolResult,
|
||||||
|
type CallToolRequest
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";${webServerImport}
|
||||||
|
|
||||||
|
import { z, ZodError } from 'zod';
|
||||||
|
import { jsonSchemaToZod } from 'json-schema-to-zod';
|
||||||
|
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type definition for JSON objects
|
||||||
|
*/
|
||||||
|
type JsonObject = Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for MCP Tool Definition
|
||||||
|
*/
|
||||||
|
interface McpToolDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
method: string;
|
||||||
|
pathTemplate: string;
|
||||||
|
executionParameters: { name: string, in: string }[];
|
||||||
|
requestBodyContentType?: string;
|
||||||
|
securityRequirements: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server configuration
|
||||||
|
*/
|
||||||
|
export const SERVER_NAME = "${serverName}";
|
||||||
|
export const SERVER_VERSION = "${serverVersion}";
|
||||||
|
export const API_BASE_URL = "${determinedBaseUrl || ''}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server instance
|
||||||
|
*/
|
||||||
|
const server = new Server(
|
||||||
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of tool definitions by name
|
||||||
|
*/
|
||||||
|
const toolDefinitionMap: Map<string, McpToolDefinition> = new Map([
|
||||||
|
${toolDefinitionMapCode}
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security schemes from the OpenAPI spec
|
||||||
|
*/
|
||||||
|
const securitySchemes = ${JSON.stringify(api.components?.securitySchemes || {}, null, 2).replace(/^/gm, ' ')};
|
||||||
|
|
||||||
|
${listToolsHandlerCode}
|
||||||
|
${callToolHandlerCode}
|
||||||
|
${executeApiToolFunctionCode}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to start the server
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
${transportCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup function for graceful shutdown
|
||||||
|
*/
|
||||||
|
async function cleanup() {
|
||||||
|
console.error("Shutting down MCP server...");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register signal handlers
|
||||||
|
process.on('SIGINT', cleanup);
|
||||||
|
process.on('SIGTERM', cleanup);
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Fatal error in main execution:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats API errors for better readability
|
||||||
|
*
|
||||||
|
* @param error Axios error
|
||||||
|
* @returns Formatted error message
|
||||||
|
*/
|
||||||
|
function formatApiError(error: AxiosError): string {
|
||||||
|
let message = 'API request failed.';
|
||||||
|
if (error.response) {
|
||||||
|
message = \`API Error: Status \${error.response.status} (\${error.response.statusText || 'Status text not available'}). \`;
|
||||||
|
const responseData = error.response.data;
|
||||||
|
const MAX_LEN = 200;
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
message += \`Response: \${responseData.substring(0, MAX_LEN)}\${responseData.length > MAX_LEN ? '...' : ''}\`;
|
||||||
|
}
|
||||||
|
else if (responseData) {
|
||||||
|
try {
|
||||||
|
const jsonString = JSON.stringify(responseData);
|
||||||
|
message += \`Response: \${jsonString.substring(0, MAX_LEN)}\${jsonString.length > MAX_LEN ? '...' : ''}\`;
|
||||||
|
} catch {
|
||||||
|
message += 'Response: [Could not serialize data]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
message += 'No response body received.';
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message = 'API Network Error: No response received from server.';
|
||||||
|
if (error.code) message += \` (Code: \${error.code})\`;
|
||||||
|
} else {
|
||||||
|
message += \`API Request Setup Error: \${error.message}\`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JSON Schema to a Zod schema for runtime validation
|
||||||
|
*
|
||||||
|
* @param jsonSchema JSON Schema
|
||||||
|
* @param toolName Tool name for error reporting
|
||||||
|
* @returns Zod schema
|
||||||
|
*/
|
||||||
|
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
|
||||||
|
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
|
||||||
|
return z.object({}).passthrough();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const zodSchemaString = jsonSchemaToZod(jsonSchema);
|
||||||
|
const zodSchema = eval(zodSchemaString);
|
||||||
|
if (typeof zodSchema?.parse !== 'function') {
|
||||||
|
throw new Error('Eval did not produce a valid Zod schema.');
|
||||||
|
}
|
||||||
|
return zodSchema as z.ZodTypeAny;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(\`Failed to generate/evaluate Zod schema for '\${toolName}':\`, err);
|
||||||
|
return z.object({}).passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
507
src/generator/web-server.ts
Normal file
507
src/generator/web-server.ts
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
/**
|
||||||
|
* Generator for web server code for the MCP server
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates web server code for the MCP server (using Express and SSE)
|
||||||
|
*
|
||||||
|
* @param port Server port (default: 3000)
|
||||||
|
* @returns Generated code for the web server
|
||||||
|
*/
|
||||||
|
export function generateWebServerCode(port: number = 3000): string {
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Web server setup for HTTP-based MCP communication
|
||||||
|
*/
|
||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
|
||||||
|
// Import server configuration constants
|
||||||
|
import { SERVER_NAME, SERVER_VERSION } from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a web server for the MCP server using Server-Sent Events (SSE)
|
||||||
|
*
|
||||||
|
* @param server The MCP Server instance
|
||||||
|
* @param port The port to listen on (default: ${port})
|
||||||
|
* @returns The Express app instance
|
||||||
|
*/
|
||||||
|
export async function setupWebServer(server: Server, port = ${port}) {
|
||||||
|
// Create Express app
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Parse JSON requests
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Add a simple health check endpoint
|
||||||
|
app.get('/health', (_, res) => {
|
||||||
|
res.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store active SSE transports by session ID
|
||||||
|
const transports: {[sessionId: string]: SSEServerTransport} = {};
|
||||||
|
|
||||||
|
// SSE endpoint for clients to connect to
|
||||||
|
app.get("/sse", async (req: Request, res: Response) => {
|
||||||
|
// Set headers for SSE
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
// Enable CORS for SSE
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
||||||
|
|
||||||
|
// Send initial comment to establish connection
|
||||||
|
res.write(':\\n\\n');
|
||||||
|
|
||||||
|
// Create new transport for this client
|
||||||
|
const transport = new SSEServerTransport('/api/messages', res);
|
||||||
|
const sessionId = transport.sessionId;
|
||||||
|
|
||||||
|
console.error(\`New SSE connection established: \${sessionId}\`);
|
||||||
|
transports[sessionId] = transport;
|
||||||
|
|
||||||
|
// Clean up on connection close
|
||||||
|
req.on('close', () => {
|
||||||
|
console.error(\`SSE connection closed: \${sessionId}\`);
|
||||||
|
delete transports[sessionId];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect the transport to the MCP server
|
||||||
|
try {
|
||||||
|
await server.connect(transport);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(\`Error connecting transport for session \${sessionId}:\`, error);
|
||||||
|
// Don't try to send errors to the client here, as headers may already be sent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint for clients to send messages
|
||||||
|
app.post("/api/messages", async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).send('Missing sessionId query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = transports[sessionId];
|
||||||
|
|
||||||
|
if (!transport) {
|
||||||
|
return res.status(404).send('No active session found with the provided sessionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(\`Error handling message for session \${sessionId}:\`, error);
|
||||||
|
|
||||||
|
// If the response hasn't been sent yet, send an error response
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).send('Internal server error processing message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static files for the web client (if any)
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.error(\`MCP Web Server running at http://localhost:\${port}\`);
|
||||||
|
console.error(\`- SSE Endpoint: http://localhost:\${port}/sse\`);
|
||||||
|
console.error(\`- Messages Endpoint: http://localhost:\${port}/api/messages?sessionId=YOUR_SESSION_ID\`);
|
||||||
|
console.error(\`- Health Check: http://localhost:\${port}/health\`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML client for testing the MCP server
|
||||||
|
*
|
||||||
|
* @param serverName The name of the MCP server
|
||||||
|
* @returns HTML content for the test client
|
||||||
|
*/
|
||||||
|
export function generateTestClientHtml(serverName: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${serverName} MCP Test Client</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 { margin-bottom: 10px; }
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
#conversation {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
#userInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
#sendButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
}
|
||||||
|
#sendButton:hover { background-color: #45a049; }
|
||||||
|
.message {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.user {
|
||||||
|
background-color: #e7f4ff;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.server {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
}
|
||||||
|
.system {
|
||||||
|
background-color: #fffde7;
|
||||||
|
color: #795548;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
#debug {
|
||||||
|
margin-top: 20px;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.debug-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#showDebug {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
#debugLog {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #111;
|
||||||
|
color: #0f0;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.clear-debug {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${serverName} MCP Test Client</h1>
|
||||||
|
<p class="status" id="status">Disconnected</p>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div id="conversation"></div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<input type="text" id="userInput" placeholder="Type a message..." disabled>
|
||||||
|
<button id="sendButton" disabled>Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="showDebug">Show Debug Console</button>
|
||||||
|
|
||||||
|
<div id="debug">
|
||||||
|
<div class="debug-controls">
|
||||||
|
<h3>Debug Console</h3>
|
||||||
|
<button class="clear-debug" id="clearDebug">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="debugLog"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const conversation = document.getElementById('conversation');
|
||||||
|
const userInput = document.getElementById('userInput');
|
||||||
|
const sendButton = document.getElementById('sendButton');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const showDebugBtn = document.getElementById('showDebug');
|
||||||
|
const debugDiv = document.getElementById('debug');
|
||||||
|
const debugLog = document.getElementById('debugLog');
|
||||||
|
const clearDebugBtn = document.getElementById('clearDebug');
|
||||||
|
|
||||||
|
let sessionId = null;
|
||||||
|
let messageId = 1;
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
function log(type, message) {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.innerHTML = \`<span style="color:#aaa;">\${timestamp}</span> <span style="color:#58a6ff;">\${type}:</span> \${message}\`;
|
||||||
|
debugLog.appendChild(entry);
|
||||||
|
debugLog.scrollTop = debugLog.scrollHeight;
|
||||||
|
console.log(\`\${type}: \${message}\`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle debug console
|
||||||
|
showDebugBtn.addEventListener('click', () => {
|
||||||
|
if (debugDiv.style.display === 'block') {
|
||||||
|
debugDiv.style.display = 'none';
|
||||||
|
showDebugBtn.textContent = 'Show Debug Console';
|
||||||
|
} else {
|
||||||
|
debugDiv.style.display = 'block';
|
||||||
|
showDebugBtn.textContent = 'Hide Debug Console';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear debug logs
|
||||||
|
clearDebugBtn.addEventListener('click', () => {
|
||||||
|
debugLog.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to SSE endpoint
|
||||||
|
function connect() {
|
||||||
|
statusEl.textContent = 'Connecting...';
|
||||||
|
log('INFO', 'Connecting to SSE endpoint...');
|
||||||
|
|
||||||
|
// Close existing connection if any
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
log('INFO', 'Closed existing connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource('/sse');
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
log('INFO', 'SSE connection opened');
|
||||||
|
statusEl.textContent = 'Connected, waiting for session ID...';
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
log('ERROR', \`SSE connection error: \${error}\`);
|
||||||
|
statusEl.textContent = 'Connection error. Reconnecting in 3s...';
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle all SSE events
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
log('RAW', event.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// The MCP SSE transport sends messages in jsonrpc format
|
||||||
|
// Check if this is a notification with clientInfo containing sessionId
|
||||||
|
if (data.method === 'notification' && data.params?.clientInfo?.sessionId) {
|
||||||
|
sessionId = data.params.clientInfo.sessionId;
|
||||||
|
statusEl.textContent = \`Connected (Session ID: \${sessionId})\`;
|
||||||
|
userInput.disabled = false;
|
||||||
|
sendButton.disabled = false;
|
||||||
|
userInput.focus();
|
||||||
|
appendMessage('system', \`Connected with session ID: \${sessionId}\`);
|
||||||
|
log('INFO', \`Received session ID from MCP notification: \${sessionId}\`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for legacy session_id format (our custom format)
|
||||||
|
if (data.type === 'session_id') {
|
||||||
|
sessionId = data.session_id;
|
||||||
|
statusEl.textContent = \`Connected (Session ID: \${sessionId})\`;
|
||||||
|
userInput.disabled = false;
|
||||||
|
sendButton.disabled = false;
|
||||||
|
userInput.focus();
|
||||||
|
appendMessage('system', \`Connected with session ID: \${sessionId}\`);
|
||||||
|
log('INFO', \`Received session ID from legacy format: \${sessionId}\`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle jsonrpc responses
|
||||||
|
if (data.jsonrpc === '2.0' && data.result) {
|
||||||
|
appendMessage('server', JSON.stringify(data.result, null, 2));
|
||||||
|
userInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle normal server messages with content
|
||||||
|
if (data.content) {
|
||||||
|
appendMessage('server', JSON.stringify(data, null, 2));
|
||||||
|
userInput.focus();
|
||||||
|
} else {
|
||||||
|
log('INFO', \`Received other message: \${JSON.stringify(data)}\`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('ERROR', \`Error parsing SSE message: \${error.message}\`);
|
||||||
|
appendMessage('system', \`Error parsing message: \${event.data}\`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to the server
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = userInput.value.trim();
|
||||||
|
if (!text || !sessionId) return;
|
||||||
|
|
||||||
|
appendMessage('user', text);
|
||||||
|
userInput.value = '';
|
||||||
|
|
||||||
|
log('INFO', \`Sending message: \${text}\`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = text.split(' ');
|
||||||
|
const toolName = parts[0];
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: messageId++,
|
||||||
|
method: 'callTool',
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: parseArguments(text)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log('REQUEST', JSON.stringify(requestBody));
|
||||||
|
|
||||||
|
const response = await fetch(\`/api/messages?sessionId=\${sessionId}\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
log('ERROR', \`Error response: \${response.status} \${response.statusText} \${errorText}\`);
|
||||||
|
appendMessage('system', \`Error: \${response.status} \${response.statusText}\\n\${errorText}\`);
|
||||||
|
} else {
|
||||||
|
log('INFO', \`Request sent successfully\`);
|
||||||
|
// Note: We don't handle the response content here because the response
|
||||||
|
// will come through the SSE connection, not this fetch response
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('ERROR', \`Error sending message: \${error.message}\`);
|
||||||
|
appendMessage('system', \`Error sending message: \${error.message}\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse arguments from user input
|
||||||
|
// Format: toolName param1=value1 param2=value2
|
||||||
|
function parseArguments(text) {
|
||||||
|
const parts = text.split(' ');
|
||||||
|
if (parts.length <= 1) return {};
|
||||||
|
|
||||||
|
const args = {};
|
||||||
|
// Skip the first part (tool name) and process the rest
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const equalsIndex = part.indexOf('=');
|
||||||
|
|
||||||
|
if (equalsIndex > 0) {
|
||||||
|
const key = part.substring(0, equalsIndex);
|
||||||
|
const value = part.substring(equalsIndex + 1);
|
||||||
|
|
||||||
|
// Try to parse as number or boolean if possible
|
||||||
|
if (value === 'true') args[key] = true;
|
||||||
|
else if (value === 'false') args[key] = false;
|
||||||
|
else if (!isNaN(Number(value))) args[key] = Number(value);
|
||||||
|
else args[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a message to the conversation
|
||||||
|
function appendMessage(sender, text) {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = \`message \${sender}\`;
|
||||||
|
|
||||||
|
// Format as code block if it looks like JSON
|
||||||
|
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
const code = document.createElement('code');
|
||||||
|
code.textContent = text;
|
||||||
|
pre.appendChild(code);
|
||||||
|
messageDiv.appendChild(pre);
|
||||||
|
} else {
|
||||||
|
messageDiv.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.appendChild(messageDiv);
|
||||||
|
conversation.scrollTop = conversation.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
sendButton.addEventListener('click', sendMessage);
|
||||||
|
userInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') sendMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect on page load
|
||||||
|
appendMessage('system', 'Connecting to server...');
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
292
src/index.ts
292
src/index.ts
@ -1,112 +1,226 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* OpenAPI to MCP Generator
|
||||||
|
*
|
||||||
|
* This tool generates a Model Context Protocol (MCP) server from an OpenAPI specification.
|
||||||
|
* It creates a Node.js project that implements MCP over stdio to proxy API requests.
|
||||||
|
*/
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
import type { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
// Import generators
|
||||||
import {
|
import {
|
||||||
generateMcpServerCode,
|
generateMcpServerCode,
|
||||||
generatePackageJson,
|
generatePackageJson,
|
||||||
generateTsconfigJson,
|
generateTsconfigJson,
|
||||||
generateGitignore
|
generateGitignore,
|
||||||
} from './generator.js';
|
generateEslintConfig,
|
||||||
|
generateJestConfig,
|
||||||
|
generatePrettierConfig,
|
||||||
|
generateEnvExample,
|
||||||
|
generateOAuth2Docs,
|
||||||
|
generateWebServerCode,
|
||||||
|
generateTestClientHtml
|
||||||
|
} from './generator/index.js';
|
||||||
|
|
||||||
interface CliOptions {
|
// Import types
|
||||||
input: string;
|
import { CliOptions } from './types/index.js';
|
||||||
output: string;
|
|
||||||
serverName?: string;
|
|
||||||
serverVersion?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Configure CLI
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('openapi-mcp-generator')
|
.name('openapi-mcp-generator')
|
||||||
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
|
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
|
||||||
// Ensure these option definitions are robust
|
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
|
||||||
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
|
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
|
||||||
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
|
.option('-n, --server-name <n>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
|
||||||
.option('-n, --server-name <name>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
|
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
|
||||||
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
|
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
|
||||||
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
|
.option('-t, --transport <type>', 'Server transport type: "stdio" or "web" (default: "stdio")')
|
||||||
.version('2.0.0'); // Match package.json version
|
.option('-p, --port <number>', 'Port for web server (used with --transport=web, default: 3000)', (val) => parseInt(val, 10))
|
||||||
|
.option('--force', 'Overwrite existing files without prompting')
|
||||||
|
.version('2.0.0'); // Match package.json version
|
||||||
|
|
||||||
// Parse arguments explicitly from process.argv
|
// Parse arguments explicitly from process.argv
|
||||||
// This is generally the most reliable way
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
||||||
// Retrieve the options AFTER parsing
|
// Retrieve the options AFTER parsing
|
||||||
const options = program.opts<CliOptions>();
|
const options = program.opts<CliOptions & { force?: boolean }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to run the generator
|
||||||
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
// Use the parsed options directly
|
// Use the parsed options directly
|
||||||
const outputDir = options.output;
|
const outputDir = options.output;
|
||||||
const inputSpec = options.input; // Use the parsed input value
|
const inputSpec = options.input;
|
||||||
|
|
||||||
const srcDir = path.join(outputDir, 'src');
|
const srcDir = path.join(outputDir, 'src');
|
||||||
const serverFilePath = path.join(srcDir, 'index.ts');
|
const serverFilePath = path.join(srcDir, 'index.ts');
|
||||||
const packageJsonPath = path.join(outputDir, 'package.json');
|
const packageJsonPath = path.join(outputDir, 'package.json');
|
||||||
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
|
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
|
||||||
const gitignorePath = path.join(outputDir, '.gitignore');
|
const gitignorePath = path.join(outputDir, '.gitignore');
|
||||||
|
const eslintPath = path.join(outputDir, '.eslintrc.json');
|
||||||
|
const prettierPath = path.join(outputDir, '.prettierrc');
|
||||||
|
const jestConfigPath = path.join(outputDir, 'jest.config.js');
|
||||||
|
const envExamplePath = path.join(outputDir, '.env.example');
|
||||||
|
const docsDir = path.join(outputDir, 'docs');
|
||||||
|
const oauth2DocsPath = path.join(docsDir, 'oauth2-configuration.md');
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
await fs.rm(outputDir, { recursive: true, force: true });
|
// Check if output directory exists and is not empty
|
||||||
console.error(`Cleaned up partially created directory: ${outputDir}`);
|
if (!options.force) {
|
||||||
} catch (cleanupError) {
|
try {
|
||||||
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
|
const dirExists = await fs.stat(outputDir).catch(() => false);
|
||||||
|
if (dirExists) {
|
||||||
|
const files = await fs.readdir(outputDir);
|
||||||
|
if (files.length > 0) {
|
||||||
|
console.error(`Error: Output directory ${outputDir} already exists and is not empty.`);
|
||||||
|
console.error('Use --force to overwrite existing files.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Directory doesn't exist, which is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse OpenAPI spec
|
||||||
|
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
|
||||||
|
const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document;
|
||||||
|
console.error('OpenAPI spec parsed successfully.');
|
||||||
|
|
||||||
|
// Determine server name and version
|
||||||
|
const serverNameRaw = options.serverName || (api.info?.title || 'my-mcp-server');
|
||||||
|
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
||||||
|
const serverVersion = options.serverVersion || (api.info?.version || '0.1.0');
|
||||||
|
|
||||||
|
console.error('Generating server code...');
|
||||||
|
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
|
||||||
|
|
||||||
|
console.error('Generating package.json...');
|
||||||
|
const packageJsonContent = generatePackageJson(serverName, serverVersion, options.transport === 'web');
|
||||||
|
|
||||||
|
console.error('Generating tsconfig.json...');
|
||||||
|
const tsconfigJsonContent = generateTsconfigJson();
|
||||||
|
|
||||||
|
console.error('Generating .gitignore...');
|
||||||
|
const gitignoreContent = generateGitignore();
|
||||||
|
|
||||||
|
console.error('Generating ESLint config...');
|
||||||
|
const eslintConfigContent = generateEslintConfig();
|
||||||
|
|
||||||
|
console.error('Generating Prettier config...');
|
||||||
|
const prettierConfigContent = generatePrettierConfig();
|
||||||
|
|
||||||
|
console.error('Generating Jest config...');
|
||||||
|
const jestConfigContent = generateJestConfig();
|
||||||
|
|
||||||
|
console.error('Generating .env.example file...');
|
||||||
|
const envExampleContent = generateEnvExample(api.components?.securitySchemes);
|
||||||
|
|
||||||
|
console.error('Generating OAuth2 documentation...');
|
||||||
|
const oauth2DocsContent = generateOAuth2Docs(api.components?.securitySchemes);
|
||||||
|
|
||||||
|
console.error(`Creating project directory structure at: ${outputDir}`);
|
||||||
|
await fs.mkdir(srcDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(serverFilePath, serverTsContent);
|
||||||
|
console.error(` -> Created ${serverFilePath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(packageJsonPath, packageJsonContent);
|
||||||
|
console.error(` -> Created ${packageJsonPath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
|
||||||
|
console.error(` -> Created ${tsconfigPath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(gitignorePath, gitignoreContent);
|
||||||
|
console.error(` -> Created ${gitignorePath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(eslintPath, eslintConfigContent);
|
||||||
|
console.error(` -> Created ${eslintPath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(prettierPath, prettierConfigContent);
|
||||||
|
console.error(` -> Created ${prettierPath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(jestConfigPath, jestConfigContent);
|
||||||
|
console.error(` -> Created ${jestConfigPath}`);
|
||||||
|
|
||||||
|
await fs.writeFile(envExamplePath, envExampleContent);
|
||||||
|
console.error(` -> Created ${envExamplePath}`);
|
||||||
|
|
||||||
|
// Only write OAuth2 docs if there are OAuth2 security schemes
|
||||||
|
if (oauth2DocsContent.includes("No OAuth2 security schemes defined")) {
|
||||||
|
console.error(` -> No OAuth2 security schemes found, skipping documentation`);
|
||||||
|
} else {
|
||||||
|
await fs.mkdir(docsDir, { recursive: true });
|
||||||
|
await fs.writeFile(oauth2DocsPath, oauth2DocsContent);
|
||||||
|
console.error(` -> Created ${oauth2DocsPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate web server files if web transport is requested
|
||||||
|
if (options.transport === 'web') {
|
||||||
|
console.error('Generating web server files...');
|
||||||
|
|
||||||
|
// Generate web server code
|
||||||
|
const webServerCode = generateWebServerCode(options.port || 3000);
|
||||||
|
await fs.writeFile(webServerPath, webServerCode);
|
||||||
|
console.error(` -> Created ${webServerPath}`);
|
||||||
|
|
||||||
|
// Create public directory and index.html
|
||||||
|
await fs.mkdir(publicDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate test client
|
||||||
|
const indexHtmlContent = generateTestClientHtml(serverName);
|
||||||
|
await fs.writeFile(indexHtmlPath, indexHtmlContent);
|
||||||
|
console.error(` -> Created ${indexHtmlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("\n---");
|
||||||
|
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
|
||||||
|
console.error("\nNext steps:");
|
||||||
|
console.error(`1. Navigate to the directory: cd ${outputDir}`);
|
||||||
|
console.error(`2. Install dependencies: npm install`);
|
||||||
|
|
||||||
|
if (options.transport === 'web') {
|
||||||
|
console.error(`3. Build the TypeScript code: npm run build`);
|
||||||
|
console.error(`4. Run the server in web mode: npm run start:web`);
|
||||||
|
console.error(` (This will start a web server on port ${options.port || 3000})`);
|
||||||
|
console.error(` Access the test client at: http://localhost:${options.port || 3000}`);
|
||||||
|
} else {
|
||||||
|
console.error(`3. Build the TypeScript code: npm run build`);
|
||||||
|
console.error(`4. Run the server: npm start`);
|
||||||
|
console.error(` (This runs the built JavaScript code in build/index.js)`);
|
||||||
|
}
|
||||||
|
console.error("---");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nError generating MCP server project:', error);
|
||||||
|
|
||||||
|
// Only attempt cleanup if the directory exists and force option was used
|
||||||
|
if (options.force) {
|
||||||
|
try {
|
||||||
|
await fs.rm(outputDir, { recursive: true, force: true });
|
||||||
|
console.error(`Cleaned up partially created directory: ${outputDir}`);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main().catch(error => {
|
||||||
|
console.error('Unhandled error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
219
src/parser/extract-tools.ts
Normal file
219
src/parser/extract-tools.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Functions for extracting tools from an OpenAPI specification
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
|
||||||
|
import { generateOperationId } from '../utils/code-gen.js';
|
||||||
|
import { McpToolDefinition } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts tool definitions from an OpenAPI document
|
||||||
|
*
|
||||||
|
* @param api OpenAPI document
|
||||||
|
* @returns Array of MCP tool definitions
|
||||||
|
*/
|
||||||
|
export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
|
||||||
|
const tools: McpToolDefinition[] = [];
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
const globalSecurity = api.security || [];
|
||||||
|
|
||||||
|
if (!api.paths) return tools;
|
||||||
|
|
||||||
|
for (const [path, pathItem] of Object.entries(api.paths)) {
|
||||||
|
if (!pathItem) continue;
|
||||||
|
|
||||||
|
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
||||||
|
const operation = pathItem[method];
|
||||||
|
if (!operation) continue;
|
||||||
|
|
||||||
|
// Generate a unique name for the tool
|
||||||
|
let baseName = operation.operationId || generateOperationId(method, path);
|
||||||
|
if (!baseName) continue;
|
||||||
|
|
||||||
|
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
|
||||||
|
baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
|
||||||
|
|
||||||
|
let finalToolName = baseName;
|
||||||
|
let counter = 1;
|
||||||
|
while (usedNames.has(finalToolName)) {
|
||||||
|
finalToolName = `${baseName}_${counter++}`;
|
||||||
|
}
|
||||||
|
usedNames.add(finalToolName);
|
||||||
|
|
||||||
|
// Get or create a description
|
||||||
|
const description = operation.description || operation.summary ||
|
||||||
|
`Executes ${method.toUpperCase()} ${path}`;
|
||||||
|
|
||||||
|
// Generate input schema and extract parameters
|
||||||
|
const { inputSchema, parameters, requestBodyContentType } = generateInputSchemaAndDetails(operation);
|
||||||
|
|
||||||
|
// Extract parameter details for execution
|
||||||
|
const executionParameters = parameters.map(p => ({ name: p.name, in: p.in }));
|
||||||
|
|
||||||
|
// Determine security requirements
|
||||||
|
const securityRequirements = operation.security === null ?
|
||||||
|
globalSecurity :
|
||||||
|
operation.security || globalSecurity;
|
||||||
|
|
||||||
|
// Create the tool definition
|
||||||
|
tools.push({
|
||||||
|
name: finalToolName,
|
||||||
|
description,
|
||||||
|
inputSchema,
|
||||||
|
method,
|
||||||
|
pathTemplate: path,
|
||||||
|
parameters,
|
||||||
|
executionParameters,
|
||||||
|
requestBodyContentType,
|
||||||
|
securityRequirements,
|
||||||
|
operationId: baseName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates input schema and extracts parameter details from an operation
|
||||||
|
*
|
||||||
|
* @param operation OpenAPI operation object
|
||||||
|
* @returns Input schema, parameters, and request body content type
|
||||||
|
*/
|
||||||
|
export function generateInputSchemaAndDetails(operation: OpenAPIV3.OperationObject): {
|
||||||
|
inputSchema: JSONSchema7 | boolean;
|
||||||
|
parameters: OpenAPIV3.ParameterObject[];
|
||||||
|
requestBodyContentType?: string;
|
||||||
|
} {
|
||||||
|
const properties: { [key: string]: JSONSchema7 | boolean } = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
|
||||||
|
// Process parameters
|
||||||
|
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
|
||||||
|
? operation.parameters.map(p => p as OpenAPIV3.ParameterObject)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
allParameters.forEach(param => {
|
||||||
|
if (!param.name || !param.schema) return;
|
||||||
|
|
||||||
|
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
|
||||||
|
if (typeof paramSchema === 'object') {
|
||||||
|
paramSchema.description = param.description || paramSchema.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
properties[param.name] = paramSchema;
|
||||||
|
if (param.required) required.push(param.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process request body (if present)
|
||||||
|
let requestBodyContentType: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (operation.requestBody) {
|
||||||
|
const opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
||||||
|
const jsonContent = opRequestBody.content?.['application/json'];
|
||||||
|
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
|
||||||
|
|
||||||
|
if (jsonContent?.schema) {
|
||||||
|
requestBodyContentType = 'application/json';
|
||||||
|
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
|
||||||
|
|
||||||
|
if (typeof bodySchema === 'object') {
|
||||||
|
bodySchema.description = opRequestBody.description ||
|
||||||
|
bodySchema.description ||
|
||||||
|
'The JSON request body.';
|
||||||
|
}
|
||||||
|
|
||||||
|
properties['requestBody'] = bodySchema;
|
||||||
|
if (opRequestBody.required) required.push('requestBody');
|
||||||
|
} else if (firstContent) {
|
||||||
|
const [contentType] = firstContent;
|
||||||
|
requestBodyContentType = contentType;
|
||||||
|
|
||||||
|
properties['requestBody'] = {
|
||||||
|
type: 'string',
|
||||||
|
description: opRequestBody.description || `Request body (content type: ${contentType})`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opRequestBody.required) required.push('requestBody');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine everything into a JSON Schema
|
||||||
|
const inputSchema: JSONSchema7 = {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
...(required.length > 0 && { required })
|
||||||
|
};
|
||||||
|
|
||||||
|
return { inputSchema, parameters: allParameters, requestBodyContentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an OpenAPI schema to a JSON Schema
|
||||||
|
*
|
||||||
|
* @param schema OpenAPI schema object or reference
|
||||||
|
* @returns JSON Schema representation
|
||||||
|
*/
|
||||||
|
export function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean {
|
||||||
|
// Handle reference objects
|
||||||
|
if ('$ref' in schema) {
|
||||||
|
console.warn(`Unresolved $ref '${schema.$ref}'.`);
|
||||||
|
return { type: 'object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle boolean schemas
|
||||||
|
if (typeof schema === 'boolean') return schema;
|
||||||
|
|
||||||
|
// Create a copy of the schema to modify
|
||||||
|
const jsonSchema: JSONSchema7 = { ...schema } as any;
|
||||||
|
|
||||||
|
// Convert integer type to number (JSON Schema compatible)
|
||||||
|
if (schema.type === 'integer') jsonSchema.type = 'number';
|
||||||
|
|
||||||
|
// Remove OpenAPI-specific properties that aren't in JSON Schema
|
||||||
|
delete (jsonSchema as any).nullable;
|
||||||
|
delete (jsonSchema as any).example;
|
||||||
|
delete (jsonSchema as any).xml;
|
||||||
|
delete (jsonSchema as any).externalDocs;
|
||||||
|
delete (jsonSchema as any).deprecated;
|
||||||
|
delete (jsonSchema as any).readOnly;
|
||||||
|
delete (jsonSchema as any).writeOnly;
|
||||||
|
|
||||||
|
// Handle nullable properties by adding null to the type
|
||||||
|
if (schema.nullable) {
|
||||||
|
if (Array.isArray(jsonSchema.type)) {
|
||||||
|
if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null');
|
||||||
|
}
|
||||||
|
else if (typeof jsonSchema.type === 'string') {
|
||||||
|
jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
|
||||||
|
}
|
||||||
|
else if (!jsonSchema.type) {
|
||||||
|
jsonSchema.type = 'null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process object properties
|
||||||
|
if (jsonSchema.type === 'object' && jsonSchema.properties) {
|
||||||
|
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
|
||||||
|
|
||||||
|
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
|
||||||
|
if (typeof propSchema === 'object' && propSchema !== null) {
|
||||||
|
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
|
||||||
|
}
|
||||||
|
else if (typeof propSchema === 'boolean') {
|
||||||
|
mappedProps[key] = propSchema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSchema.properties = mappedProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process array items
|
||||||
|
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
|
||||||
|
jsonSchema.items = mapOpenApiSchemaToJsonSchema(
|
||||||
|
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonSchema;
|
||||||
|
}
|
||||||
4
src/parser/index.ts
Normal file
4
src/parser/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Parser module exports
|
||||||
|
*/
|
||||||
|
export * from './extract-tools.js';
|
||||||
57
src/types/index.ts
Normal file
57
src/types/index.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Core type definitions for the openapi-to-mcp generator
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI options for the generator
|
||||||
|
*/
|
||||||
|
export interface CliOptions {
|
||||||
|
/** Path to the OpenAPI specification file */
|
||||||
|
input: string;
|
||||||
|
/** Output directory path for generated files */
|
||||||
|
output: string;
|
||||||
|
/** Optional server name to override the one in the OpenAPI spec */
|
||||||
|
serverName?: string;
|
||||||
|
/** Optional server version to override the one in the OpenAPI spec */
|
||||||
|
serverVersion?: string;
|
||||||
|
/** Optional base URL to override the one in the OpenAPI spec */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Server transport type (stdio or web) */
|
||||||
|
transport?: 'stdio' | 'web';
|
||||||
|
/** Server port (for web transport) */
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Tool Definition describes a tool extracted from an OpenAPI spec
|
||||||
|
* for use in Model Context Protocol server
|
||||||
|
*/
|
||||||
|
export interface McpToolDefinition {
|
||||||
|
/** Name of the tool, must be unique */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable description of the tool */
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema that defines the input parameters */
|
||||||
|
inputSchema: JSONSchema7 | boolean;
|
||||||
|
/** HTTP method for the operation (get, post, etc.) */
|
||||||
|
method: string;
|
||||||
|
/** URL path template with parameter placeholders */
|
||||||
|
pathTemplate: string;
|
||||||
|
/** OpenAPI parameter objects for this operation */
|
||||||
|
parameters: OpenAPIV3.ParameterObject[];
|
||||||
|
/** Parameter names and locations for execution */
|
||||||
|
executionParameters: { name: string, in: string }[];
|
||||||
|
/** Content type for request body, if applicable */
|
||||||
|
requestBodyContentType?: string;
|
||||||
|
/** Security requirements for this operation */
|
||||||
|
securityRequirements: OpenAPIV3.SecurityRequirementObject[];
|
||||||
|
/** Original operation ID from the OpenAPI spec */
|
||||||
|
operationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type for JSON objects
|
||||||
|
*/
|
||||||
|
export type JsonObject = Record<string, any>;
|
||||||
40
src/utils.ts
40
src/utils.ts
@ -1,40 +0,0 @@
|
|||||||
export function titleCase(str: string): string {
|
|
||||||
// Converts snake_case, kebab-case, or path/parts to TitleCase
|
|
||||||
return str
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
|
|
||||||
.replace(/^{/, '') // Remove leading { from path params
|
|
||||||
.replace(/}$/, '') // Remove trailing } from path params
|
|
||||||
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateOperationId(method: string, path: string): string {
|
|
||||||
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
|
|
||||||
const parts = path.split('/').filter(p => p); // Split and remove empty parts
|
|
||||||
|
|
||||||
let name = method.toLowerCase(); // Start with method name
|
|
||||||
|
|
||||||
parts.forEach((part, index) => {
|
|
||||||
if (part.startsWith('{') && part.endsWith('}')) {
|
|
||||||
// Append 'By' + ParamName only for the *last* path parameter segment
|
|
||||||
if (index === parts.length - 1) {
|
|
||||||
name += 'By' + titleCase(part);
|
|
||||||
}
|
|
||||||
// Potentially include non-terminal params differently if needed, e.g.:
|
|
||||||
// else { name += 'With' + titleCase(part); }
|
|
||||||
} else {
|
|
||||||
// Append the static path part in TitleCase
|
|
||||||
name += titleCase(part);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simple fallback if name is just the method (e.g., GET /)
|
|
||||||
if (name === method.toLowerCase()) {
|
|
||||||
name += 'Root';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure first letter is uppercase after potential lowercase method start
|
|
||||||
name = name.charAt(0).toUpperCase() + name.slice(1);
|
|
||||||
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
154
src/utils/code-gen.ts
Normal file
154
src/utils/code-gen.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Code generation utilities for OpenAPI to MCP generator
|
||||||
|
*/
|
||||||
|
import { McpToolDefinition } from '../types/index.js';
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import { sanitizeForTemplate } from './helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the tool definition map code
|
||||||
|
*
|
||||||
|
* @param tools List of tool definitions
|
||||||
|
* @param securitySchemes Security schemes from OpenAPI spec
|
||||||
|
* @returns Generated code for the tool definition map
|
||||||
|
*/
|
||||||
|
export function generateToolDefinitionMap(
|
||||||
|
tools: McpToolDefinition[],
|
||||||
|
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||||
|
): string {
|
||||||
|
if (tools.length === 0) return "";
|
||||||
|
|
||||||
|
return tools.map(tool => {
|
||||||
|
// Safely stringify complex objects
|
||||||
|
let schemaString;
|
||||||
|
try {
|
||||||
|
schemaString = JSON.stringify(tool.inputSchema);
|
||||||
|
} catch (e) {
|
||||||
|
schemaString = '{}';
|
||||||
|
console.warn(`Failed to stringify schema for tool ${tool.name}: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let execParamsString;
|
||||||
|
try {
|
||||||
|
execParamsString = JSON.stringify(tool.executionParameters);
|
||||||
|
} catch (e) {
|
||||||
|
execParamsString = "[]";
|
||||||
|
console.warn(`Failed to stringify execution parameters for tool ${tool.name}: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let securityReqsString;
|
||||||
|
try {
|
||||||
|
securityReqsString = JSON.stringify(tool.securityRequirements);
|
||||||
|
} catch (e) {
|
||||||
|
securityReqsString = "[]";
|
||||||
|
console.warn(`Failed to stringify security requirements for tool ${tool.name}: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize description for template literal
|
||||||
|
const escapedDescription = sanitizeForTemplate(tool.description);
|
||||||
|
|
||||||
|
// Build the tool definition entry
|
||||||
|
return `
|
||||||
|
["${tool.name}", {
|
||||||
|
name: "${tool.name}",
|
||||||
|
description: \`${escapedDescription}\`,
|
||||||
|
inputSchema: ${schemaString},
|
||||||
|
method: "${tool.method}",
|
||||||
|
pathTemplate: "${tool.pathTemplate}",
|
||||||
|
executionParameters: ${execParamsString},
|
||||||
|
requestBodyContentType: ${tool.requestBodyContentType ? `"${tool.requestBodyContentType}"` : 'undefined'},
|
||||||
|
securityRequirements: ${securityReqsString}
|
||||||
|
}],`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the list tools handler code
|
||||||
|
*
|
||||||
|
* @returns Generated code for the list tools handler
|
||||||
|
*/
|
||||||
|
export function generateListToolsHandler(): string {
|
||||||
|
return `
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
const toolsForClient: Tool[] = Array.from(toolDefinitionMap.values()).map(def => ({
|
||||||
|
name: def.name,
|
||||||
|
description: def.description,
|
||||||
|
inputSchema: def.inputSchema
|
||||||
|
}));
|
||||||
|
return { tools: toolsForClient };
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the call tool handler code
|
||||||
|
*
|
||||||
|
* @returns Generated code for the call tool handler
|
||||||
|
*/
|
||||||
|
export function generateCallToolHandler(): string {
|
||||||
|
return `
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
|
||||||
|
const { name: toolName, arguments: toolArgs } = request.params;
|
||||||
|
const toolDefinition = toolDefinitionMap.get(toolName);
|
||||||
|
if (!toolDefinition) {
|
||||||
|
console.error(\`Error: Unknown tool requested: \${toolName}\`);
|
||||||
|
return { content: [{ type: "text", text: \`Error: Unknown tool requested: \${toolName}\` }] };
|
||||||
|
}
|
||||||
|
return await executeApiTool(toolName, toolDefinition, toolArgs ?? {}, securitySchemes);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to title case
|
||||||
|
*
|
||||||
|
* @param str String to convert
|
||||||
|
* @returns Title case string
|
||||||
|
*/
|
||||||
|
export function titleCase(str: string): string {
|
||||||
|
// Converts snake_case, kebab-case, or path/parts to TitleCase
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
|
||||||
|
.replace(/^{/, '') // Remove leading { from path params
|
||||||
|
.replace(/}$/, '') // Remove trailing } from path params
|
||||||
|
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an operation ID from method and path
|
||||||
|
*
|
||||||
|
* @param method HTTP method
|
||||||
|
* @param path API path
|
||||||
|
* @returns Generated operation ID
|
||||||
|
*/
|
||||||
|
export function generateOperationId(method: string, path: string): string {
|
||||||
|
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
|
||||||
|
const parts = path.split('/').filter(p => p); // Split and remove empty parts
|
||||||
|
|
||||||
|
let name = method.toLowerCase(); // Start with method name
|
||||||
|
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
if (part.startsWith('{') && part.endsWith('}')) {
|
||||||
|
// Append 'By' + ParamName only for the *last* path parameter segment
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
name += 'By' + titleCase(part);
|
||||||
|
}
|
||||||
|
// Potentially include non-terminal params differently if needed, e.g.:
|
||||||
|
// else { name += 'With' + titleCase(part); }
|
||||||
|
} else {
|
||||||
|
// Append the static path part in TitleCase
|
||||||
|
name += titleCase(part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple fallback if name is just the method (e.g., GET /)
|
||||||
|
if (name === method.toLowerCase()) {
|
||||||
|
name += 'Root';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure first letter is uppercase after potential lowercase method start
|
||||||
|
name = name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
112
src/utils/helpers.ts
Normal file
112
src/utils/helpers.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* General helper utilities for OpenAPI to MCP generator
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely stringify a JSON object with proper error handling
|
||||||
|
*
|
||||||
|
* @param obj Object to stringify
|
||||||
|
* @param defaultValue Default value to return if stringify fails
|
||||||
|
* @returns JSON string or default value
|
||||||
|
*/
|
||||||
|
export function safeJsonStringify(obj: any, defaultValue: string = '{}'): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to stringify object: ${e}`);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a string for use in template strings
|
||||||
|
*
|
||||||
|
* @param str String to sanitize
|
||||||
|
* @returns Sanitized string safe for use in template literals
|
||||||
|
*/
|
||||||
|
export function sanitizeForTemplate(str: string): string {
|
||||||
|
return (str || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string to camelCase
|
||||||
|
*
|
||||||
|
* @param str String to convert
|
||||||
|
* @returns camelCase string
|
||||||
|
*/
|
||||||
|
export function toCamelCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
|
||||||
|
index === 0 ? word.toLowerCase() : word.toUpperCase()
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string to PascalCase
|
||||||
|
*
|
||||||
|
* @param str String to convert
|
||||||
|
* @returns PascalCase string
|
||||||
|
*/
|
||||||
|
export function toPascalCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a valid variable name from a string
|
||||||
|
*
|
||||||
|
* @param str Input string
|
||||||
|
* @returns Valid JavaScript variable name
|
||||||
|
*/
|
||||||
|
export function toValidVariableName(str: string): string {
|
||||||
|
// Replace non-alphanumeric characters with underscores
|
||||||
|
const sanitized = str.replace(/[^a-zA-Z0-9_$]/g, '_');
|
||||||
|
|
||||||
|
// Ensure the variable name doesn't start with a number
|
||||||
|
return sanitized.match(/^[0-9]/) ? '_' + sanitized : sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid JavaScript identifier
|
||||||
|
*
|
||||||
|
* @param str String to check
|
||||||
|
* @returns True if valid identifier, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidIdentifier(str: string): boolean {
|
||||||
|
// Check if the string is a valid JavaScript identifier
|
||||||
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a string for use in code comments
|
||||||
|
*
|
||||||
|
* @param str String to format
|
||||||
|
* @param maxLineLength Maximum line length
|
||||||
|
* @returns Formatted comment string
|
||||||
|
*/
|
||||||
|
export function formatComment(str: string, maxLineLength: number = 80): string {
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
const words = str.trim().split(/\s+/);
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
words.forEach(word => {
|
||||||
|
if ((currentLine + ' ' + word).length <= maxLineLength) {
|
||||||
|
currentLine += (currentLine ? ' ' : '') + word;
|
||||||
|
} else {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n * ');
|
||||||
|
}
|
||||||
7
src/utils/index.ts
Normal file
7
src/utils/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Utilities module exports
|
||||||
|
*/
|
||||||
|
export * from './code-gen.js';
|
||||||
|
export * from './security.js';
|
||||||
|
export * from './helpers.js';
|
||||||
|
export { determineBaseUrl } from './url.js';
|
||||||
584
src/utils/security.ts
Normal file
584
src/utils/security.ts
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* Security handling utilities for OpenAPI to MCP generator
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment variable name for a security scheme
|
||||||
|
*
|
||||||
|
* @param schemeName Security scheme name
|
||||||
|
* @param type Type of security credentials
|
||||||
|
* @returns Environment variable name
|
||||||
|
*/
|
||||||
|
export function getEnvVarName(
|
||||||
|
schemeName: string,
|
||||||
|
type: 'API_KEY' | 'BEARER_TOKEN' | 'BASIC_USERNAME' | 'BASIC_PASSWORD' | 'OAUTH_CLIENT_ID' | 'OAUTH_CLIENT_SECRET' | 'OAUTH_TOKEN' | 'OAUTH_SCOPES' | 'OPENID_TOKEN'
|
||||||
|
): string {
|
||||||
|
const sanitizedName = schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||||
|
return `${type}_${sanitizedName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code for handling API key security
|
||||||
|
*
|
||||||
|
* @param scheme API key security scheme
|
||||||
|
* @returns Generated code
|
||||||
|
*/
|
||||||
|
export function generateApiKeySecurityCode(scheme: OpenAPIV3.ApiKeySecurityScheme): string {
|
||||||
|
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||||
|
return `
|
||||||
|
if (scheme?.type === 'apiKey') {
|
||||||
|
const apiKey = process.env[\`${getEnvVarName(schemeName, 'API_KEY')}\`];
|
||||||
|
if (apiKey) {
|
||||||
|
if (scheme.in === 'header') {
|
||||||
|
headers[scheme.name.toLowerCase()] = apiKey;
|
||||||
|
}
|
||||||
|
else if (scheme.in === 'query') {
|
||||||
|
queryParams[scheme.name] = apiKey;
|
||||||
|
}
|
||||||
|
else if (scheme.in === 'cookie') {
|
||||||
|
headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code for handling HTTP security (Bearer/Basic)
|
||||||
|
*
|
||||||
|
* @returns Generated code
|
||||||
|
*/
|
||||||
|
export function generateHttpSecurityCode(): string {
|
||||||
|
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||||
|
return `
|
||||||
|
else if (scheme?.type === 'http') {
|
||||||
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||||
|
const token = process.env[\`${getEnvVarName(schemeName, 'BEARER_TOKEN')}\`];
|
||||||
|
if (token) {
|
||||||
|
headers['authorization'] = \`Bearer \${token}\`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||||
|
const username = process.env[\`${getEnvVarName(schemeName, 'BASIC_USERNAME')}\`];
|
||||||
|
const password = process.env[\`${getEnvVarName(schemeName, 'BASIC_PASSWORD')}\`];
|
||||||
|
if (username && password) {
|
||||||
|
headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code for OAuth2 token acquisition
|
||||||
|
*
|
||||||
|
* @returns Generated code for OAuth2 token acquisition
|
||||||
|
*/
|
||||||
|
export function generateOAuth2TokenAcquisitionCode(): string {
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Type definition for cached OAuth tokens
|
||||||
|
*/
|
||||||
|
interface TokenCacheEntry {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare global __oauthTokenCache property for TypeScript
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
var __oauthTokenCache: Record<string, TokenCacheEntry> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires an OAuth2 token using client credentials flow
|
||||||
|
*
|
||||||
|
* @param schemeName Name of the security scheme
|
||||||
|
* @param scheme OAuth2 security scheme
|
||||||
|
* @returns Acquired token or null if unable to acquire
|
||||||
|
*/
|
||||||
|
async function acquireOAuth2Token(schemeName: string, scheme: any): Promise<string | null | undefined> {
|
||||||
|
try {
|
||||||
|
// Check if we have the necessary credentials
|
||||||
|
const clientId = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_ID')}\`];
|
||||||
|
const clientSecret = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_SECRET')}\`];
|
||||||
|
const scopes = process.env[\`${getEnvVarName('schemeName', 'OAUTH_SCOPES')}\`];
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
console.error(\`Missing client credentials for OAuth2 scheme '\${schemeName}'\`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize token cache if needed
|
||||||
|
if (typeof global.__oauthTokenCache === 'undefined') {
|
||||||
|
global.__oauthTokenCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a cached token
|
||||||
|
const cacheKey = \`\${schemeName}_\${clientId}\`;
|
||||||
|
const cachedToken = global.__oauthTokenCache[cacheKey];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cachedToken && cachedToken.expiresAt > now) {
|
||||||
|
console.error(\`Using cached OAuth2 token for '\${schemeName}' (expires in \${Math.floor((cachedToken.expiresAt - now) / 1000)} seconds)\`);
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine token URL based on flow type
|
||||||
|
let tokenUrl = '';
|
||||||
|
if (scheme.flows?.clientCredentials?.tokenUrl) {
|
||||||
|
tokenUrl = scheme.flows.clientCredentials.tokenUrl;
|
||||||
|
console.error(\`Using client credentials flow for '\${schemeName}'\`);
|
||||||
|
} else if (scheme.flows?.password?.tokenUrl) {
|
||||||
|
tokenUrl = scheme.flows.password.tokenUrl;
|
||||||
|
console.error(\`Using password flow for '\${schemeName}'\`);
|
||||||
|
} else {
|
||||||
|
console.error(\`No supported OAuth2 flow found for '\${schemeName}'\`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the token request
|
||||||
|
let formData = new URLSearchParams();
|
||||||
|
formData.append('grant_type', 'client_credentials');
|
||||||
|
|
||||||
|
// Add scopes if specified
|
||||||
|
if (scopes) {
|
||||||
|
formData.append('scope', scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(\`Requesting OAuth2 token from \${tokenUrl}\`);
|
||||||
|
|
||||||
|
// Make the token request
|
||||||
|
const response = await axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: tokenUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Authorization': \`Basic \${Buffer.from(\`\${clientId}:\${clientSecret}\`).toString('base64')}\`
|
||||||
|
},
|
||||||
|
data: formData.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the response
|
||||||
|
if (response.data?.access_token) {
|
||||||
|
const token = response.data.access_token;
|
||||||
|
const expiresIn = response.data.expires_in || 3600; // Default to 1 hour
|
||||||
|
|
||||||
|
// Cache the token
|
||||||
|
global.__oauthTokenCache[cacheKey] = {
|
||||||
|
token,
|
||||||
|
expiresAt: now + (expiresIn * 1000) - 60000 // Expire 1 minute early
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(\`Successfully acquired OAuth2 token for '\${schemeName}' (expires in \${expiresIn} seconds)\`);
|
||||||
|
return token;
|
||||||
|
} else {
|
||||||
|
console.error(\`Failed to acquire OAuth2 token for '\${schemeName}': No access_token in response\`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(\`Error acquiring OAuth2 token for '\${schemeName}':\`, errorMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code for executing API tools with security handling
|
||||||
|
*
|
||||||
|
* @param securitySchemes Security schemes from OpenAPI spec
|
||||||
|
* @returns Generated code for the execute API tool function
|
||||||
|
*/
|
||||||
|
export function generateExecuteApiToolFunction(
|
||||||
|
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||||
|
): string {
|
||||||
|
// Generate OAuth2 token acquisition function
|
||||||
|
const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode();
|
||||||
|
|
||||||
|
// Generate security handling code for checking, applying security
|
||||||
|
const securityCode = `
|
||||||
|
// Apply security requirements if available
|
||||||
|
// Security requirements use OR between array items and AND within each object
|
||||||
|
const appliedSecurity = definition.securityRequirements?.find(req => {
|
||||||
|
// Try each security requirement (combined with OR)
|
||||||
|
return Object.entries(req).every(([schemeName, scopesArray]) => {
|
||||||
|
const scheme = allSecuritySchemes[schemeName];
|
||||||
|
if (!scheme) return false;
|
||||||
|
|
||||||
|
// API Key security (header, query, cookie)
|
||||||
|
if (scheme.type === 'apiKey') {
|
||||||
|
return !!process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP security (basic, bearer)
|
||||||
|
if (scheme.type === 'http') {
|
||||||
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||||
|
return !!process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
}
|
||||||
|
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||||
|
return !!process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] &&
|
||||||
|
!!process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2 security
|
||||||
|
if (scheme.type === 'oauth2') {
|
||||||
|
// Check for pre-existing token
|
||||||
|
if (process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for client credentials for auto-acquisition
|
||||||
|
if (process.env[\`OAUTH_CLIENT_ID_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`] &&
|
||||||
|
process.env[\`OAUTH_CLIENT_SECRET_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`]) {
|
||||||
|
// Verify we have a supported flow
|
||||||
|
if (scheme.flows?.clientCredentials || scheme.flows?.password) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenID Connect
|
||||||
|
if (scheme.type === 'openIdConnect') {
|
||||||
|
return !!process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we found matching security scheme(s), apply them
|
||||||
|
if (appliedSecurity) {
|
||||||
|
// Apply each security scheme from this requirement (combined with AND)
|
||||||
|
for (const [schemeName, scopesArray] of Object.entries(appliedSecurity)) {
|
||||||
|
const scheme = allSecuritySchemes[schemeName];
|
||||||
|
|
||||||
|
// API Key security
|
||||||
|
if (scheme?.type === 'apiKey') {
|
||||||
|
const apiKey = process.env[\`API_KEY_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
if (apiKey) {
|
||||||
|
if (scheme.in === 'header') {
|
||||||
|
headers[scheme.name.toLowerCase()] = apiKey;
|
||||||
|
console.error(\`Applied API key '\${schemeName}' in header '\${scheme.name}'\`);
|
||||||
|
}
|
||||||
|
else if (scheme.in === 'query') {
|
||||||
|
queryParams[scheme.name] = apiKey;
|
||||||
|
console.error(\`Applied API key '\${schemeName}' in query parameter '\${scheme.name}'\`);
|
||||||
|
}
|
||||||
|
else if (scheme.in === 'cookie') {
|
||||||
|
// Add the cookie, preserving other cookies if they exist
|
||||||
|
headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`;
|
||||||
|
console.error(\`Applied API key '\${schemeName}' in cookie '\${scheme.name}'\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HTTP security (Bearer or Basic)
|
||||||
|
else if (scheme?.type === 'http') {
|
||||||
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||||
|
const token = process.env[\`BEARER_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
if (token) {
|
||||||
|
headers['authorization'] = \`Bearer \${token}\`;
|
||||||
|
console.error(\`Applied Bearer token for '\${schemeName}'\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||||
|
const username = process.env[\`BASIC_USERNAME_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
const password = process.env[\`BASIC_PASSWORD_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
if (username && password) {
|
||||||
|
headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`;
|
||||||
|
console.error(\`Applied Basic authentication for '\${schemeName}'\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OAuth2 security
|
||||||
|
else if (scheme?.type === 'oauth2') {
|
||||||
|
// First try to use a pre-provided token
|
||||||
|
let token = process.env[\`OAUTH_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
|
||||||
|
// If no token but we have client credentials, try to acquire a token
|
||||||
|
if (!token && (scheme.flows?.clientCredentials || scheme.flows?.password)) {
|
||||||
|
console.error(\`Attempting to acquire OAuth token for '\${schemeName}'\`);
|
||||||
|
token = (await acquireOAuth2Token(schemeName, scheme)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply token if available
|
||||||
|
if (token) {
|
||||||
|
headers['authorization'] = \`Bearer \${token}\`;
|
||||||
|
console.error(\`Applied OAuth2 token for '\${schemeName}'\`);
|
||||||
|
|
||||||
|
// List the scopes that were requested, if any
|
||||||
|
const scopes = scopesArray as string[];
|
||||||
|
if (scopes && scopes.length > 0) {
|
||||||
|
console.error(\`Requested scopes: \${scopes.join(', ')}\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OpenID Connect
|
||||||
|
else if (scheme?.type === 'openIdConnect') {
|
||||||
|
const token = process.env[\`OPENID_TOKEN_\${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}\`];
|
||||||
|
if (token) {
|
||||||
|
headers['authorization'] = \`Bearer \${token}\`;
|
||||||
|
console.error(\`Applied OpenID Connect token for '\${schemeName}'\`);
|
||||||
|
|
||||||
|
// List the scopes that were requested, if any
|
||||||
|
const scopes = scopesArray as string[];
|
||||||
|
if (scopes && scopes.length > 0) {
|
||||||
|
console.error(\`Requested scopes: \${scopes.join(', ')}\`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Log warning if security is required but not available
|
||||||
|
else if (definition.securityRequirements?.length > 0) {
|
||||||
|
// First generate a more readable representation of the security requirements
|
||||||
|
const securityRequirementsString = definition.securityRequirements
|
||||||
|
.map(req => {
|
||||||
|
const parts = Object.entries(req)
|
||||||
|
.map(([name, scopesArray]) => {
|
||||||
|
const scopes = scopesArray as string[];
|
||||||
|
if (scopes.length === 0) return name;
|
||||||
|
return \`\${name} (scopes: \${scopes.join(', ')})\`;
|
||||||
|
})
|
||||||
|
.join(' AND ');
|
||||||
|
return \`[\${parts}]\`;
|
||||||
|
})
|
||||||
|
.join(' OR ');
|
||||||
|
|
||||||
|
console.warn(\`Tool '\${toolName}' requires security: \${securityRequirementsString}, but no suitable credentials found.\`);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Generate complete execute API tool function
|
||||||
|
return `
|
||||||
|
${oauth2TokenAcquisitionCode}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an API tool with the provided arguments
|
||||||
|
*
|
||||||
|
* @param toolName Name of the tool to execute
|
||||||
|
* @param definition Tool definition
|
||||||
|
* @param toolArgs Arguments provided by the user
|
||||||
|
* @param allSecuritySchemes Security schemes from the OpenAPI spec
|
||||||
|
* @returns Call tool result
|
||||||
|
*/
|
||||||
|
async function executeApiTool(
|
||||||
|
toolName: string,
|
||||||
|
definition: McpToolDefinition,
|
||||||
|
toolArgs: JsonObject,
|
||||||
|
allSecuritySchemes: Record<string, any>
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
// Validate arguments against the input schema
|
||||||
|
let validatedArgs: JsonObject;
|
||||||
|
try {
|
||||||
|
const zodSchema = getZodSchemaFromJsonSchema(definition.inputSchema, toolName);
|
||||||
|
const argsToParse = (typeof toolArgs === 'object' && toolArgs !== null) ? toolArgs : {};
|
||||||
|
validatedArgs = zodSchema.parse(argsToParse);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const validationErrorMessage = \`Invalid arguments for tool '\${toolName}': \${error.errors.map(e => \`\${e.path.join('.')} (\${e.code}): \${e.message}\`).join(', ')}\`;
|
||||||
|
return { content: [{ type: 'text', text: validationErrorMessage }] };
|
||||||
|
} else {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return { content: [{ type: 'text', text: \`Internal error during validation setup: \${errorMessage}\` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare URL, query parameters, headers, and request body
|
||||||
|
let urlPath = definition.pathTemplate;
|
||||||
|
const queryParams: Record<string, any> = {};
|
||||||
|
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||||
|
let requestBodyData: any = undefined;
|
||||||
|
|
||||||
|
// Apply parameters to the URL path, query, or headers
|
||||||
|
definition.executionParameters.forEach((param) => {
|
||||||
|
const value = validatedArgs[param.name];
|
||||||
|
if (typeof value !== 'undefined' && value !== null) {
|
||||||
|
if (param.in === 'path') {
|
||||||
|
urlPath = urlPath.replace(\`{\${param.name}}\`, encodeURIComponent(String(value)));
|
||||||
|
}
|
||||||
|
else if (param.in === 'query') {
|
||||||
|
queryParams[param.name] = value;
|
||||||
|
}
|
||||||
|
else if (param.in === 'header') {
|
||||||
|
headers[param.name.toLowerCase()] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure all path parameters are resolved
|
||||||
|
if (urlPath.includes('{')) {
|
||||||
|
throw new Error(\`Failed to resolve path parameters: \${urlPath}\`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the full URL
|
||||||
|
const requestUrl = API_BASE_URL ? \`\${API_BASE_URL}\${urlPath}\` : urlPath;
|
||||||
|
|
||||||
|
// Handle request body if needed
|
||||||
|
if (definition.requestBodyContentType && typeof validatedArgs['requestBody'] !== 'undefined') {
|
||||||
|
requestBodyData = validatedArgs['requestBody'];
|
||||||
|
headers['content-type'] = definition.requestBodyContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
${securityCode}
|
||||||
|
|
||||||
|
// Prepare the axios request configuration
|
||||||
|
const config: AxiosRequestConfig = {
|
||||||
|
method: definition.method.toUpperCase(),
|
||||||
|
url: requestUrl,
|
||||||
|
params: queryParams,
|
||||||
|
headers: headers,
|
||||||
|
...(requestBodyData !== undefined && { data: requestBodyData }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log request info to stderr (doesn't affect MCP output)
|
||||||
|
console.error(\`Executing tool "\${toolName}": \${config.method} \${config.url}\`);
|
||||||
|
|
||||||
|
// Execute the request
|
||||||
|
const response = await axios(config);
|
||||||
|
|
||||||
|
// Process and format the response
|
||||||
|
let responseText = '';
|
||||||
|
const contentType = response.headers['content-type']?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Handle JSON responses
|
||||||
|
if (contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null) {
|
||||||
|
try {
|
||||||
|
responseText = JSON.stringify(response.data, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
responseText = "[Stringify Error]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle string responses
|
||||||
|
else if (typeof response.data === 'string') {
|
||||||
|
responseText = response.data;
|
||||||
|
}
|
||||||
|
// Handle other response types
|
||||||
|
else if (response.data !== undefined && response.data !== null) {
|
||||||
|
responseText = String(response.data);
|
||||||
|
}
|
||||||
|
// Handle empty responses
|
||||||
|
else {
|
||||||
|
responseText = \`(Status: \${response.status} - No body content)\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return formatted response
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: \`API Response (Status: \${response.status}):\\n\${responseText}\`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Handle errors during execution
|
||||||
|
let errorMessage: string;
|
||||||
|
|
||||||
|
// Format Axios errors specially
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
errorMessage = formatApiError(error);
|
||||||
|
}
|
||||||
|
// Handle standard errors
|
||||||
|
else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
// Handle unexpected error types
|
||||||
|
else {
|
||||||
|
errorMessage = 'Unexpected error: ' + String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log error to stderr
|
||||||
|
console.error(\`Error during execution of tool '\${toolName}':\`, errorMessage);
|
||||||
|
|
||||||
|
// Return error message to client
|
||||||
|
return { content: [{ type: "text", text: errorMessage }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets security scheme documentation for README
|
||||||
|
*
|
||||||
|
* @param securitySchemes Security schemes from OpenAPI spec
|
||||||
|
* @returns Documentation for security schemes
|
||||||
|
*/
|
||||||
|
export function getSecuritySchemesDocs(
|
||||||
|
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||||
|
): string {
|
||||||
|
if (!securitySchemes) return 'No security schemes defined in the OpenAPI spec.';
|
||||||
|
|
||||||
|
let docs = '';
|
||||||
|
|
||||||
|
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||||
|
if ('$ref' in schemeOrRef) {
|
||||||
|
docs += `- \`${name}\`: Referenced security scheme (reference not resolved)\n`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = schemeOrRef;
|
||||||
|
|
||||||
|
if (scheme.type === 'apiKey') {
|
||||||
|
const envVar = getEnvVarName(name, 'API_KEY');
|
||||||
|
docs += `- \`${envVar}\`: API key for ${scheme.name} (in ${scheme.in})\n`;
|
||||||
|
}
|
||||||
|
else if (scheme.type === 'http') {
|
||||||
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||||
|
const envVar = getEnvVarName(name, 'BEARER_TOKEN');
|
||||||
|
docs += `- \`${envVar}\`: Bearer token for authentication\n`;
|
||||||
|
}
|
||||||
|
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||||
|
const usernameEnvVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||||
|
const passwordEnvVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||||
|
docs += `- \`${usernameEnvVar}\`: Username for Basic authentication\n`;
|
||||||
|
docs += `- \`${passwordEnvVar}\`: Password for Basic authentication\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scheme.type === 'oauth2') {
|
||||||
|
const flowTypes = scheme.flows ? Object.keys(scheme.flows) : ['unknown'];
|
||||||
|
|
||||||
|
// Add client credentials for OAuth2
|
||||||
|
const clientIdVar = getEnvVarName(name, 'OAUTH_CLIENT_ID');
|
||||||
|
const clientSecretVar = getEnvVarName(name, 'OAUTH_CLIENT_SECRET');
|
||||||
|
docs += `- \`${clientIdVar}\`: Client ID for OAuth2 authentication (${flowTypes.join(', ')} flow)\n`;
|
||||||
|
docs += `- \`${clientSecretVar}\`: Client secret for OAuth2 authentication\n`;
|
||||||
|
|
||||||
|
// Add OAuth token for manual setting
|
||||||
|
const tokenVar = getEnvVarName(name, 'OAUTH_TOKEN');
|
||||||
|
docs += `- \`${tokenVar}\`: OAuth2 token (if not using automatic token acquisition)\n`;
|
||||||
|
|
||||||
|
// Add scopes env var
|
||||||
|
const scopesVar = getEnvVarName(name, 'OAUTH_SCOPES');
|
||||||
|
docs += `- \`${scopesVar}\`: Space-separated list of OAuth2 scopes to request\n`;
|
||||||
|
|
||||||
|
// If available, list flow-specific details
|
||||||
|
if (scheme.flows?.clientCredentials) {
|
||||||
|
docs += ` Client Credentials Flow Token URL: ${scheme.flows.clientCredentials.tokenUrl}\n`;
|
||||||
|
|
||||||
|
// List available scopes if defined
|
||||||
|
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
|
||||||
|
docs += ` Available scopes:\n`;
|
||||||
|
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
|
||||||
|
docs += ` - \`${scope}\`: ${description}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scheme.type === 'openIdConnect') {
|
||||||
|
const tokenVar = getEnvVarName(name, 'OPENID_TOKEN');
|
||||||
|
docs += `- \`${tokenVar}\`: OpenID Connect token\n`;
|
||||||
|
if (scheme.openIdConnectUrl) {
|
||||||
|
docs += ` OpenID Connect Discovery URL: ${scheme.openIdConnectUrl}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
101
src/utils/url.ts
Normal file
101
src/utils/url.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* URL handling utilities for OpenAPI to MCP generator
|
||||||
|
*/
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the base URL from the OpenAPI document or CLI options
|
||||||
|
*
|
||||||
|
* @param api OpenAPI document
|
||||||
|
* @param cmdLineBaseUrl Optional base URL from command line options
|
||||||
|
* @returns The determined base URL or null if none is available
|
||||||
|
*/
|
||||||
|
export function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null {
|
||||||
|
// Command line option takes precedence
|
||||||
|
if (cmdLineBaseUrl) {
|
||||||
|
return normalizeUrl(cmdLineBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single server in OpenAPI spec
|
||||||
|
if (api.servers && api.servers.length === 1 && api.servers[0].url) {
|
||||||
|
return normalizeUrl(api.servers[0].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple servers - use first one with warning
|
||||||
|
if (api.servers && api.servers.length > 1) {
|
||||||
|
console.warn(`Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
|
||||||
|
return normalizeUrl(api.servers[0].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No server information available
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a URL by removing trailing slashes
|
||||||
|
*
|
||||||
|
* @param url URL to normalize
|
||||||
|
* @returns Normalized URL
|
||||||
|
*/
|
||||||
|
export function normalizeUrl(url: string): string {
|
||||||
|
return url.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins URL segments handling slashes correctly
|
||||||
|
*
|
||||||
|
* @param baseUrl Base URL
|
||||||
|
* @param path Path to append
|
||||||
|
* @returns Joined URL
|
||||||
|
*/
|
||||||
|
export function joinUrl(baseUrl: string, path: string): string {
|
||||||
|
if (!baseUrl) return path;
|
||||||
|
if (!path) return baseUrl;
|
||||||
|
|
||||||
|
const normalizedBase = normalizeUrl(baseUrl);
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a URL with query parameters
|
||||||
|
*
|
||||||
|
* @param baseUrl Base URL
|
||||||
|
* @param queryParams Query parameters
|
||||||
|
* @returns URL with query parameters
|
||||||
|
*/
|
||||||
|
export function buildUrlWithQuery(baseUrl: string, queryParams: Record<string, any>): string {
|
||||||
|
if (!Object.keys(queryParams).length) return baseUrl;
|
||||||
|
|
||||||
|
const url = new URL(baseUrl.startsWith('http') ? baseUrl : `http://localhost${baseUrl.startsWith('/') ? '' : '/'}${baseUrl}`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(item => url.searchParams.append(key, String(item)));
|
||||||
|
} else {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove http://localhost if we added it
|
||||||
|
return baseUrl.startsWith('http') ? url.toString() : url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts path parameters from a URL template
|
||||||
|
*
|
||||||
|
* @param urlTemplate URL template with {param} placeholders
|
||||||
|
* @returns Array of parameter names
|
||||||
|
*/
|
||||||
|
export function extractPathParams(urlTemplate: string): string[] {
|
||||||
|
const paramRegex = /{([^}]+)}/g;
|
||||||
|
const params: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = paramRegex.exec(urlTemplate)) !== null) {
|
||||||
|
params.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
@ -1,15 +1,18 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "Node16",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "./build",
|
"esModuleInterop": true,
|
||||||
"rootDir": "./src",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"skipLibCheck": true,
|
||||||
"skipLibCheck": true,
|
"declaration": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"sourceMap": true,
|
||||||
},
|
"outDir": "./dist",
|
||||||
"include": ["src/**/*"],
|
"rootDir": "./src",
|
||||||
"exclude": ["node_modules"]
|
"resolveJsonModule": true
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user