UNPKG

@agentdesk/workflows-mcp

Version:

MCP workflow orchestration tool with presets for thinking, coding and more

456 lines (401 loc) 13.3 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { promptFunctions } from "./prompts.js"; import { loadConfigSync, loadPresetConfigs, listAvailablePresets, mergeConfigs, validateToolConfig, convertParametersToJsonSchema, convertParametersToZodSchema, } from "./config.js"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import fs from "fs"; // Define TypeScript interfaces for returned types interface CommandLineArgs { configPath?: string; presets: string[]; } /** * Gets the package version from package.json * @returns {Object} The parsed package.json content */ function getPackageInfo(): Record<string, any> { try { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, "..", "package.json"); // Check if file exists before reading if (!fs.existsSync(packageJsonPath)) { console.error(`Package.json not found at ${packageJsonPath}`); return { version: "0.1.7" }; // Fallback to hardcoded version } try { return JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); } catch (parseError) { console.error(`Error parsing package.json: ${parseError}`); return { version: "0.1.7" }; // Fallback to hardcoded version } } catch (error) { console.error(`Error accessing package info: ${error}`); return { version: "0.1.7" }; // Fallback to hardcoded version } } /** * Parses command line arguments to extract config path and presets * @returns {Object} Object containing configPath and presets */ function parseCommandLineArgs(): CommandLineArgs { const args = process.argv.slice(2); let configPath: string | undefined; let presets: string[] = []; // Initialize with empty array let hasPreset = false; for (let i = 0; i < args.length; i++) { if (args[i] === "--config" && i + 1 < args.length) { configPath = args[i + 1]; } else if (args[i] === "--preset" && i + 1 < args.length) { // Split comma-separated presets presets = args[i + 1].split(","); hasPreset = true; } } // Only default to thinking preset if no preset specified AND no config path provided if (presets.length === 0 && !configPath) { presets = ["thinking"]; } return { configPath, presets }; } /** * Loads and merges configuration from presets and user config * @param presets - Array of preset names to load * @param configPath - Optional path to user config */ function loadAndMergeConfig( presets: string[], configPath?: string ): Record<string, any> { // Log available presets const availablePresets = listAvailablePresets(); console.error(`Available presets: ${availablePresets.join(", ")}`); console.error(`Using presets: ${presets.join(", ")}`); // 1. Load preset configs const presetConfig = loadPresetConfigs(presets); console.error( `Loaded ${Object.keys(presetConfig).length} tools from presets` ); // 2. Load user configs from .workflows directory if provided const userConfig = configPath ? loadConfigSync(configPath) : {}; if (configPath) { console.error( `Loaded ${ Object.keys(userConfig).length } tool configurations from user config directory: ${configPath}` ); } // 3. Merge configs (user config overrides preset config) const finalConfig = mergeConfigs(presetConfig, userConfig); console.error( `Final configuration contains ${Object.keys(finalConfig).length} tools` ); return finalConfig; } /** * Creates and configures the MCP server with tools from the provided configuration * @param config - The tool configuration object * @param version - Server version */ function createMcpServer( config: Record<string, any>, version: string ): McpServer { // Create an MCP server const server = new McpServer( { name: "DevTools MCP", version: version, }, { capabilities: { tools: {}, }, } ); // Register all tools from the config registerToolsFromConfig(server, config); // If no tools were registered, add a dummy tool if ( Object.keys(config).filter((key) => !config[key]?.disabled).length === 0 ) { console.error("No tools registered, adding placeholder tool"); server.tool( "placeholder", "This is a placeholder tool when no other tools are loaded", async () => { return { content: [ { type: "text", text: "No tools are currently configured", }, ], }; } ); } return server; } /** * Registers tools to the MCP server based on the provided configuration * @param server - The MCP server instance * @param config - The tool configuration object */ function registerToolsFromConfig( server: McpServer, config: Record<string, any> ): void { // Function to add a tool if it's not disabled const addTool = ( name: string, description: string, inputSchema: any | undefined, callback: (params?: Record<string, any>) => Promise<any> ) => { server.tool( name, description, inputSchema, async (params: Record<string, any>) => { try { // Log parameters for debugging console.error(`Tool ${name} called with params:`, params); // Validate tool configuration const validationError = validateToolConfig(config, name); if (validationError) { return { content: [{ type: "text", text: `Error: ${validationError}` }], isError: true, }; } // Pass the params to the callback return callback(params); } catch (error) { // Improved error handling const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error executing tool ${name}:`, errorMessage); return { content: [ { type: "text", text: `Error executing tool: ${errorMessage}`, }, ], isError: true, }; } } ); }; // Dynamically register all tools from the config Object.entries(config).forEach(([key, toolConfig]: [string, any]) => { // Skip if toolConfig is undefined or disabled if (!toolConfig || toolConfig.disabled) { return; } // Find corresponding prompt function if available const promptFunction = promptFunctions[key]; // Use custom name if provided, otherwise use the key const toolName = toolConfig.name || key; // Convert parameters to Zod schema if provided let inputSchema = undefined; if ( toolConfig.parameters && Object.keys(toolConfig.parameters).length > 0 ) { // Use Zod schema instead of JSON Schema for better MCP SDK compatibility inputSchema = convertParametersToZodSchema(toolConfig.parameters); // Log the input schema for debugging console.error( `Tool ${toolName} input schema:`, JSON.stringify(Object.keys(inputSchema), null, 2) ); } addTool( toolName, toolConfig.description || `${key.replace(/_/g, " ")} tool`, inputSchema, async (params?: Record<string, any>) => { // Generate the tool prompt let text = generateToolPrompt(key, toolConfig, promptFunction, config); // If we have parameters, add them to the response if (params && Object.keys(params).length > 0) { text += `\n\nParameters: ${JSON.stringify(params, null, 2)}`; } return { content: [{ type: "text", text }] }; } ); }); } /** * Generates the prompt text for a tool based on its configuration * @param key - The tool key * @param toolConfig - The tool configuration * @param promptFunction - Optional prompt generation function * @param fullConfig - The complete configuration object */ function generateToolPrompt( key: string, toolConfig: Record<string, any>, promptFunction: ((config: Record<string, any>) => string) | undefined, fullConfig: Record<string, any> ): string { // Use the prompt function if available if (promptFunction) { return promptFunction(fullConfig); } // Otherwise use the prompt directly from config if (toolConfig.prompt) { let text = toolConfig.prompt; // Add context if provided if (toolConfig.context) { text += `\n\n${toolConfig.context}`; } // Add tools section if provided if (toolConfig.tools && toolConfig.tools.length > 0) { text += "\n\n## Available Tools\n"; if (toolConfig.toolMode === "sequential") { text += "If all required user input/feedback is acquired or if no input/feedback is needed, execute this exact sequence of tools to complete this task:\n\n"; toolConfig.tools.forEach((tool: any, index: number) => { text += `${index + 1}. **${tool.name}**`; if (tool.description) { text += `: ${tool.description}`; } text += "\n"; }); } else { // Default to dynamic mode text += `Use these tools as needed to complete the user's request:\n\n`; toolConfig.tools.forEach((tool: any) => { text += `- **${tool.name}**`; if (tool.description) { text += `: ${tool.description}`; } text += "\n"; }); } } return text; } // No prompt available console.error(`Tool "${key}" has no prompt defined`); return `# ${key}\n\nNo prompt defined for this tool.`; } /** * Starts the MCP server with the specified transport * @param server - The configured MCP server * @param presets - Array of preset names used * @param configPath - Optional path to user config */ async function startServer( server: McpServer, presets: string[], configPath?: string ): Promise<void> { const transport = new StdioServerTransport(); // Critical: Make sure stdin/stdout are in raw mode // This prevents the shell from interpreting JSON as commands if (process.stdin.isTTY) { process.stdin.setRawMode(true); } // Ensure stdio is properly set up to handle binary data process.stdin.setEncoding("utf8"); // Completely replace stdout.write to ensure strict JSON-RPC handling const originalStdoutWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: any, encoding?: any, callback?: any) => { try { // Only process valid JSON objects if (typeof chunk === "string" && chunk.trim().startsWith("{")) { // Validate JSON format before sending JSON.parse(chunk.trim()); return originalStdoutWrite(chunk, encoding, callback); } // Log non-JSON to stderr if (typeof chunk === "string" && chunk.trim()) { console.error( `[stdout filtered]: ${chunk.substring(0, 100)}${ chunk.length > 100 ? "..." : "" }` ); } // Indicate success without writing anything if (callback) callback(); return true; } catch (e) { // Log JSON parsing errors to stderr console.error( `[JSON error]: ${e instanceof Error ? e.message : String(e)}` ); if (callback) callback(); return true; } }; try { // Connect to the MCP server await server.connect(transport); // Log successful connection console.error( `DevTools MCP server running with presets: ${presets.join(", ")}${ configPath ? ` and user config from: ${configPath}` : "" }` ); // Keep the process alive process.on("SIGINT", () => { console.error("MCP server shutting down..."); process.exit(0); }); } catch (err) { console.error("Error starting server:", err); process.exit(1); } } // Main execution (async function main() { try { // Use hardcoded version to avoid package.json issues with npx const version = "0.1.7"; // Match the current package version // Parse command line arguments const { configPath, presets } = parseCommandLineArgs(); // Log startup information console.error( `Starting MCP server v${version} with presets: ${presets.join(", ")}` ); // Load and merge configuration - wrap in try/catch for better error handling let finalConfig; try { finalConfig = loadAndMergeConfig(presets, configPath); } catch (error) { console.error("Error loading configuration:", error); process.exit(1); } // Create and configure the MCP server const server = createMcpServer(finalConfig, version); // Start the server await startServer(server, presets, configPath); // Keep process alive for incoming connections process.stdin.on("end", () => { console.error("Input stream ended, shutting down..."); process.exit(0); }); process.on("uncaughtException", (err) => { console.error("Uncaught exception:", err); process.exit(1); }); } catch (err) { console.error("Fatal error:", err); process.exit(1); } })();