feat(cache): Implement caching for listTasks MCP endpoint
Implemented LRU caching for the function to improve performance for repeated requests. Key changes include: - Added dependency. - Introduced a reusable utility function in leveraging a . - Refactored in to use the caching utility with a key based on task path, filter, and subtask flag. - Modified to include the boolean flag in the final JSON response structure, nesting the original data under a key. - Added function and corresponding MCP tool () for monitoring cache performance. - Improved error handling in for cases where is not found. This addresses the previous issue of the empty task list likely caused by stale cache entries and provides clear visibility into whether a response is served from the cache. Relates to #23.9
This commit is contained in:
85
mcp-server/src/core/__tests__/context-manager.test.js
Normal file
85
mcp-server/src/core/__tests__/context-manager.test.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { ContextManager } from '../context-manager.js';
|
||||
|
||||
describe('ContextManager', () => {
|
||||
let contextManager;
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager({
|
||||
maxCacheSize: 10,
|
||||
ttl: 1000, // 1 second for testing
|
||||
maxContextSize: 1000
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContext', () => {
|
||||
it('should create a new context when not in cache', async () => {
|
||||
const context = await contextManager.getContext('test-id', { test: true });
|
||||
expect(context.id).toBe('test-id');
|
||||
expect(context.metadata.test).toBe(true);
|
||||
expect(contextManager.stats.misses).toBe(1);
|
||||
expect(contextManager.stats.hits).toBe(0);
|
||||
});
|
||||
|
||||
it('should return cached context when available', async () => {
|
||||
// First call creates the context
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
|
||||
// Second call should hit cache
|
||||
const context = await contextManager.getContext('test-id', { test: true });
|
||||
expect(context.id).toBe('test-id');
|
||||
expect(context.metadata.test).toBe(true);
|
||||
expect(contextManager.stats.hits).toBe(1);
|
||||
expect(contextManager.stats.misses).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect TTL settings', async () => {
|
||||
// Create context
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
// Should create new context
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
expect(contextManager.stats.misses).toBe(2);
|
||||
expect(contextManager.stats.hits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateContext', () => {
|
||||
it('should update existing context metadata', async () => {
|
||||
await contextManager.getContext('test-id', { initial: true });
|
||||
const updated = await contextManager.updateContext('test-id', { updated: true });
|
||||
|
||||
expect(updated.metadata.initial).toBe(true);
|
||||
expect(updated.metadata.updated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateContext', () => {
|
||||
it('should remove context from cache', async () => {
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
contextManager.invalidateContext('test-id', { test: true });
|
||||
|
||||
// Should be a cache miss
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
expect(contextManager.stats.invalidations).toBe(1);
|
||||
expect(contextManager.stats.misses).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return current cache statistics', async () => {
|
||||
await contextManager.getContext('test-id', { test: true });
|
||||
const stats = contextManager.getStats();
|
||||
|
||||
expect(stats.hits).toBe(0);
|
||||
expect(stats.misses).toBe(1);
|
||||
expect(stats.invalidations).toBe(0);
|
||||
expect(stats.size).toBe(1);
|
||||
expect(stats.maxSize).toBe(10);
|
||||
expect(stats.ttl).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
mcp-server/src/core/context-manager.js
Normal file
170
mcp-server/src/core/context-manager.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* context-manager.js
|
||||
* Context and cache management for Task Master MCP Server
|
||||
*/
|
||||
|
||||
import { FastMCP } from 'fastmcp';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Configuration options for the ContextManager
|
||||
* @typedef {Object} ContextManagerConfig
|
||||
* @property {number} maxCacheSize - Maximum number of items in the cache
|
||||
* @property {number} ttl - Time to live for cached items in milliseconds
|
||||
* @property {number} maxContextSize - Maximum size of context window in tokens
|
||||
*/
|
||||
|
||||
export class ContextManager {
|
||||
/**
|
||||
* Create a new ContextManager instance
|
||||
* @param {ContextManagerConfig} config - Configuration options
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
maxCacheSize: config.maxCacheSize || 1000,
|
||||
ttl: config.ttl || 1000 * 60 * 5, // 5 minutes default
|
||||
maxContextSize: config.maxContextSize || 4000
|
||||
};
|
||||
|
||||
// Initialize LRU cache for context data
|
||||
this.cache = new LRUCache({
|
||||
max: this.config.maxCacheSize,
|
||||
ttl: this.config.ttl,
|
||||
updateAgeOnGet: true
|
||||
});
|
||||
|
||||
// Cache statistics
|
||||
this.stats = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
invalidations: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new context or retrieve from cache
|
||||
* @param {string} contextId - Unique identifier for the context
|
||||
* @param {Object} metadata - Additional metadata for the context
|
||||
* @returns {Object} Context object with metadata
|
||||
*/
|
||||
async getContext(contextId, metadata = {}) {
|
||||
const cacheKey = this._getCacheKey(contextId, metadata);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
this.stats.hits++;
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.stats.misses++;
|
||||
|
||||
// Create new context if not in cache
|
||||
const context = {
|
||||
id: contextId,
|
||||
metadata: {
|
||||
...metadata,
|
||||
created: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the new context
|
||||
this.cache.set(cacheKey, context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing context
|
||||
* @param {string} contextId - Context identifier
|
||||
* @param {Object} updates - Updates to apply to the context
|
||||
* @returns {Object} Updated context
|
||||
*/
|
||||
async updateContext(contextId, updates) {
|
||||
const context = await this.getContext(contextId);
|
||||
|
||||
// Apply updates to context
|
||||
Object.assign(context.metadata, updates);
|
||||
|
||||
// Update cache
|
||||
const cacheKey = this._getCacheKey(contextId, context.metadata);
|
||||
this.cache.set(cacheKey, context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a context in the cache
|
||||
* @param {string} contextId - Context identifier
|
||||
* @param {Object} metadata - Metadata used in the cache key
|
||||
*/
|
||||
invalidateContext(contextId, metadata = {}) {
|
||||
const cacheKey = this._getCacheKey(contextId, metadata);
|
||||
this.cache.delete(cacheKey);
|
||||
this.stats.invalidations++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data associated with a specific key.
|
||||
* Increments cache hit stats if found.
|
||||
* @param {string} key - The cache key.
|
||||
* @returns {any | undefined} The cached data or undefined if not found/expired.
|
||||
*/
|
||||
getCachedData(key) {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached !== undefined) { // Check for undefined specifically, as null/false might be valid cached values
|
||||
this.stats.hits++;
|
||||
return cached;
|
||||
}
|
||||
this.stats.misses++;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data in the cache with a specific key.
|
||||
* @param {string} key - The cache key.
|
||||
* @param {any} data - The data to cache.
|
||||
*/
|
||||
setCachedData(key, data) {
|
||||
this.cache.set(key, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific cache key.
|
||||
* Increments invalidation stats.
|
||||
* @param {string} key - The cache key to invalidate.
|
||||
*/
|
||||
invalidateCacheKey(key) {
|
||||
this.cache.delete(key);
|
||||
this.stats.invalidations++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} Cache statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
hits: this.stats.hits,
|
||||
misses: this.stats.misses,
|
||||
invalidations: this.stats.invalidations,
|
||||
size: this.cache.size,
|
||||
maxSize: this.config.maxCacheSize,
|
||||
ttl: this.config.ttl
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from context ID and metadata
|
||||
* @private
|
||||
* @deprecated No longer used for direct cache key generation outside the manager.
|
||||
* Prefer generating specific keys in calling functions.
|
||||
*/
|
||||
_getCacheKey(contextId, metadata) {
|
||||
// Kept for potential backward compatibility or internal use if needed later.
|
||||
return `${contextId}:${JSON.stringify(metadata)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance with default config
|
||||
export const contextManager = new ContextManager();
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
// We'll import more functions as we continue implementation
|
||||
} from '../../../scripts/modules/task-manager.js';
|
||||
|
||||
// Import context manager
|
||||
import { contextManager } from './context-manager.js';
|
||||
import { getCachedOrExecute } from '../tools/utils.js'; // Import the utility here
|
||||
|
||||
/**
|
||||
* Finds the absolute path to the tasks.json file based on project root and arguments.
|
||||
* @param {Object} args - Command arguments, potentially including 'projectRoot' and 'file'.
|
||||
@@ -58,52 +62,95 @@ function findTasksJsonPath(args, log) {
|
||||
}
|
||||
|
||||
// If no file was found, throw an error
|
||||
throw new Error(`Tasks file not found in any of the expected locations relative to ${projectRoot}: ${possiblePaths.join(', ')}`);
|
||||
const error = new Error(`Tasks file not found in any of the expected locations relative to ${projectRoot}: ${possiblePaths.join(', ')}`);
|
||||
error.code = 'TASKS_FILE_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct function wrapper for listTasks with error handling
|
||||
*
|
||||
* @param {Object} args - Command arguments (projectRoot is expected to be resolved)
|
||||
* @param {Object} log - Logger object
|
||||
* @returns {Object} - Task list result
|
||||
* Direct function wrapper for listTasks with error handling and caching.
|
||||
*
|
||||
* @param {Object} args - Command arguments (projectRoot is expected to be resolved).
|
||||
* @param {Object} log - Logger object.
|
||||
* @returns {Promise<Object>} - Task list result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean }.
|
||||
*/
|
||||
export async function listTasksDirect(args, log) {
|
||||
let tasksPath;
|
||||
try {
|
||||
log.info(`Listing tasks with args: ${JSON.stringify(args)}`);
|
||||
|
||||
// Use the helper function to find the tasks file path
|
||||
const tasksPath = findTasksJsonPath(args, log);
|
||||
|
||||
// Extract other arguments
|
||||
const statusFilter = args.status || null;
|
||||
const withSubtasks = args.withSubtasks || false;
|
||||
|
||||
log.info(`Using tasks file at: ${tasksPath}`);
|
||||
log.info(`Status filter: ${statusFilter}, withSubtasks: ${withSubtasks}`);
|
||||
|
||||
// Call listTasks with json format
|
||||
const result = listTasks(tasksPath, statusFilter, withSubtasks, 'json');
|
||||
|
||||
if (!result || !result.tasks) {
|
||||
throw new Error('Invalid or empty response from listTasks function');
|
||||
// Find the tasks path first - needed for cache key and execution
|
||||
tasksPath = findTasksJsonPath(args, log);
|
||||
} catch (error) {
|
||||
if (error.code === 'TASKS_FILE_NOT_FOUND') {
|
||||
log.error(`Tasks file not found: ${error.message}`);
|
||||
// Return the error structure expected by the calling tool/handler
|
||||
return { success: false, error: { code: error.code, message: error.message }, fromCache: false };
|
||||
}
|
||||
|
||||
log.info(`Successfully retrieved ${result.tasks.length} tasks`);
|
||||
|
||||
// Return the raw result directly
|
||||
log.error(`Unexpected error finding tasks file: ${error.message}`);
|
||||
// Re-throw for outer catch or return structured error
|
||||
return { success: false, error: { code: 'FIND_TASKS_PATH_ERROR', message: error.message }, fromCache: false };
|
||||
}
|
||||
|
||||
// Generate cache key *after* finding tasksPath
|
||||
const statusFilter = args.status || 'all';
|
||||
const withSubtasks = args.withSubtasks || false;
|
||||
const cacheKey = `listTasks:${tasksPath}:${statusFilter}:${withSubtasks}`;
|
||||
|
||||
// Define the action function to be executed on cache miss
|
||||
const coreListTasksAction = async () => {
|
||||
try {
|
||||
log.info(`Executing core listTasks function for path: ${tasksPath}, filter: ${statusFilter}, subtasks: ${withSubtasks}`);
|
||||
const resultData = listTasks(tasksPath, statusFilter, withSubtasks, 'json');
|
||||
|
||||
if (!resultData || !resultData.tasks) {
|
||||
log.error('Invalid or empty response from listTasks core function');
|
||||
return { success: false, error: { code: 'INVALID_CORE_RESPONSE', message: 'Invalid or empty response from listTasks core function' } };
|
||||
}
|
||||
log.info(`Core listTasks function retrieved ${resultData.tasks.length} tasks`);
|
||||
return { success: true, data: resultData };
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Core listTasks function failed: ${error.message}`);
|
||||
return { success: false, error: { code: 'LIST_TASKS_CORE_ERROR', message: error.message || 'Failed to list tasks' } };
|
||||
}
|
||||
};
|
||||
|
||||
// Use the caching utility
|
||||
try {
|
||||
const result = await getCachedOrExecute({
|
||||
cacheKey,
|
||||
actionFn: coreListTasksAction,
|
||||
log
|
||||
});
|
||||
log.info(`listTasksDirect completed. From cache: ${result.fromCache}`);
|
||||
return result; // Returns { success, data/error, fromCache }
|
||||
} catch(error) {
|
||||
// Catch unexpected errors from getCachedOrExecute itself (though unlikely)
|
||||
log.error(`Unexpected error during getCachedOrExecute for listTasks: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
return { success: false, error: { code: 'CACHE_UTIL_ERROR', message: error.message }, fromCache: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for monitoring
|
||||
* @param {Object} args - Command arguments
|
||||
* @param {Object} log - Logger object
|
||||
* @returns {Object} - Cache statistics
|
||||
*/
|
||||
export async function getCacheStatsDirect(args, log) {
|
||||
try {
|
||||
log.info('Retrieving cache statistics');
|
||||
const stats = contextManager.getStats();
|
||||
return {
|
||||
success: true,
|
||||
data: result // Return the actual object, not a stringified version
|
||||
data: stats
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error in listTasksDirect: ${error.message}`);
|
||||
|
||||
// Ensure we always return a properly structured error object
|
||||
log.error(`Error getting cache stats: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: error.code || 'LIST_TASKS_ERROR',
|
||||
code: 'CACHE_STATS_ERROR',
|
||||
message: error.message || 'Unknown error occurred'
|
||||
}
|
||||
};
|
||||
@@ -115,5 +162,6 @@ export async function listTasksDirect(args, log) {
|
||||
*/
|
||||
export const directFunctions = {
|
||||
list: listTasksDirect,
|
||||
cacheStats: getCacheStatsDirect,
|
||||
// Add more functions as we implement them
|
||||
};
|
||||
Reference in New Issue
Block a user