UNPKG

@channel.io/be-user-chat-mcp

Version:

Backend UserChat Model Context Protocol server for Channel.io

158 lines 7.56 kB
import axios from "axios"; /** * Executes a specific Redash query and returns the result rows. * * @template T The expected type of a single row in the result set. * @param queryId The ID of the Redash query to execute. * @param parameters Optional parameters for the Redash query (strings, numbers, booleans, null). * @param context RequestContext containing Redash credentials. * @returns An array of result rows, typed as T[]. * @throws If the API call fails or the context is not provided. */ export async function executeRedashQuery(queryId, parameters, context) { const apiUrl = `${context.redashUrl}/api/queries/${queryId}/results`; const headers = { Authorization: `Key ${context.redashApiKey}`, "Content-Type": "application/json", }; try { const response = await axios.post(apiUrl, { parameters: parameters, max_age: 0 }, { headers }); if (response.data.job) { console.warn(`Redash returned a job ID (${response.data.job.id}). Polling for results...`); const resultData = await pollRedashJob(response.data.job.id, context); // Assuming the rows match the expected type T. // Add runtime validation (e.g., with Zod) if necessary for robustness. return resultData.rows; } else if (response.data.query_result) { return response.data.query_result.data.rows; } else { throw new Error("Unexpected response format from Redash API (no job or query_result)"); } } catch (error) { handleRedashError(error, `query ${queryId}`); } } /** * Executes a raw SQL query directly via the Redash API on a specific data source. * WARNING: SQL INJECTION RISK! Parameters must be safely handled *before* calling this function. * * @template T The expected type of a single row in the result set. * @param dataSourceId The ID of the Redash Data Source to run the query against. * @param sqlQuery The raw SQL query string to execute. * @param context Optional context string for error logging. * @param requestContext RequestContext containing Redash credentials. * @returns An array of result rows, typed as T[]. * @throws If the API call fails or the context is not provided. */ export async function executeRawRedashQuery(dataSourceId, sqlQuery, context = "raw query", requestContext) { if (typeof dataSourceId !== "number" || isNaN(dataSourceId)) { throw new Error(`Invalid dataSourceId provided: ${dataSourceId}. Must be a number.`); } // Basic check for obviously unsafe query patterns (example only, not exhaustive) if (sqlQuery.match(/;|--|\/\*|\*\//)) { console.error(`Potential unsafe characters detected in raw SQL query (context: ${context}): ${sqlQuery}`); // Depending on policy, might throw an error here // throw new Error("Unsafe characters detected in raw SQL query."); } const apiUrl = `${requestContext.redashUrl}/api/query_results`; const headers = { Authorization: `Key ${requestContext.redashApiKey}`, "Content-Type": "application/json", }; try { const response = await axios.post(apiUrl, { data_source_id: dataSourceId, query: sqlQuery, max_age: 0, }, { headers }); if (response.data.job) { console.warn(`Redash returned a job ID (${response.data.job.id}) for raw query. Polling for results...`); const resultData = await pollRedashJob(response.data.job.id, requestContext); return resultData.rows; } else if (response.data.query_result) { console.warn(`Redash returned direct results for raw query (unexpected).`); return response.data.query_result.data.rows; } else { throw new Error("Unexpected response format from Redash API (no job or query_result)"); } } catch (error) { handleRedashError(error, context); } } // Helper function for polling Redash job status async function pollRedashJob(jobId, requestContext) { const jobUrl = `${requestContext.redashUrl}/api/jobs/${jobId}`; const headers = { Authorization: `Key ${requestContext.redashApiKey}` }; const maxAttempts = 10; const initialDelayMs = 1000; for (let attempts = 0; attempts < maxAttempts; attempts++) { // Use slightly increasing delay await new Promise((resolve) => setTimeout(resolve, initialDelayMs * (attempts + 1))); try { const jobResponse = await axios.get(jobUrl, { headers }); const job = jobResponse.data.job; if (job.status === 3 && job.query_result_id) { return await getQueryResult(job.query_result_id, requestContext); } else if (job.status === 4) { throw new Error(`Redash job ${jobId} failed: ${job.error || "Unknown error"}`); } else if (job.status > 4) { throw new Error(`Redash job ${jobId} terminated unexpectedly with status ${job.status}`); } // Else: status 1 or 2, continue polling } catch (error) { console.error(`Error polling Redash job ${jobId} (attempt ${attempts + 1}/${maxAttempts}):`, error); // Re-throw immediately if it's a job failure/termination error if (error instanceof Error && (error.message.includes("failed:") || error.message.includes("terminated"))) { handleRedashError(error, `job poll ${jobId}`); } // If it's the last attempt and still failing, let handleRedashError throw if (attempts === maxAttempts - 1) { handleRedashError(error, `job poll ${jobId} - max attempts reached`); } // Otherwise, log and continue polling loop } } // Should not be reached if handleRedashError throws correctly on last attempt throw new Error(`Redash job ${jobId} polling timed out after ${maxAttempts} attempts.`); } // Helper function to get query results using query_result_id async function getQueryResult(queryResultId, requestContext) { const resultUrl = `${requestContext.redashUrl}/api/query_results/${queryResultId}`; const headers = { Authorization: `Key ${requestContext.redashApiKey}` }; try { const response = await axios.get(resultUrl, { headers }); if (!response.data?.query_result?.data) { // Use optional chaining throw new Error("Invalid query result format from Redash API"); } return response.data.query_result.data; } catch (error) { handleRedashError(error, `query result ${queryResultId}`); } } // Centralized error handling for Redash API calls function handleRedashError(error, context) { console.error(`Error during Redash operation (context: ${context}):`, error); let message = `Redash operation failed (context: ${context}).`; if (axios.isAxiosError(error)) { const status = error.response?.status || "N/A"; const responseData = error.response?.data; message = `Redash API request failed (context: ${context}, status: ${status}): ${responseData ? JSON.stringify(responseData) : "No response data"}`; } else if (error instanceof Error) { message = `Redash operation failed (context: ${context}): ${error.message}`; } throw new Error(message); // Always throw } //# sourceMappingURL=redash.service.js.map