Skip to main content
Glama

McFlow

manager.ts19.9 kB
import fs from 'fs/promises'; import path from 'path'; import { detectWorkflowStructure, WorkflowStructure } from './finder.js'; import { WorkflowDocumenter } from './documenter.js'; import { WorkflowInitializer } from './initializer.js'; import { NodeValidator } from '../nodes/validator.js'; import { WorkflowFormatter } from './formatter.js'; import { ChangeTracker } from '../utils/change-tracker.js'; import { stringifyWorkflowFile } from '../utils/json-formatter.js'; export { NodeValidator } from '../nodes/validator.js'; export class WorkflowManager { private structure: WorkflowStructure; private workflowsPath: string; private documenter: WorkflowDocumenter; private initializer: WorkflowInitializer; private validator: NodeValidator; private formatter: WorkflowFormatter; private changeTracker: ChangeTracker; constructor(workflowsPath: string) { this.workflowsPath = workflowsPath; this.structure = detectWorkflowStructure(); this.documenter = new WorkflowDocumenter(workflowsPath); this.initializer = new WorkflowInitializer(workflowsPath); this.validator = new NodeValidator(workflowsPath); this.formatter = new WorkflowFormatter(); this.changeTracker = new ChangeTracker(workflowsPath); this.changeTracker.initialize().catch(console.error); // Create instructions template if needed this.documenter.createInstructionsTemplate().catch(console.error); } /** * Get the node validator instance */ getValidator(): NodeValidator { return this.validator; } /** * Generates a succinct, unique workflow name */ private async generateUniqueName(baseName: string, targetPath: string): Promise<string> { // Clean up the base name - remove redundant words and make succinct let cleanName = baseName .toLowerCase() .replace(/workflow/gi, '') // Remove 'workflow' as it's redundant .replace(/[\s_]+/g, '-') // Replace spaces/underscores with hyphens .replace(/--+/g, '-') // Remove duplicate hyphens .replace(/^-|-$/g, '') // Remove leading/trailing hyphens .slice(0, 30); // Keep names reasonably short // If name is empty after cleaning, use a default if (!cleanName) { cleanName = 'flow'; } // Check if file exists and generate unique name if needed let finalName = cleanName; let counter = 1; while (true) { try { const filePath = path.join(targetPath, `${finalName}.json`); await fs.access(filePath); // File exists, try with a number suffix finalName = `${cleanName}-${counter}`; counter++; } catch { // File doesn't exist, we can use this name break; } } return finalName; } /** * Creates a new workflow with intelligent path handling * For simple structure: creates in ./workflows/ * For multi-project: creates in ./project/workflows/ (only if project specified) */ async createWorkflow(name: string, workflow: any, project?: string): Promise<any> { try { // Enforce dash naming convention if (name.includes('_')) { name = name.replace(/_/g, '-'); console.log(`📝 Converting underscores to dashes in filename: ${name}`); } // Initialize workflows structure if needed (first time) const wasInitialized = await this.initializer.initialize(); let targetPath: string; let relativePath: string; // Determine where to create the workflow based on structure if (this.structure.type === 'simple' || !project) { // Simple structure: use ./flows/ (workflowsPath already points to workflows dir) targetPath = path.join(this.workflowsPath, 'flows'); relativePath = `flows/${name}.json`; } else if (this.structure.type === 'multi-project' && project) { // Multi-project structure with project specified targetPath = path.join(this.workflowsPath, project, 'workflows'); relativePath = `${project}/workflows/${name}.json`; } else { // Unknown structure: default to simple with flows targetPath = path.join(this.workflowsPath, 'flows'); relativePath = `flows/${name}.json`; } // Create directory if it doesn't exist await fs.mkdir(targetPath, { recursive: true }); // Generate a succinct, unique name const finalName = await this.generateUniqueName(name, targetPath); // Update the workflow's internal name to match if (workflow.name) { workflow.name = finalName; } // Write temporary file for validation in /tmp const tempPath = `/tmp/mcflow_validate_${Date.now()}_${finalName}.json`; await fs.writeFile(tempPath, stringifyWorkflowFile(workflow)); // Validate and auto-fix if needed const validationResult = await this.validator.validateWorkflow(tempPath); if (!validationResult.valid) { // Try to auto-fix const fixResult = await this.validator.autoFixWorkflow(tempPath); if (fixResult.fixed) { // Re-read the fixed workflow const fixedContent = await fs.readFile(tempPath, 'utf-8'); workflow = JSON.parse(fixedContent); } } // Write the final workflow file with proper formatting const filePath = path.join(targetPath, `${finalName}.json`); await fs.writeFile(filePath, stringifyWorkflowFile(workflow)); // Remove temp file try { await fs.unlink(tempPath); } catch {} // Update the relative path with the final name if (this.structure.type === 'simple' || !project) { relativePath = `workflows/flows/${finalName}.json`; } else if (project) { relativePath = `${project}/workflows/${finalName}.json`; } // Update documentation try { const customInstructions = await this.documenter.getCustomInstructions(); await this.documenter.updateWorkflowDocumentation(finalName, workflow, 'create', customInstructions); // Extract and update credentials const credentials = this.initializer.extractCredentialsFromWorkflow(workflow); if (credentials.size > 0) { await this.initializer.updateEnvExample(credentials); } // Update README with workflow list const allWorkflows = await this.getWorkflowList(); await this.initializer.updateReadmeWorkflowList(allWorkflows); } catch (docError) { console.error('Failed to update documentation:', docError); // Don't fail the workflow creation if documentation fails } return { content: [ { type: 'text', text: `✅ Workflow Created Successfully!\n\n` + `📁 File: ${relativePath}\n` + `📝 Name: ${finalName}\n` + `${project ? `📂 Project: ${project}\n` : ''}` + `\n` + `The workflow has been saved and documented.`, }, ], }; } catch (error) { throw new Error(`Failed to create workflow: ${error}`); } } /** * Get workflow list for internal use */ private async getWorkflowList(): Promise<Array<{name: string, description?: string}>> { const workflows: Array<{name: string, description?: string}> = []; // workflowsPath already points to the workflows folder const possibleFlowsPaths = [ path.join(this.workflowsPath, 'flows'), ]; for (const flowsDir of possibleFlowsPaths) { try { const files = await fs.readdir(flowsDir); for (const file of files) { if (file.endsWith('.json') && !file.includes('package.json') && !file.includes('workflow_package.json')) { try { const content = await fs.readFile(path.join(flowsDir, file), 'utf-8'); const workflow = JSON.parse(content); workflows.push({ name: file.replace('.json', ''), description: workflow.description || workflow.name }); } catch { workflows.push({ name: file.replace('.json', ''), description: undefined }); } } } return workflows; // Return if we found workflows } catch { // Try next path } } return workflows; } /** * Lists workflows based on structure type */ async listWorkflows(project?: string): Promise<any> { const workflows: Array<{ path: string; name: string; project?: string }> = []; try { if (this.structure.type === 'simple') { // Simple structure: look in ./workflows/flows/ // Try multiple possible paths const possibleFlowsPaths = [ path.join(this.workflowsPath, 'flows'), path.join(this.workflowsPath, 'flows'), ]; let foundWorkflows = false; for (const flowsDir of possibleFlowsPaths) { try { const files = await fs.readdir(flowsDir); for (const file of files) { if (file.endsWith('.json') && !file.includes('package.json') && !file.includes('workflow_package.json')) { workflows.push({ path: `workflows/flows/${file}`, name: file.replace('.json', ''), }); foundWorkflows = true; } } if (foundWorkflows) break; } catch { // Try next path } } if (!foundWorkflows) { console.error('No flows directory found. It will be created when you add your first workflow.'); } } else if (this.structure.type === 'multi-project') { // Multi-project structure if (project) { // List workflows for specific project const projectPath = path.join(this.workflowsPath, project, 'workflows'); try { const files = await fs.readdir(projectPath); for (const file of files) { if (file.endsWith('.json')) { workflows.push({ path: `${project}/workflows/${file}`, name: file.replace('.json', ''), project, }); } } } catch {} } else if (this.structure.projects) { // List all workflows from all projects for (const proj of this.structure.projects) { const workflowsDir = path.join(this.workflowsPath, proj, 'workflows'); try { const files = await fs.readdir(workflowsDir); for (const file of files) { if (file.endsWith('.json')) { workflows.push({ path: `${proj}/workflows/${file}`, name: file.replace('.json', ''), project: proj, }); } } } catch {} } } } else { // Unknown structure: try common locations const possiblePaths = [ path.join(this.workflowsPath, 'flows'), // New structure this.workflowsPath, // Already the workflows folder this.workflowsPath, // Root ]; for (const searchPath of possiblePaths) { try { const files = await fs.readdir(searchPath); for (const file of files) { if (file.endsWith('.json')) { const relativePath = path.relative(this.workflowsPath, path.join(searchPath, file)); workflows.push({ path: relativePath, name: file.replace('.json', ''), }); } } break; // Stop after finding workflows in one location } catch {} } } // Format the output nicely let output = ''; if (workflows.length === 0) { output = '📭 No workflows found.\n\nCreate your first workflow to get started!'; } else { output = `📋 Found ${workflows.length} workflow${workflows.length > 1 ? 's' : ''}:\n\n`; // Group by project if multi-project if (this.structure.type === 'multi-project') { const grouped = workflows.reduce((acc, w) => { const proj = w.project || 'common'; if (!acc[proj]) acc[proj] = []; acc[proj].push(w); return acc; }, {} as Record<string, typeof workflows>); for (const [proj, flows] of Object.entries(grouped)) { output += `📂 ${proj}/\n`; for (const flow of flows) { output += ` • ${flow.name}\n`; } output += '\n'; } } else { // Simple list for simple structure for (const workflow of workflows) { output += `• ${workflow.name}\n`; } } } return { content: [ { type: 'text', text: output, }, ], }; } catch (error) { throw new Error(`Failed to list workflows: ${error}`); } } /** * Reads a workflow file with formatted output */ async readWorkflow(workflowPath: string, options?: { format?: boolean; raw?: boolean }): Promise<any> { try { const fullPath = path.join(this.workflowsPath, workflowPath); const content = await fs.readFile(fullPath, 'utf-8'); const workflow = JSON.parse(content); // Return raw JSON if requested if (options?.raw) { return { content: [ { type: 'text', text: content, }, ], }; } // Format the workflow for better readability const formatted = this.formatter.formatWorkflow(workflow, { colorize: true, indent: 2, compact: false, showNodeDetails: true }); return { content: [ { type: 'text', text: formatted, }, ], }; } catch (error) { throw new Error(`Failed to read workflow: ${error}`); } } /** * Updates an existing workflow */ async updateWorkflow(workflowPath: string, workflow: any): Promise<any> { try { const fullPath = path.join(this.workflowsPath, workflowPath); // Try to preserve the existing workflow ID if it exists try { const existingContent = await fs.readFile(fullPath, 'utf-8'); const existingWorkflow = JSON.parse(existingContent); // Preserve important metadata from existing workflow if (existingWorkflow.id && !workflow.id) { workflow.id = existingWorkflow.id; console.error(`Preserving workflow ID: ${workflow.id}`); } if (existingWorkflow.createdAt && !workflow.createdAt) { workflow.createdAt = existingWorkflow.createdAt; } // Always update the updatedAt timestamp workflow.updatedAt = new Date().toISOString(); } catch (readError) { // File might not exist yet or might be invalid JSON console.error('Could not read existing workflow, creating new'); } await fs.writeFile(fullPath, stringifyWorkflowFile(workflow)); // Mark as edited in change tracker const relativePath = path.relative(this.workflowsPath, fullPath); await this.changeTracker.markEdited(relativePath); // Update documentation const workflowName = path.basename(workflowPath, '.json'); try { const customInstructions = await this.documenter.getCustomInstructions(); await this.documenter.updateWorkflowDocumentation(workflowName, workflow, 'update', customInstructions); // Extract and update credentials const credentials = this.initializer.extractCredentialsFromWorkflow(workflow); if (credentials.size > 0) { await this.initializer.updateEnvExample(credentials); } // Update README with workflow list const allWorkflows = await this.getWorkflowList(); await this.initializer.updateReadmeWorkflowList(allWorkflows); } catch (docError) { console.error('Failed to update documentation:', docError); // Don't fail the workflow update if documentation fails } return { content: [ { type: 'text', text: `✅ Workflow Updated!\n\n` + `📁 File: ${workflowPath}\n` + `📝 Name: ${workflowName}\n\n` + `The workflow and documentation have been updated.`, }, ], }; } catch (error) { throw new Error(`Failed to update workflow: ${error}`); } } /** * Gets project information (for multi-project structure) */ async getProjectInfo(project?: string): Promise<any> { try { if (this.structure.type === 'simple') { // Simple structure: return info about the single workflows folder const info: any = { type: 'simple', workflowsPath: 'workflows/', workflows: [], }; const workflowsDir = this.workflowsPath; // Already points to workflows folder try { const files = await fs.readdir(workflowsDir); info.workflows = files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')); } catch {} // Check for README and .env try { await fs.access(path.join(this.workflowsPath, 'README.md')); info.hasReadme = true; } catch {} try { await fs.access(path.join(this.workflowsPath, '.env.example')); info.hasEnvExample = true; } catch {} return { content: [ { type: 'text', text: JSON.stringify(info, null, 2), }, ], }; } else if (this.structure.type === 'multi-project') { if (project) { // Get info for specific project const projectPath = path.join(this.workflowsPath, project); const info: any = { name: project, type: 'multi-project', workflows: [], }; const workflowsDir = path.join(projectPath, 'workflows'); try { const files = await fs.readdir(workflowsDir); info.workflows = files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')); } catch {} return { content: [ { type: 'text', text: JSON.stringify(info, null, 2), }, ], }; } else { // List all projects return { content: [ { type: 'text', text: JSON.stringify({ type: 'multi-project', projects: this.structure.projects || [], }, null, 2), }, ], }; } } return { content: [ { type: 'text', text: JSON.stringify({ type: 'unknown', message: 'Could not determine project structure', }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to get project info: ${error}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mckinleymedia/mcflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server