@sigyl-dev/cli
Version:
Official Sigyl CLI for installing and managing MCP packages. Zero-config installation for public packages, secure API-based authentication.
700 lines (619 loc) • 21.8 kB
text/typescript
import { writeFileSync, mkdirSync, existsSync } from "node:fs"
import { join } from "node:path"
import * as yaml from "yaml"
import { verboseLog } from "../logger"
import type { ExpressEndpoint } from "./express-scanner"
export interface MCPGenerationOptions {
appPort?: string
[key: string]: unknown
}
export class MCPGenerator {
private outDir: string
private language: "typescript" | "javascript" | "python"
constructor(outDir: string, language: "typescript" | "javascript" | "python") {
this.outDir = outDir
this.language = language
}
async generateFromEndpoints(
endpoints: ExpressEndpoint[],
options: MCPGenerationOptions = {}
): Promise<void> {
// Ensure output directory exists
if (!existsSync(this.outDir)) {
mkdirSync(this.outDir, { recursive: true })
}
// Generate MCP configuration
await this.generateMCPConfig(endpoints, options)
// Generate server code
if (this.language === "typescript") {
await this.generateTypeScriptServer(endpoints, options)
} else if (this.language === "javascript") {
await this.generateJavaScriptServer(endpoints, options)
} else {
await this.generatePythonServer(endpoints, options)
}
}
private async generateMCPConfig(
endpoints: ExpressEndpoint[],
options: MCPGenerationOptions
): Promise<void> {
// Generate minimal SigylConfig format (no env, build, or entryPoint)
const config = {
runtime: "node",
language: this.language,
startCommand: {
type: "http"
}
};
const yamlHeader = `# MCP-compatible server configuration\n# This template demonstrates all major JSON Schema features for configSchema.\n# - apiKey: Secret string field\n# - serviceName: Arbitrary string field\n# - logLevel: Enum string field\n# - timeout: Number field with min/max\n# - enableMetrics: Boolean field\n# - allowedClients: Array of strings\n# - customSettings: Object field\n# - environment: Enum for environment\n# Add/remove fields as needed for your server.\n`;
const yamlContent = yamlHeader + yaml.stringify(config, { indent: 2 });
writeFileSync(join(this.outDir, "sigyl.yaml"), yamlContent);
verboseLog("Generated sigyl.yaml configuration");
}
private async generateTypeScriptServer(
endpoints: ExpressEndpoint[],
options: MCPGenerationOptions
): Promise<void> {
const serverCode = `/**
* Auto-generated MCP Server from Express endpoints
*
* This server provides tools that map to your Express API endpoints.
* Each tool makes HTTP requests to your Express application and returns the responses.
*
* To add a new tool manually, follow the template at the bottom of this file.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { z } from "zod"
// ============================================================================
// SERVER CONFIGURATION
// ============================================================================
export default function createStatelessServer({
config,
}: {
config: any;
}) {
const server = new McpServer({
name: "generated-mcp-server",
version: "1.0.0",
});
// ============================================================================
// AUTO-GENERATED TOOLS FROM EXPRESS ENDPOINTS
// ============================================================================
// These tools were automatically generated from your Express application.
// Each tool corresponds to an endpoint in your Express app.
${endpoints.map(endpoint => {
const toolName = this.generateToolName(endpoint)
const shape = this.generateZodShapeObject(endpoint)
const description = endpoint.description || `${endpoint.method} ${endpoint.path}`
const methodLiteral = endpoint.method.toUpperCase();
const pathLiteral = endpoint.path;
return ` // ===== ${endpoint.method.toUpperCase()} ${endpoint.path} =====
server.tool(
"${toolName}",
"${description}",
${shape},
async (args) => {
// ===== REQUEST CONFIGURATION =====
/**
* IMPORTANT: This MCP tool calls your Express API at the address below.
* To change the API base URL (host/port), set the APP_BASE_URL environment variable when starting this server,
* or edit the code below. Default is http://localhost:3000
* Example: APP_BASE_URL=http://myhost:4000 node server.js
*/
const baseUrl = process.env.APP_BASE_URL || \`http://localhost:${'${config.appPort || 3000}'}\`;
const url = \`${'${baseUrl}'}${'${endpoint.path}'}\`;
const method = "${'${endpoint.method.toUpperCase()}'}";
// Build request options
const requestOptions: any = {
method,
headers: {
"Content-Type": "application/json",
},
};
// ===== PARAMETER HANDLING =====
const queryParams = new URLSearchParams();
const bodyParams: any = {};
${this.generateParameterHandling(endpoint)}
// ===== URL CONSTRUCTION =====
if (queryParams.toString()) {
requestOptions.url = url + (url.includes('?') ? '&' : '?') + queryParams.toString();
} else {
requestOptions.url = url;
}
// Add body for POST/PUT/PATCH requests
if (["POST", "PUT", "PATCH"].includes(method) && Object.keys(bodyParams).length > 0) {
requestOptions.body = JSON.stringify(bodyParams);
}
// ===== HTTP REQUEST & RESPONSE =====
try {
const response = await fetch(requestOptions.url, requestOptions);
const data = await response.json();
return data;
} catch (error) {
return {
content: [
{
type: "text",
text: \`Error calling ${endpoint.method.toUpperCase()} ${endpoint.path}: \${error instanceof Error ? error.message : String(error)}\`
}
]
};
}
}
);
`}).join('\n\n')}
// ============================================================================
// MANUAL TOOL TEMPLATE
// ============================================================================
// To add a new tool manually, use the following simple template:
/*
server.tool(
"reverseString",
"Reverse a string value",
{
value: z.string().describe("String to reverse"),
},
async ({ value }) => {
return {
content: [
{ type: "text", text: value.split("").reverse().join("") }
]
};
}
);
*/
return server.server;
}`;
writeFileSync(join(this.outDir, "server.ts"), serverCode);
verboseLog("Generated TypeScript server");
// Generate package.json for the server
const packageJson = {
name: "generated-mcp-server",
version: "1.0.0",
type: "module",
main: "server.js",
description: "Auto-generated MCP server from Express endpoints",
scripts: {
build: "tsc",
start: "node server.js"
},
dependencies: {
"@modelcontextprotocol/sdk": "^1.10.1",
"zod": "^3.22.0"
},
devDependencies: {
"typescript": "^5.0.0",
"@types/node": "^20.0.0"
}
};
writeFileSync(join(this.outDir, "package.json"), JSON.stringify(packageJson, null, 2));
verboseLog("Generated package.json");
// Always generate a valid tsconfig.json for TypeScript/ESM compatibility
const tsConfig = {
compilerOptions: {
target: "ES2020",
module: "ESNext",
moduleResolution: "node",
outDir: "./",
rootDir: "./",
strict: true,
esModuleInterop: true,
skipLibCheck: true
},
include: ["*.ts"],
exclude: ["node_modules", "*.js"]
};
writeFileSync(join(this.outDir, "tsconfig.json"), JSON.stringify(tsConfig, null, 2));
verboseLog("Generated tsconfig.json");
}
private async generateJavaScriptServer(
endpoints: ExpressEndpoint[],
options: MCPGenerationOptions
): Promise<void> {
const serverCode = `/**
* Auto-generated MCP Server from Express endpoints (JavaScript)
*
* This server provides tools that map to your Express API endpoints.
* Each tool makes HTTP requests to your Express application and returns the responses.
*
* To add a new tool manually, follow the template at the bottom of this file.
*/
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { z } = require("zod");
// ============================================================================
// SERVER CONFIGURATION
// ============================================================================
function createStatelessServer({ config }) {
const server = new McpServer({
name: "generated-mcp-server",
version: "1.0.0",
});
// ============================================================================
// AUTO-GENERATED TOOLS FROM EXPRESS ENDPOINTS
// ============================================================================
// These tools were automatically generated from your Express application.
// Each tool corresponds to an endpoint in your Express app.
${endpoints.map(endpoint => {
const toolName = this.generateToolName(endpoint)
const description = endpoint.description || `${endpoint.method} ${endpoint.path}`
return ` // ===== ${endpoint.method.toUpperCase()} ${endpoint.path} =====
server.tool(
"${toolName}",
"${description}",
{},
async (args) => {
return { content: [ { type: "text", text: "Dummy response from ${toolName}" } ] };
}
);
`}).join('\n\n')}
// ============================================================================
// MANUAL TOOL TEMPLATE
// ============================================================================
// To add a new tool manually, use the following simple template:
/*
server.tool(
"reverseString",
"Reverse a string value",
{
value: z.string().describe("String to reverse"),
},
async ({ value }) => {
return {
content: [
{ type: "text", text: value.split("").reverse().join("") }
]
};
}
);
*/
return server.server;
}`;
writeFileSync(join(this.outDir, "server.js"), serverCode);
verboseLog("Generated JavaScript server");
// Generate package.json for the server
const packageJson = {
name: "generated-mcp-server",
version: "1.0.0",
type: "module",
main: "server.js",
description: "Auto-generated MCP server from Express endpoints",
scripts: {
start: "node server.js"
},
dependencies: {
"@modelcontextprotocol/sdk": "^1.10.1",
"zod": "^3.22.0"
}
};
writeFileSync(join(this.outDir, "package.json"), JSON.stringify(packageJson, null, 2));
verboseLog("Generated package.json");
// --- PATCH: Generate tool handler files ---
const toolsDir = join(this.outDir, "tools");
if (!existsSync(toolsDir)) {
mkdirSync(toolsDir, { recursive: true });
}
for (const endpoint of endpoints) {
const toolName = this.generateToolName(endpoint);
const handlerCode = `export async function ${toolName}(args) {
// TODO: Implement actual logic for ${endpoint.method} ${endpoint.path}
return { content: [ { type: "text", text: "Dummy response from ${toolName}" } ] };
}
`;
writeFileSync(join(toolsDir, `${toolName}.js`), handlerCode);
}
}
private async generatePythonServer(
endpoints: ExpressEndpoint[],
options: MCPGenerationOptions
): Promise<void> {
// TODO: Implement Python server generation
verboseLog("Python server generation not yet implemented")
}
private generateToolName(endpoint: ExpressEndpoint): string {
// Convert path and method to camelCase tool name
// e.g., GET /api/users/:id -> getApiUsersById
// e.g., GET /api/users/advanced-search -> getApiUsersAdvancedSearch
const method = endpoint.method.toLowerCase()
const pathParts = endpoint.path
.split('/')
.filter(part => part && part !== '')
.map(part => {
// Remove colons from path parameters
if (part.startsWith(':')) {
return 'By' + part.slice(1).charAt(0).toUpperCase() + part.slice(2)
}
// Handle hyphens and other special characters
return part
.replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
})
return method + pathParts.join('')
}
private generateToolSchema(endpoint: ExpressEndpoint): Record<string, any> {
const schema: Record<string, any> = {}
// Add path and query parameters
if (endpoint.parameters) {
for (const param of endpoint.parameters) {
schema[param.name] = {
type: this.mapTypeToJSONSchema(param.type),
description: param.description || `${param.location} parameter`,
...(param.location === "path" && { required: true })
}
}
}
// Add request body for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method)) {
if (endpoint.requestBody) {
if (endpoint.requestBody.properties) {
// If we have detailed properties, create a proper object schema
schema.body = {
type: "object",
description: "Request body data",
properties: this.mapPropertiesToJSONSchema(endpoint.requestBody.properties),
...(endpoint.requestBody.required && endpoint.requestBody.required.length > 0 && {
required: endpoint.requestBody.required
})
}
} else {
// Fallback to generic object
schema.body = {
type: this.mapTypeToJSONSchema(endpoint.requestBody.type),
description: "Request body data"
}
}
} else {
schema.body = {
type: "object",
description: "Request body data"
}
}
}
return schema
}
private mapTypeToJSONSchema(type: string): string {
// Map TypeScript types to JSON Schema types
switch (type.toLowerCase()) {
case "string": return "string"
case "number": return "number"
case "boolean": return "boolean"
case "array": return "array"
case "object": return "object"
case "date": return "string"
case "any": return "object"
case "unknown": return "object"
default: return "object" // Default for custom types
}
}
private mapPropertiesToJSONSchema(properties: Record<string, any>): Record<string, any> {
const mapped: Record<string, any> = {}
for (const [key, value] of Object.entries(properties)) {
const propertySchema: any = {
type: this.mapTypeToJSONSchema(value.type),
description: value.description || `Property: ${key}`
}
// Add additional schema properties based on type
if (value.type === "string") {
// Could add format, pattern, minLength, maxLength, etc.
} else if (value.type === "number") {
// Could add minimum, maximum, etc.
} else if (value.type === "array") {
// Could add items schema
}
mapped[key] = propertySchema
}
return mapped
}
private generateToolHandler(endpoint: ExpressEndpoint, options: MCPGenerationOptions): string {
const toolName = this.generateToolName(endpoint)
const appPort = options.appPort || "3000"
if (this.language === "typescript") {
// Generate TypeScript interface for the tool arguments
const argInterface = this.generateToolArgInterface(endpoint)
// Check if we have required parameters (path params are always required)
const hasRequiredParams = endpoint.parameters?.some(p => p.required || p.location === "path") || false
const defaultValue = hasRequiredParams ? "" : " = {}"
return `// Tool handler for ${endpoint.method} ${endpoint.path}
${argInterface}
export async function ${toolName}(args: ${toolName}Args${defaultValue}): Promise<{ content: Array<{ type: string; text: string }> }> {
try {
// Construct URL for the Express endpoint
const baseUrl = "http://localhost:${appPort}"
let url = "${endpoint.path}"
// Replace path parameters
${endpoint.parameters?.filter(p => p.location === "path").map(param =>
`url = url.replace(":${param.name}", String(args.${param.name}) || "");`
).join('\n\t\t') || ''}
// Add query parameters
const queryParams = new URLSearchParams()
${endpoint.parameters?.filter(p => p.location === "query").map(param =>
`if (args.${param.name} !== undefined) queryParams.append("${param.name}", String(args.${param.name}));`
).join('\n\t\t') || ''}
if (queryParams.toString()) {
url += "?" + queryParams.toString()
}
const fullUrl = baseUrl + url
// Make HTTP request to Express app
const options: RequestInit = {
method: "${endpoint.method}",
headers: {
"Content-Type": "application/json",
},
}
// Add body for POST/PUT/PATCH requests
${['POST', 'PUT', 'PATCH'].includes(endpoint.method) ?
`if (args.body) {\n\t\t\toptions.body = JSON.stringify(args.body)\n\t\t}` :
'// No body for GET requests'
}
const response = await fetch(fullUrl, options)
const result = await response.text()
return {
content: [
{
type: "text",
text: "Request: " + options.method + " " + fullUrl + "\\nResponse: " + result
}
]
}
} catch (error) {
return {
content: [
{
type: "text",
text: \`Error calling ${endpoint.method.toUpperCase()} ${endpoint.path}: \${error instanceof Error ? error.message : String(error)}\`
}
]
}
}
}
`
} else if (this.language === "javascript") {
// Generate plain JS handler (no types)
return `// Tool handler for ${endpoint.method} ${endpoint.path}
export async function ${toolName}(args = {}) {
try {
// Construct URL for the Express endpoint
const baseUrl = "http://localhost:${appPort}"
let url = "${endpoint.path}"
// Replace path parameters
${endpoint.parameters?.filter(p => p.location === "path").map(param =>
`url = url.replace(":${param.name}", String(args.${param.name}) || "");`
).join('\n\t\t') || ''}
// Add query parameters
const queryParams = new URLSearchParams()
${endpoint.parameters?.filter(p => p.location === "query").map(param =>
`if (args.${param.name} !== undefined) queryParams.append("${param.name}", String(args.${param.name}));`
).join('\n\t\t') || ''}
if (queryParams.toString()) {
url += "?" + queryParams.toString()
}
const fullUrl = baseUrl + url
// Make HTTP request to Express app
const options = {
method: "${endpoint.method}",
headers: {
"Content-Type": "application/json",
},
}
// Add body for POST/PUT/PATCH requests
${['POST', 'PUT', 'PATCH'].includes(endpoint.method) ?
`if (args.body) {\n\t\t\toptions.body = JSON.stringify(args.body)\n\t\t}` :
'// No body for GET requests'
}
const response = await fetch(fullUrl, options)
const result = await response.text()
return {
content: [
{
type: "text",
text: "Request: " + options.method + " " + fullUrl + "\\nResponse: " + result
}
]
}
} catch (error) {
return {
content: [
{
type: "text",
text: \`Error calling ${endpoint.method.toUpperCase()} ${endpoint.path}: \${error instanceof Error ? error.message : String(error)}\`
}
]
}
}
}
`
}
return ""
}
private generateToolArgInterface(endpoint: ExpressEndpoint): string {
const toolName = this.generateToolName(endpoint)
const interfaceName = `${toolName}Args`
let properties: string[] = []
// Add path and query parameters
if (endpoint.parameters) {
for (const param of endpoint.parameters) {
const optional = param.required ? "" : "?"
properties.push(`\t${param.name}${optional}: ${this.mapTypeToTypeScript(param.type)}`)
}
}
// Add body parameter for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method)) {
properties.push(`\tbody?: any`)
}
if (properties.length === 0) {
return `interface ${interfaceName} {}`
}
return `interface ${interfaceName} {
${properties.join('\n')}
}`
}
private mapTypeToTypeScript(type: string): string {
switch (type.toLowerCase()) {
case "string": return "string"
case "number": return "number"
case "boolean": return "boolean"
case "array": return "any[]"
case "object": return "any"
case "date": return "string"
default: return "any"
}
}
private generateZodShapeObject(endpoint: ExpressEndpoint): string {
const properties: string[] = []
// Add path and query parameters
if (endpoint.parameters) {
for (const param of endpoint.parameters) {
const zodType = this.mapTypeToZod(param.type)
const description = param.description || `${param.location} parameter: ${param.name}`
const optional = param.required ? "" : ".optional()"
properties.push(`\t\t${param.name}: z.${zodType}()${optional}.describe("${description}")`)
}
}
// Add body parameter for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method)) {
properties.push(`\t\tbody: z.any().optional().describe("Request body data")`)
}
if (properties.length === 0) {
return "{}"
}
return `{
${properties.join(',\n')}
\t}`
}
private mapTypeToZod(type: string): string {
switch (type.toLowerCase()) {
case "string": return "string"
case "number": return "number"
case "boolean": return "boolean"
case "array": return "array"
case "object": return "object"
default: return "any"
}
}
private generateParameterHandling(endpoint: ExpressEndpoint): string {
let code = ""
if (endpoint.parameters) {
for (const param of endpoint.parameters) {
if (param.location === "path") {
code += `\t\t\trequestOptions.url = requestOptions.url.replace(":${param.name}", String(args.${param.name}) || "");\n`
} else if (param.location === "query") {
code += `\t\t\tif (args.${param.name} !== undefined) {\n`
code += `\t\t\t\tqueryParams.set("${param.name}", String(args.${param.name}));\n`
code += `\t\t\t}\n`
} else if (param.location === "body") {
code += `\t\t\tif (args.${param.name} !== undefined) {\n`
code += `\t\t\t\tbodyParams.${param.name} = args.${param.name};\n`
code += `\t\t\t}\n`
}
}
}
// Handle body parameters for POST/PUT/PATCH
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method)) {
code += `\t\t\tif (args.body !== undefined) {\n`
code += `\t\t\t\tObject.assign(bodyParams, args.body);\n`
code += `\t\t\t}\n`
}
return code
}
}