UNPKG

suitecrm-mcp-server

Version:

Model Context Protocol server for SuiteCRM integration with natural language SQL reporting

314 lines 11.6 kB
"use strict"; /** * SQL query execution service for SuiteCRM */ Object.defineProperty(exports, "__esModule", { value: true }); exports.QueryService = void 0; const zod_1 = require("zod"); const types_1 = require("../types"); const http_1 = require("../utils/http"); const logger_1 = require("../utils/logger"); // Validation schemas const QueryRequestSchema = zod_1.z.object({ sql: zod_1.z.string().min(1, 'SQL query is required'), parameters: zod_1.z.record(zod_1.z.any()).optional(), limit: zod_1.z.number().positive().max(1000).optional(), offset: zod_1.z.number().nonnegative().optional() }); function safeJsonParse(data) { if (typeof data === 'string') { try { return JSON.parse(data); } catch { return data; } } return data; } class QueryService { httpClient; maxQueryRows; maxQueryLength = 10000; // 10KB constructor(baseUrl, maxQueryRows = 100, timeout = 30000) { this.httpClient = new http_1.HttpClient({ baseURL: baseUrl, timeout }); this.maxQueryRows = maxQueryRows; } /** * Execute SQL query against SuiteCRM */ async executeQuery(accessToken, request) { const startTime = Date.now(); try { // Validate input const validatedRequest = QueryRequestSchema.parse(request); // Transform validated request to remove undefined optional properties const cleanRequest = { sql: validatedRequest.sql }; if (validatedRequest.limit) { cleanRequest.limit = validatedRequest.limit; } if (validatedRequest.parameters) { cleanRequest.parameters = validatedRequest.parameters; } if (validatedRequest.offset) { cleanRequest.offset = validatedRequest.offset; } // Security check const securityCheck = this.validateQuerySecurity(cleanRequest.sql); if (securityCheck.blocked) { throw new types_1.QueryError(`Query blocked for security reasons: ${securityCheck.reason}`); } if (securityCheck.warnings.length > 0) { logger_1.logger.warn('Query security warnings', { warnings: securityCheck.warnings, sql: this.maskSensitiveData(cleanRequest.sql) }); } // Apply limits const limitedQuery = this.applyQueryLimits(cleanRequest); // Transform to remove undefined optional properties const queryRequest = { sql: limitedQuery.sql }; if (limitedQuery.limit) { queryRequest.limit = limitedQuery.limit; } if (limitedQuery.parameters) { queryRequest.parameters = limitedQuery.parameters; } if (limitedQuery.offset) { queryRequest.offset = limitedQuery.offset; } logger_1.logger.info('Executing SQL query', { queryLength: queryRequest.sql.length, hasParameters: !!queryRequest.parameters && Object.keys(queryRequest.parameters).length > 0 }); this.httpClient.setAuthToken(accessToken); const response = await this.httpClient.post('/Api/V8/custom/modules/query', { sql: queryRequest.sql, parameters: queryRequest.parameters || {}, limit: queryRequest.limit || this.maxQueryRows, offset: queryRequest.offset || 0 }); if (response.status !== 200) { throw new types_1.QueryError(`Query execution failed: ${response.status} ${response.statusText}`); } // Handle SuiteCRM's actual response structure const crm = response.data; const data = safeJsonParse(crm.data); const rows = data?.data || []; const meta = data?.meta || {}; const metadata = { query_time: typeof meta.execution_time === 'number' ? Math.round(meta.execution_time * 1000) : 0, // ms rows_returned: meta.row_count || rows.length, columns: (meta.fields || []).map((name) => ({ name, type: 'string', // Could be improved if type info is available label: name })) }; const queryResponse = { success: crm.success, data: rows, metadata, }; if (crm.errors) { queryResponse.error = JSON.stringify(crm.errors); } if (crm.message && !crm.success) { queryResponse.error = crm.message; } const duration = Date.now() - startTime; logger_1.logger.logQuery(queryRequest.sql, duration, queryResponse.metadata.rows_returned); return queryResponse; } catch (error) { const duration = Date.now() - startTime; logger_1.logger.logQueryError(request.sql, error instanceof Error ? error : new Error(String(error)), duration); if (error instanceof types_1.QueryError || error instanceof types_1.ValidationError) { throw error; } throw new types_1.QueryError(`Query execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Execute a simple SELECT query */ async executeSelect(accessToken, sql, limit) { return this.executeQuery(accessToken, { sql: this.ensureSelectQuery(sql), limit: limit || this.maxQueryRows }); } /** * Execute a count query */ async executeCount(accessToken, sql) { const countSql = this.convertToCountQuery(sql); const response = await this.executeQuery(accessToken, { sql: countSql }); if (response.data.length > 0 && response.data[0]) { const firstRow = response.data[0]; const countKey = Object.keys(firstRow).find(key => key.toLowerCase().includes('count') || key.toLowerCase().includes('total')); return countKey ? Number(firstRow[countKey]) : 0; } return 0; } /** * Validate query security */ validateQuerySecurity(sql) { const upperSql = sql.toUpperCase(); const warnings = []; let blocked = false; let reason; // Check for dangerous operations const dangerousKeywords = [ 'DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT', 'UPDATE', 'GRANT', 'REVOKE' ]; for (const keyword of dangerousKeywords) { if (upperSql.includes(keyword)) { if (keyword === 'DELETE' && !upperSql.includes('WHERE')) { blocked = true; reason = 'DELETE without WHERE clause is not allowed'; break; } if (['DROP', 'TRUNCATE', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'].includes(keyword)) { blocked = true; reason = `${keyword} operations are not allowed`; break; } warnings.push(`Query contains ${keyword} operation`); } } // Check for potential SQL injection patterns const injectionPatterns = [ /;\s*$/, // Trailing semicolon /--\s*$/, // SQL comments /\/\*.*\*\//, // Multi-line comments /UNION\s+ALL/i, // UNION ALL /UNION\s+SELECT/i, // UNION SELECT ]; for (const pattern of injectionPatterns) { if (pattern.test(sql)) { warnings.push('Query contains potentially unsafe patterns'); break; } } // Check query length if (sql.length > this.maxQueryLength) { blocked = true; reason = `Query too long (${sql.length} characters, max ${this.maxQueryLength})`; } // Check for multiple statements const statementCount = (sql.match(/;/g) || []).length; if (statementCount > 1) { blocked = true; reason = 'Multiple SQL statements not allowed'; } return { isSafe: !blocked && warnings.length === 0, warnings, blocked, ...(reason && { reason }) }; } /** * Apply query limits and safety measures */ applyQueryLimits(request) { let sql = request.sql.trim(); // Remove trailing semicolon for easier manipulation let hadSemicolon = false; if (sql.endsWith(';')) { sql = sql.slice(0, -1).trim(); hadSemicolon = true; } // Only add LIMIT if: // - Not already present // - Not a COUNT query // - Not an aggregate-only query const upperSql = sql.toUpperCase(); const isCountQuery = /^SELECT\s+COUNT\s*\(/.test(upperSql); if (!isCountQuery && !/LIMIT\s+\d+/i.test(upperSql)) { const limit = request.limit || this.maxQueryRows; sql += ` LIMIT ${limit}`; } // Restore semicolon if it was present if (hadSemicolon) { sql += ';'; } const result = { sql }; if (request.limit) result.limit = request.limit; if (request.parameters) result.parameters = request.parameters; if (request.offset) result.offset = request.offset; return result; } /** * Ensure query is a SELECT query */ ensureSelectQuery(sql) { const upperSql = sql.trim().toUpperCase(); if (!upperSql.startsWith('SELECT')) { throw new types_1.QueryError('Only SELECT queries are allowed'); } return sql; } /** * Convert query to COUNT query */ convertToCountQuery(sql) { const upperSql = sql.toUpperCase(); // If already a COUNT query, return as is if (upperSql.includes('COUNT(')) { return sql; } // Extract FROM clause const fromMatch = sql.match(/FROM\s+(\w+)/i); if (!fromMatch) { throw new types_1.QueryError('Invalid query: no FROM clause found'); } const tableName = fromMatch[1]; return `SELECT COUNT(*) as total_count FROM ${tableName}`; } /** * Mask sensitive data in SQL for logging */ maskSensitiveData(sql) { return sql .replace(/password\s*=\s*['"][^'"]*['"]/gi, 'password=***') .replace(/token\s*=\s*['"][^'"]*['"]/gi, 'token=***') .replace(/secret\s*=\s*['"][^'"]*['"]/gi, 'secret=***'); } /** * Get query statistics */ getQueryStats() { return { maxRows: this.maxQueryRows, maxLength: this.maxQueryLength }; } /** * Set maximum query rows */ setMaxQueryRows(maxRows) { if (maxRows > 0 && maxRows <= 10000) { this.maxQueryRows = maxRows; logger_1.logger.info('Updated max query rows', { maxRows }); } else { throw new types_1.ValidationError('Max query rows must be between 1 and 10000'); } } } exports.QueryService = QueryService; //# sourceMappingURL=query.js.map