UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

346 lines (310 loc) 10.3 kB
/** * Check Status Tool * * MCP tool for querying async job status and progress. Provides comprehensive * job monitoring with incremental polling support, session ownership verification, * and memory-first lookup with FileCache fallback for completed jobs. */ import { createToolResponse, createToolError } from './index.js'; import { getAsyncJobStore, JOB_STATUS } from '../async/asyncJobStore.js'; import { getFileCache } from '../async/fileCache.js'; import { debugLog, debugError } from '../utils/console.js'; import { createLogger } from '../utils/logger.js'; import { formatJobStatus, formatHumanReadableStatus, formatJobListHumanReadable, formatConversationHistory, } from '../utils/formatStatus.js'; import { isSafeIdSegment } from '../utils/idValidation.js'; const logger = createLogger('check-status'); /** * Check status tool implementation * @param {object} args - Tool arguments * @param {object} dependencies - Injected dependencies (config, providers, etc.) * @returns {object} MCP tool response */ export async function checkStatusTool(args, dependencies) { const startTime = Date.now(); try { const { config, providers } = dependencies; // Extract and validate arguments const { continuation_id, full_history = false } = args; // Validate arguments if (continuation_id !== undefined) { if (typeof continuation_id !== 'string') { return createToolError('continuation_id must be a string'); } if (continuation_id.length === 0) { return createToolError( 'Invalid continuation_id: must be a non-empty string', ); } if (!isSafeIdSegment(continuation_id)) { return createToolError( 'Invalid continuation_id: contains unsafe characters', ); } } const asyncJobStore = getAsyncJobStore(); const fileCache = getFileCache(); debugLog('checkStatus', 'Processing status query', { continuation_id, full_history, }); if (continuation_id) { // Query specific job by continuation_id const jobStatus = await querySpecificJob( continuation_id, asyncJobStore, fileCache, { include_output: true, // Always include output full_history, }, ); if (!jobStatus) { return createToolError( `Job not found or access denied: ${continuation_id}`, ); } // Format content as human-readable status (now async) const content = full_history ? await formatConversationHistory(jobStatus, continuation_id, { config, providers, }) : await formatHumanReadableStatus( jobStatus, { sequence: '1/1' }, { config, providers }, ); return createToolResponse({ content, metadata: { continuation_id, execution_time: (Date.now() - startTime) / 1000, full_history, }, }); } else { // List all active/recent jobs (hardcoded to 10 latest) const jobsList = await listAllJobs(asyncJobStore, fileCache, { include_output: true, // Always include output max_results: 10, }); // Format content as human-readable jobs list (now async) const content = await formatJobListHumanReadable(jobsList, { config, providers, }); return createToolResponse({ content, metadata: { execution_time: (Date.now() - startTime) / 1000, total_jobs: jobsList.jobs.length, }, }); } } catch (error) { debugError('checkStatus', 'Tool execution failed:', error); return createToolError('Failed to check job status', error); } } /** * Query specific job by continuation_id * @param {string} continuationId - Job continuation ID * @param {object} asyncJobStore - Async job store instance * @param {object} fileCache - File cache instance * @param {object} options - Query options * @returns {object|null} Job status or null if not found */ async function querySpecificJob( continuationId, asyncJobStore, fileCache, options = {}, ) { try { // First try memory store (AsyncJobStore) const memoryJob = await asyncJobStore.get(continuationId); if (memoryJob) { return formatJobStatus(memoryJob, options); } // Fallback to FileCache for completed jobs const fileJob = await fileCache.readSnapshot(continuationId); if (fileJob) { return formatJobStatus(fileJob, options); } // Job not found debugLog('checkStatus', 'Job not found', { continuationId }); return null; } catch (error) { debugError('checkStatus', 'Error querying specific job:', error); throw error; } } /** * List all active/recent jobs * @param {object} asyncJobStore - Async job store instance * @param {object} fileCache - File cache instance * @param {object} options - Query options * @returns {object} Jobs list with summary */ async function listAllJobs(asyncJobStore, fileCache, options = {}) { try { const jobs = []; const summary = { active_jobs: 0, completed_jobs: 0, failed_jobs: 0, cancelled_jobs: 0, total_jobs: 0, }; // Get stats from AsyncJobStore to understand what's in memory const storeStats = await asyncJobStore.getStats(); debugLog('checkStatus', 'AsyncJobStore stats:', storeStats); // Get all jobs from AsyncJobStore const allJobs = await getAllJobsFromStore(asyncJobStore, options); for (const job of allJobs) { const formattedJob = formatJobStatus(job, options); jobs.push(formattedJob); // Update summary summary.total_jobs++; switch (job.status) { case JOB_STATUS.RUNNING: case JOB_STATUS.QUEUED: summary.active_jobs++; break; case JOB_STATUS.COMPLETED: summary.completed_jobs++; break; case JOB_STATUS.FAILED: summary.failed_jobs++; break; case JOB_STATUS.CANCELLED: summary.cancelled_jobs++; break; } } // If we haven't reached 10 jobs, try to get more from FileCache if (jobs.length < 10 && fileCache) { debugLog( 'checkStatus', `Have ${jobs.length} jobs from memory, checking FileCache for additional completed jobs`, ); try { // Get recent jobs from FileCache const remainingLimit = 10 - jobs.length; const cachedJobs = await fileCache.listRecentJobs({ limit: remainingLimit, daysBack: 3, }); debugLog( 'checkStatus', `Found ${cachedJobs.length} additional jobs in FileCache`, ); // Format and add cached jobs to the list for (const cachedJob of cachedJobs) { // Check if this job is already in our list (avoid duplicates) const isDuplicate = jobs.some( (j) => j.continuation_id === cachedJob.jobId, ); if (!isDuplicate) { // Format the cached job to match our standard format const formattedJob = formatJobStatus( { jobId: cachedJob.jobId, status: cachedJob.status || 'completed', tool: cachedJob.tool || 'unknown', mode: cachedJob.mode || cachedJob.tool, createdAt: cachedJob.createdAt || cachedJob.startedAt, updatedAt: cachedJob.completedAt || cachedJob.updatedAt || Date.now(), overall: { result: cachedJob.result || cachedJob, progress: 100, startedAt: cachedJob.startedAt, endedAt: cachedJob.completedAt, }, provider: cachedJob.provider, model: cachedJob.model, title: cachedJob.title, final_summary: cachedJob.final_summary, }, options, ); jobs.push(formattedJob); // Update summary summary.total_jobs++; if (cachedJob.status === 'completed') { summary.completed_jobs++; } else if (cachedJob.status === 'failed') { summary.failed_jobs++; } else if (cachedJob.status === 'cancelled') { summary.cancelled_jobs++; } if (jobs.length >= 10) break; } } } catch (error) { debugError( 'checkStatus', 'Error retrieving jobs from FileCache:', error, ); // Continue without cached jobs if there's an error } } return { jobs, summary, query_options: { include_output: true, // Always include output max_results: 10, }, timestamp: Date.now(), }; } catch (error) { debugError('checkStatus', 'Error listing session jobs:', error); throw error; } } /** * Get all jobs from AsyncJobStore * @param {object} asyncJobStore - Async job store instance * @param {object} options - Query options * @returns {Array} Array of all jobs */ async function getAllJobsFromStore(asyncJobStore, options = {}) { try { return await asyncJobStore.getAllJobs({ limit: 10, sortBy: 'updatedAt', sortOrder: 'desc', }); } catch (error) { debugError('checkStatus', 'Error getting all jobs:', error); return []; } } // Tool metadata and input schema checkStatusTool.description = 'Check the status and progress of async jobs. Query specific jobs by continuation_id or list the 10 most recent jobs. Returns job status with start time and progress information.'; checkStatusTool.inputSchema = { type: 'object', properties: { continuation_id: { type: 'string', description: 'Optional job continuation ID to query. If not provided, returns the 10 most recent jobs.', }, full_history: { type: 'boolean', default: false, description: 'When used with continuation_id, returns the full conversation history for that continuation ID. Only use when there are multiple turns and you need the full conversation.', }, }, additionalProperties: false, };