suitecrm-mcp-server
Version:
Model Context Protocol server for SuiteCRM integration with natural language SQL reporting
314 lines • 11.6 kB
JavaScript
/**
* 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
;