UNPKG

@tiflux/mcp

Version:

TiFlux MCP Server - Model Context Protocol integration for Claude Code and other AI clients

611 lines (539 loc) 21.8 kB
#!/usr/bin/env node /** * TiFlux MCP Server - Versão Final com Arquitetura Limpa * * Servidor MCP completo com 4 camadas bem definidas: * - Core Layer: Container DI, Config, Logger, Error handling * - Infrastructure Layer: HTTP Client, Cache, Retry policies * - Domain Layer: Services, Repositories, Validators, Mappers * - Presentation Layer: Handlers, Middleware pipeline, Response formatters * * Características: * - Clean Architecture com separação clara de responsabilidades * - Container DI para todas as dependências * - Pipeline de middleware robusto * - Formatação consistente de respostas * - Health checks e métricas completas * - 100% de compatibilidade com MCP Protocol * - Configuração específica por ambiente */ const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); const { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } = require('@modelcontextprotocol/sdk/types.js'); // Core imports const Container = require('./core/Container'); const Config = require('./core/Config'); const Logger = require('./core/Logger'); // Bootstrap imports const InfrastructureBootstrap = require('./infrastructure/InfrastructureBootstrap'); const DomainBootstrap = require('./domain/DomainBootstrap'); const PresentationBootstrap = require('./presentation/PresentationBootstrap'); class TiFluxMCPServer { constructor() { this.server = new Server( { name: 'tiflux-mcp', version: '1.0.0' }, { capabilities: { tools: {} } } ); this.container = null; this.logger = null; this.presentationOrchestrator = null; this.healthChecker = null; this.isInitialized = false; } /** * Inicializa o servidor completo */ async initialize() { try { console.log('🚀 Starting TiFlux MCP Server initialization...'); // 1. Setup do Container DI this.container = new Container(); console.log('✅ Container DI created'); // 2. Configuração const config = new Config(); await config.load(); this.container.registerInstance('config', config); console.log('✅ Configuration loaded'); // 3. Logger const logger = new Logger(config.get('logging', {})); this.container.registerInstance('logger', logger); this.logger = logger; this.logger.info('TiFlux MCP Server starting', { version: '1.0.0', environment: config.get('environment', 'development'), node_version: process.version }); // 4. Bootstrap das camadas this.logger.info('Bootstrapping infrastructure layer...'); InfrastructureBootstrap.register(this.container); InfrastructureBootstrap.registerEnvironmentConfig(this.container); this.logger.info('Bootstrapping domain layer...'); DomainBootstrap.register(this.container); DomainBootstrap.registerEnvironmentConfig(this.container); this.logger.info('Bootstrapping presentation layer...'); PresentationBootstrap.register(this.container); PresentationBootstrap.registerEnvironmentConfig(this.container); // 5. Resolve componentes principais this.presentationOrchestrator = this.container.resolve('presentationOrchestrator'); // Health checker agregado const self = this; this.healthChecker = { async checkHealth() { const results = { server: { status: 'healthy', timestamp: new Date().toISOString() }, layers: {} }; // Infrastructure health try { const infraHealthChecker = self.container.resolve('infrastructureHealthChecker'); results.layers.infrastructure = await infraHealthChecker.checkHealth(); } catch (error) { results.layers.infrastructure = { status: 'error', error: error.message }; } // Domain health try { const domainHealthChecker = self.container.resolve('domainHealthChecker'); results.layers.domain = await domainHealthChecker.checkHealth(); } catch (error) { results.layers.domain = { status: 'error', error: error.message }; } // Presentation health try { const presentationHealthChecker = self.container.resolve('presentationHealthChecker'); results.layers.presentation = await presentationHealthChecker.checkHealth(); } catch (error) { results.layers.presentation = { status: 'error', error: error.message }; } return results; } }; // 6. Registra handlers MCP this._registerMCPHandlers(); // 7. Teste de inicialização await this._performInitializationTest(); this.isInitialized = true; this.logger.info('TiFlux MCP Server initialized successfully', { total_services: this.container.list().length, layers: ['core', 'infrastructure', 'domain', 'presentation'], operations: await this._getAvailableOperations() }); console.log('🎉 TiFlux MCP Server initialized successfully!'); } catch (error) { const errorMessage = `Failed to initialize TiFlux MCP Server: ${error.message}`; if (this.logger) { this.logger.error('Server initialization failed', { error: error.message, stack: error.stack }); } else { console.error('❌', errorMessage); console.error(error.stack); } throw new Error(errorMessage); } } /** * Registra handlers MCP padrão */ _registerMCPHandlers() { // List tools handler this.server.setRequestHandler(ListToolsRequestSchema, async () => { try { const tools = [ // Ticket operations { name: 'get_ticket', description: 'Buscar um ticket específico no TiFlux pelo ID', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket a ser buscado' } }, required: ['ticket_number'] } }, { name: 'create_ticket', description: 'Criar um novo ticket no TiFlux', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Título do ticket' }, description: { type: 'string', description: 'Descrição do ticket' }, client_id: { type: 'number', description: 'ID do cliente (opcional se client_name fornecido)' }, client_name: { type: 'string', description: 'Nome do cliente para busca automática' }, desk_id: { type: 'number', description: 'ID da mesa (opcional)' }, desk_name: { type: 'string', description: 'Nome da mesa para busca automática' }, priority_id: { type: 'number', description: 'ID da prioridade (opcional)' }, services_catalogs_item_id: { type: 'number', description: 'ID do item de catálogo (opcional)' }, responsible_id: { type: 'number', description: 'ID do responsável (opcional)' }, status_id: { type: 'number', description: 'ID do status (opcional)' }, requestor_name: { type: 'string', description: 'Nome do solicitante (opcional)' }, requestor_email: { type: 'string', description: 'Email do solicitante (opcional)' }, requestor_telephone: { type: 'string', description: 'Telefone do solicitante (opcional)' }, followers: { type: 'string', description: 'Emails dos seguidores separados por vírgula (opcional)' } }, required: ['title', 'description'] } }, { name: 'update_ticket', description: 'Atualizar um ticket existente no TiFlux', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket a ser atualizado' }, title: { type: 'string', description: 'Novo título do ticket (opcional)' }, description: { type: 'string', description: 'Nova descrição do ticket (opcional)' }, client_id: { type: 'number', description: 'Novo ID do cliente (opcional)' }, desk_id: { type: 'number', description: 'Novo ID da mesa (opcional)' }, responsible_id: { type: 'number', description: 'ID do responsável (opcional)' }, stage_id: { type: 'number', description: 'ID do estágio/fase do ticket (opcional)' }, followers: { type: 'string', description: 'Emails dos seguidores separados por vírgula (opcional)' } }, required: ['ticket_number'] } }, { name: 'list_tickets', description: 'Listar tickets do TiFlux com filtros', inputSchema: { type: 'object', properties: { desk_ids: { type: 'string', description: 'IDs das mesas separados por vírgula (máximo 15)' }, desk_name: { type: 'string', description: 'Nome da mesa para busca automática' }, client_ids: { type: 'string', description: 'IDs dos clientes separados por vírgula (máximo 15)' }, stage_ids: { type: 'string', description: 'IDs dos estágios separados por vírgula (máximo 15)' }, stage_name: { type: 'string', description: 'Nome do estágio para busca automática' }, responsible_ids: { type: 'string', description: 'IDs dos responsáveis separados por vírgula (máximo 15)' }, is_closed: { type: 'boolean', description: 'Filtrar tickets fechados (padrão: false - apenas abertos)' }, limit: { type: 'number', description: 'Número de tickets por página (padrão: 20, máximo: 200)' }, offset: { type: 'number', description: 'Número da página (padrão: 1)' } }, required: [] } }, { name: 'close_ticket', description: 'Fechar um ticket específico no TiFlux', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket a ser fechado (ex: "37", "123")' } }, required: ['ticket_number'] } }, { name: 'get_ticket_files', description: 'Buscar os arquivos anexados a um ticket específico no TiFlux', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket para buscar os arquivos (ex: "85218")' } }, required: ['ticket_number'] } }, { name: 'create_ticket_answer', description: 'Criar uma nova resposta (comunicação com cliente) em um ticket específico', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket onde será criada a resposta' }, text: { type: 'string', description: 'Conteúdo da resposta que será enviada ao cliente' }, with_signature: { type: 'boolean', description: 'Incluir assinatura do usuário na resposta (padrão: false)' }, files: { type: 'array', description: 'Lista com os caminhos dos arquivos a serem anexados (opcional, máximo 10 arquivos de 25MB cada)', items: { type: 'string' } } }, required: ['ticket_number', 'text'] } }, // Client operations { name: 'search_client', description: 'Buscar clientes no TiFlux por nome', inputSchema: { type: 'object', properties: { client_name: { type: 'string', description: 'Nome do cliente a ser buscado (busca parcial)' } }, required: ['client_name'] } }, // Communication operations { name: 'create_internal_communication', description: 'Criar uma nova comunicação interna em um ticket específico', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket onde será criada a comunicação interna' }, text: { type: 'string', description: 'Conteúdo da comunicação interna' }, files: { type: 'array', description: 'Lista com os caminhos dos arquivos a serem anexados (opcional, máximo 10 arquivos de 25MB cada)', items: { type: 'string' } } }, required: ['ticket_number', 'text'] } }, { name: 'list_internal_communications', description: 'Listar comunicações internas existentes em um ticket específico', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket para listar as comunicações internas' }, limit: { type: 'number', description: 'Número de comunicações por página (padrão: 20, máximo: 200)' }, offset: { type: 'number', description: 'Número da página a ser retornada (padrão: 1)' } }, required: ['ticket_number'] } }, { name: 'get_internal_communication', description: 'Obter uma comunicação interna específica com texto completo', inputSchema: { type: 'object', properties: { ticket_number: { type: 'string', description: 'Número do ticket da comunicação interna' }, communication_id: { type: 'string', description: 'ID da comunicação interna a ser obtida' } }, required: ['ticket_number', 'communication_id'] } } ]; this.logger.debug('Listed MCP tools', { toolCount: tools.length }); return { tools }; } catch (error) { this.logger.error('Failed to list tools', { error: error.message }); throw new McpError(ErrorCode.InternalError, `Failed to list tools: ${error.message}`); } }); // Call tool handler this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name: operation, arguments: args } = request.params; try { this.logger.info('MCP tool called', { operation, hasArgs: !!args, requestId: this._generateRequestId() }); // Verifica se servidor foi inicializado if (!this.isInitialized) { throw new McpError(ErrorCode.InternalError, 'Server not fully initialized'); } // Valida operação const availableOperations = await this._getAvailableOperations(); if (!availableOperations.includes(operation)) { throw new McpError(ErrorCode.InvalidRequest, `Unknown operation: ${operation}`); } // Executa via presentation orchestrator const result = await this.presentationOrchestrator.executeHandler(operation, args, { requestId: this._generateRequestId(), timestamp: new Date().toISOString() }); this.logger.info('MCP tool executed successfully', { operation, hasResult: !!result, hasContent: !!(result && result.content) }); return result; } catch (error) { this.logger.error('MCP tool execution failed', { operation, error: error.message, stack: error.stack }); // Se é um McpError, re-throw if (error instanceof McpError) { throw error; } // Senão, converte para McpError const errorCode = this._getErrorCode(error); throw new McpError(errorCode, error.message); } }); this.logger.info('MCP handlers registered successfully'); } /** * Realiza teste básico de inicialização */ async _performInitializationTest() { this.logger.info('Performing initialization test...'); try { // Testa health checks const health = await this.healthChecker.checkHealth(); const healthyLayers = Object.values(health.layers).filter(layer => !layer.error && !layer.status !== 'error' ).length; if (healthyLayers < 3) { throw new Error(`Only ${healthyLayers}/3 layers are healthy`); } // Testa presentation orchestrator const stats = await this.presentationOrchestrator.getStats(); if (!stats.handlers || Object.keys(stats.handlers).length < 3) { throw new Error('Insufficient handlers registered'); } this.logger.info('Initialization test passed', { healthyLayers, totalHandlers: Object.keys(stats.handlers).length, totalOperations: stats.operations ? stats.operations.length : 0 }); } catch (error) { this.logger.error('Initialization test failed', { error: error.message }); throw error; } } /** * Inicia o servidor */ async start() { try { // Inicializa se necessário if (!this.isInitialized) { await this.initialize(); } // Conecta transport const transport = new StdioServerTransport(); await this.server.connect(transport); this.logger.info('TiFlux MCP Server started successfully', { transport: 'stdio', pid: process.pid }); console.log('🎉 TiFlux MCP Server is running!'); } catch (error) { const errorMessage = `Failed to start TiFlux MCP Server: ${error.message}`; if (this.logger) { this.logger.error('Server start failed', { error: error.message, stack: error.stack }); } else { console.error('❌', errorMessage); } process.exit(1); } } /** * Para o servidor gracefully */ async stop() { try { this.logger.info('Stopping TiFlux MCP Server...'); if (this.server) { await this.server.close(); } // Limpa recursos do container if (this.container) { // Fecha conexões, limpa caches, etc. try { const cacheManager = this.container.resolve('cacheManager'); cacheManager.clear(); } catch (error) { this.logger.warn('Failed to clear cache on shutdown', { error: error.message }); } } this.logger.info('TiFlux MCP Server stopped successfully'); console.log('👋 TiFlux MCP Server stopped'); } catch (error) { this.logger.error('Error stopping server', { error: error.message }); throw error; } } /** * Obtém operações disponíveis */ async _getAvailableOperations() { try { const stats = await this.presentationOrchestrator.getStats(); return stats.operations || []; } catch (error) { this.logger.warn('Failed to get available operations', { error: error.message }); return [ 'get_ticket', 'create_ticket', 'update_ticket', 'list_tickets', 'close_ticket', 'get_ticket_files', 'create_ticket_answer', 'search_client', 'create_internal_communication', 'list_internal_communications', 'get_internal_communication' ]; } } /** * Gera ID único para request */ _generateRequestId() { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Converte erro para código MCP apropriado */ _getErrorCode(error) { const errorName = error.constructor.name; switch (errorName) { case 'ValidationError': return ErrorCode.InvalidRequest; case 'NotFoundError': return ErrorCode.InvalidRequest; case 'TimeoutError': return ErrorCode.InternalError; case 'NetworkError': return ErrorCode.InternalError; case 'APIError': return ErrorCode.InternalError; case 'RateLimitError': return ErrorCode.InvalidRequest; default: return ErrorCode.InternalError; } } } // ============ MAIN EXECUTION ============ // Handle graceful shutdown process.on('SIGINT', async () => { console.log('\n🛑 Received SIGINT, shutting down gracefully...'); if (server) { await server.stop(); } process.exit(0); }); process.on('SIGTERM', async () => { console.log('\n🛑 Received SIGTERM, shutting down gracefully...'); if (server) { await server.stop(); } process.exit(0); }); // Handle uncaught errors process.on('uncaughtException', (error) => { console.error('❌ Uncaught Exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); // Start server const server = new TiFluxMCPServer(); // Inicia o servidor server.start().catch((error) => { console.error('❌ Fatal error starting server:', error); process.exit(1); }); module.exports = TiFluxMCPServer;