/** * get_cron_job_history - Gets execution history for pg_cron jobs. * * Requires the pg_cron extension to be installed. * Shows recent job runs with status and timing information. */ import { z } from 'zod'; import { handleSqlResponse, executeSqlWithFallback } from './utils.js'; import type { ToolContext, ToolPrivilegeLevel } from './types.js'; const CronJobRunSchema = z.object({ jobid: z.number(), jobname: z.string().nullable(), schedule: z.string(), runid: z.number().nullable(), status: z.string().nullable(), start_time: z.string().nullable(), end_time: z.string().nullable(), return_message: z.string().nullable(), }); const GetCronJobHistoryOutputSchema = z.array(CronJobRunSchema); const GetCronJobHistoryInputSchema = z.object({ jobid: z.number().optional().describe('Filter by specific job ID.'), jobname: z.string().optional().describe('Filter by job name pattern.'), limit: z.number().optional().default(100).describe('Maximum number of records to return.'), status: z.enum(['succeeded', 'failed']).optional().describe('Filter by execution status.'), }); type GetCronJobHistoryInput = z.infer; const mcpInputSchema = { type: 'object', properties: { jobid: { type: 'number', description: 'Filter by specific job ID.', }, jobname: { type: 'string', description: 'Filter by job name pattern.', }, limit: { type: 'number', description: 'Maximum number of records to return.', default: 100, }, status: { type: 'string', enum: ['succeeded', 'failed'], description: 'Filter by execution status.', }, }, required: [], }; // Safe pattern for job name search - alphanumeric, underscore, hyphen, space const jobNamePattern = /^[a-zA-Z0-9_\-\s]+$/; export const getCronJobHistoryTool = { name: 'get_cron_job_history', description: 'Gets execution history for pg_cron jobs including status and timing. Requires pg_cron to be installed.', privilegeLevel: 'regular' as ToolPrivilegeLevel, inputSchema: GetCronJobHistoryInputSchema, mcpInputSchema: mcpInputSchema, outputSchema: GetCronJobHistoryOutputSchema, execute: async (input: GetCronJobHistoryInput, context: ToolContext) => { const client = context.selfhostedClient; const { jobid, jobname, limit = 100, status } = input; // Validate jobname if provided if (jobname && !jobNamePattern.test(jobname)) { throw new Error(`Invalid job name pattern: ${jobname}. Use only alphanumeric, underscore, hyphen, and space characters.`); } // First check if pg_cron extension is installed const checkExtensionSql = ` SELECT EXISTS ( SELECT 1 FROM pg_extension WHERE extname = 'pg_cron' ) as installed; `; const extensionCheck = await executeSqlWithFallback(client, checkExtensionSql, true); if (!Array.isArray(extensionCheck) || extensionCheck.length === 0) { throw new Error('Failed to check pg_cron extension status.'); } if (!extensionCheck[0].installed) { throw new Error('pg_cron extension is not installed. Install it with: CREATE EXTENSION pg_cron;'); } // Build query with filters const conditions: string[] = []; if (jobid !== undefined) { // jobid is a number from Zod validation, safe to use directly conditions.push(`j.jobid = ${jobid}`); } if (jobname) { conditions.push(`j.jobname ILIKE '%${jobname}%'`); } if (status) { // status is validated by Zod enum, safe to use directly conditions.push(`r.status = '${status}'`); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Ensure limit is within bounds const safeLimit = Math.min(Math.max(1, limit), 1000); const historySql = ` SELECT j.jobid, j.jobname, j.schedule, r.runid, r.status, r.start_time::text, r.end_time::text, r.return_message FROM cron.job j LEFT JOIN cron.job_run_details r ON j.jobid = r.jobid ${whereClause} ORDER BY r.start_time DESC NULLS LAST LIMIT ${safeLimit} `; const result = await executeSqlWithFallback(client, historySql, true); return handleSqlResponse(result, GetCronJobHistoryOutputSchema); }, };