feat(mcp): Add tagInfo to responses and integrate ContextGatherer

Enhances the MCP server to include 'tagInfo' (currentTag, availableTags) in all tool responses, providing better client-side context.

- Introduces a new 'ContextGatherer' utility to standardize the collection of file, task, and project context for AI-powered commands. This refactors several task-manager modules ('expand-task', 'research', 'update-task', etc.) to use the new utility.

- Fixes an issue in 'get-task' and 'get-tasks' MCP tools where the 'projectRoot' was not being passed correctly, preventing tag information from being included in their responses.

- Adds subtask '103.17' to track the implementation of the task template importing feature.

- Updates documentation ('.cursor/rules', 'docs/') to align with the new tagged task system and context gatherer logic.
This commit is contained in:
Eyal Toledano
2025-06-11 23:06:36 -04:00
parent bb775e3180
commit 83d6405b17
29 changed files with 34433 additions and 13239 deletions

View File

@@ -7,7 +7,13 @@
import fs from 'fs';
import path from 'path';
import pkg from 'gpt-tokens';
import { readJSON, findTaskById, truncate } from '../utils.js';
import Fuse from 'fuse.js';
import {
readJSON,
findTaskById,
truncate,
flattenTasksWithSubtasks
} from '../utils.js';
const { encode } = pkg;
@@ -17,7 +23,26 @@ const { encode } = pkg;
export class ContextGatherer {
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.tasksPath = path.join(projectRoot, 'tasks', 'tasks.json');
this.tasksPath = path.join(
projectRoot,
'.taskmaster',
'tasks',
'tasks.json'
);
this.allTasks = this._loadAllTasks();
}
_loadAllTasks() {
try {
const data = readJSON(this.tasksPath, this.projectRoot);
const tasks = data?.tasks || [];
return tasks;
} catch (error) {
console.warn(
`Warning: Could not load tasks for ContextGatherer: ${error.message}`
);
return [];
}
}
/**
@@ -46,7 +71,10 @@ export class ContextGatherer {
* @param {string} [options.customContext] - Additional custom context
* @param {boolean} [options.includeProjectTree] - Include project file tree
* @param {string} [options.format] - Output format: 'research', 'chat', 'system-prompt'
* @returns {Promise<string>} Formatted context string
* @param {string} [options.semanticQuery] - A query string for semantic task searching.
* @param {number} [options.maxSemanticResults] - Max number of semantic results.
* @param {Array<number>} [options.dependencyTasks] - Array of task IDs to build dependency graphs from.
* @returns {Promise<Object>} Object with context string and analysis data
*/
async gather(options = {}) {
const {
@@ -55,86 +83,304 @@ export class ContextGatherer {
customContext = '',
includeProjectTree = false,
format = 'research',
includeTokenCounts = false
semanticQuery,
maxSemanticResults = 10,
dependencyTasks = []
} = options;
const contextSections = [];
const tokenBreakdown = {
customContext: null,
tasks: [],
files: [],
projectTree: null,
total: 0
};
const finalTaskIds = new Set(tasks.map(String));
let analysisData = null;
// Add custom context first if provided
if (customContext && customContext.trim()) {
const formattedCustom = this._formatCustomContext(customContext, format);
contextSections.push(formattedCustom);
if (includeTokenCounts) {
tokenBreakdown.customContext = {
tokens: this.countTokens(formattedCustom),
characters: formattedCustom.length
};
}
// Semantic Search
if (semanticQuery && this.allTasks.length > 0) {
const semanticResults = this._performSemanticSearch(
semanticQuery,
maxSemanticResults
);
// Store the analysis data for UI display
analysisData = semanticResults.analysisData;
semanticResults.tasks.forEach((task) => {
finalTaskIds.add(String(task.id));
});
}
// Add task context
if (tasks.length > 0) {
// Dependency Graph Analysis
if (dependencyTasks.length > 0) {
const dependencyResults = this._buildDependencyContext(dependencyTasks);
dependencyResults.allRelatedTaskIds.forEach((id) =>
finalTaskIds.add(String(id))
);
// We can format and add dependencyResults.graphVisualization later if needed
}
// Add custom context first
if (customContext && customContext.trim()) {
contextSections.push(this._formatCustomContext(customContext, format));
}
// Gather context for the final list of tasks
if (finalTaskIds.size > 0) {
const taskContextResult = await this._gatherTaskContext(
tasks,
format,
includeTokenCounts
Array.from(finalTaskIds),
format
);
if (taskContextResult.context) {
contextSections.push(taskContextResult.context);
if (includeTokenCounts) {
tokenBreakdown.tasks = taskContextResult.breakdown;
}
}
}
// Add file context
if (files.length > 0) {
const fileContextResult = await this._gatherFileContext(
files,
format,
includeTokenCounts
);
const fileContextResult = await this._gatherFileContext(files, format);
if (fileContextResult.context) {
contextSections.push(fileContextResult.context);
if (includeTokenCounts) {
tokenBreakdown.files = fileContextResult.breakdown;
}
}
}
// Add project tree context
if (includeProjectTree) {
const treeContextResult = await this._gatherProjectTreeContext(
format,
includeTokenCounts
);
const treeContextResult = await this._gatherProjectTreeContext(format);
if (treeContextResult.context) {
contextSections.push(treeContextResult.context);
if (includeTokenCounts) {
tokenBreakdown.projectTree = treeContextResult.breakdown;
}
}
const finalContext = this._joinContextSections(contextSections, format);
return {
context: finalContext,
analysisData: analysisData,
contextSections: contextSections.length,
finalTaskIds: Array.from(finalTaskIds)
};
}
_performSemanticSearch(query, maxResults) {
const searchableTasks = this.allTasks.map((task) => {
const dependencyTitles =
task.dependencies?.length > 0
? task.dependencies
.map((depId) => this.allTasks.find((t) => t.id === depId)?.title)
.filter(Boolean)
.join(' ')
: '';
return { ...task, dependencyTitles };
});
// Use the exact same approach as add-task.js
const searchOptions = {
includeScore: true, // Return match scores
threshold: 0.4, // Lower threshold = stricter matching (range 0-1)
keys: [
{ name: 'title', weight: 1.5 }, // Title is most important
{ name: 'description', weight: 2 }, // Description is very important
{ name: 'details', weight: 3 }, // Details is most important
// Search dependencies to find tasks that depend on similar things
{ name: 'dependencyTitles', weight: 0.5 }
],
// Sort matches by score (lower is better)
shouldSort: true,
// Allow searching in nested properties
useExtendedSearch: true,
// Return up to 50 matches
limit: 50
};
// Create search index using Fuse.js
const fuse = new Fuse(searchableTasks, searchOptions);
// Extract significant words and phrases from the prompt (like add-task.js does)
const promptWords = query
.toLowerCase()
.replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
.split(/\s+/)
.filter((word) => word.length > 3); // Words at least 4 chars
// Use the user's prompt for fuzzy search
const fuzzyResults = fuse.search(query);
// Also search for each significant word to catch different aspects
const wordResults = [];
for (const word of promptWords) {
if (word.length > 5) {
// Only use significant words
const results = fuse.search(word);
if (results.length > 0) {
wordResults.push(...results);
}
}
}
// Join all sections based on format
const finalContext = this._joinContextSections(contextSections, format);
// Merge and deduplicate results
const mergedResults = [...fuzzyResults];
if (includeTokenCounts) {
tokenBreakdown.total = this.countTokens(finalContext);
return {
context: finalContext,
tokenBreakdown: tokenBreakdown
};
// Add word results that aren't already in fuzzyResults
for (const wordResult of wordResults) {
if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
mergedResults.push(wordResult);
}
}
return finalContext;
// Group search results by relevance
const highRelevance = mergedResults
.filter((result) => result.score < 0.25)
.map((result) => result.item);
const mediumRelevance = mergedResults
.filter((result) => result.score >= 0.25 && result.score < 0.4)
.map((result) => result.item);
// Get recent tasks (newest first)
const recentTasks = [...this.allTasks]
.sort((a, b) => b.id - a.id)
.slice(0, 5);
// Combine high relevance, medium relevance, and recent tasks
// Prioritize high relevance first
const allRelevantTasks = [...highRelevance];
// Add medium relevance if not already included
for (const task of mediumRelevance) {
if (!allRelevantTasks.some((t) => t.id === task.id)) {
allRelevantTasks.push(task);
}
}
// Add recent tasks if not already included
for (const task of recentTasks) {
if (!allRelevantTasks.some((t) => t.id === task.id)) {
allRelevantTasks.push(task);
}
}
// Get top N results for context
const finalResults = allRelevantTasks.slice(0, maxResults);
return {
tasks: finalResults,
analysisData: {
highRelevance: highRelevance,
mediumRelevance: mediumRelevance,
recentTasks: recentTasks,
allRelevantTasks: allRelevantTasks
}
};
}
_buildDependencyContext(taskIds) {
const { allRelatedTaskIds, graphs, depthMap } =
this._buildDependencyGraphs(taskIds);
if (allRelatedTaskIds.size === 0) return '';
const dependentTasks = Array.from(allRelatedTaskIds)
.map((id) => this.allTasks.find((t) => t.id === id))
.filter(Boolean)
.sort((a, b) => (depthMap.get(a.id) || 0) - (depthMap.get(b.id) || 0));
const uniqueDetailedTasks = dependentTasks.slice(0, 8);
let context = `\nThis task relates to a dependency structure with ${dependentTasks.length} related tasks in the chain.`;
const directDeps = this.allTasks.filter((t) => taskIds.includes(t.id));
if (directDeps.length > 0) {
context += `\n\nDirect dependencies:\n${directDeps
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
.join('\n')}`;
}
const indirectDeps = dependentTasks.filter((t) => !taskIds.includes(t.id));
if (indirectDeps.length > 0) {
context += `\n\nIndirect dependencies (dependencies of dependencies):\n${indirectDeps
.slice(0, 5)
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
.join('\n')}`;
if (indirectDeps.length > 5)
context += `\n- ... and ${
indirectDeps.length - 5
} more indirect dependencies`;
}
context += `\n\nDetailed information about dependencies:`;
for (const depTask of uniqueDetailedTasks) {
const isDirect = taskIds.includes(depTask.id)
? ' [DIRECT DEPENDENCY]'
: '';
context += `\n\n------ Task ${depTask.id}${isDirect}: ${depTask.title} ------\n`;
context += `Description: ${depTask.description}\n`;
if (depTask.dependencies?.length) {
context += `Dependencies: ${depTask.dependencies.join(', ')}\n`;
}
if (depTask.details) {
context += `Implementation Details: ${truncate(
depTask.details,
400
)}\n`;
}
}
if (graphs.length > 0) {
context += '\n\nDependency Chain Visualization:';
context += graphs
.map((graph) => this._formatDependencyChain(graph))
.join('');
}
return context;
}
_buildDependencyGraphs(taskIds) {
const visited = new Set();
const depthMap = new Map();
const graphs = [];
for (const id of taskIds) {
const graph = this._buildDependencyGraph(id, visited, depthMap);
if (graph) graphs.push(graph);
}
return { allRelatedTaskIds: visited, graphs, depthMap };
}
_buildDependencyGraph(taskId, visited, depthMap, depth = 0) {
if (visited.has(taskId) || depth > 5) return null; // Limit recursion depth
const task = this.allTasks.find((t) => t.id === taskId);
if (!task) return null;
visited.add(taskId);
if (!depthMap.has(taskId) || depth < depthMap.get(taskId)) {
depthMap.set(taskId, depth);
}
const dependencies =
task.dependencies
?.map((depId) =>
this._buildDependencyGraph(depId, visited, depthMap, depth + 1)
)
.filter(Boolean) || [];
return { ...task, dependencies };
}
_formatDependencyChain(node, prefix = '', isLast = true, depth = 0) {
if (depth > 3) return '';
const connector = isLast ? '└── ' : '├── ';
let result = `${prefix}${connector}Task ${node.id}: ${node.title}`;
if (node.dependencies?.length) {
const childPrefix = prefix + (isLast ? ' ' : '│ ');
result += node.dependencies
.map((dep, index) =>
this._formatDependencyChain(
dep,
childPrefix,
index === node.dependencies.length - 1,
depth + 1
)
)
.join('');
}
return '\n' + result;
}
/**
@@ -178,8 +424,7 @@ export class ContextGatherer {
*/
async _gatherTaskContext(taskIds, format, includeTokenCounts = false) {
try {
const tasksData = readJSON(this.tasksPath);
if (!tasksData || !tasksData.tasks) {
if (!this.allTasks || this.allTasks.length === 0) {
return { context: null, breakdown: [] };
}
@@ -192,7 +437,7 @@ export class ContextGatherer {
let itemInfo = null;
if (parsed.type === 'task') {
const result = findTaskById(tasksData.tasks, parsed.taskId);
const result = findTaskById(this.allTasks, parsed.taskId);
if (result.task) {
formattedItem = this._formatTaskForContext(result.task, format);
itemInfo = {
@@ -204,7 +449,7 @@ export class ContextGatherer {
};
}
} else if (parsed.type === 'subtask') {
const parentResult = findTaskById(tasksData.tasks, parsed.parentId);
const parentResult = findTaskById(this.allTasks, parsed.parentId);
if (parentResult.task && parentResult.task.subtasks) {
const subtask = parentResult.task.subtasks.find(
(st) => st.id === parsed.subtaskId
@@ -334,21 +579,16 @@ export class ContextGatherer {
: path.join(this.projectRoot, filePath);
if (!fs.existsSync(fullPath)) {
console.warn(`Warning: File not found: ${filePath}`);
continue;
}
const stats = fs.statSync(fullPath);
if (!stats.isFile()) {
console.warn(`Warning: Path is not a file: ${filePath}`);
continue;
}
// Check file size (limit to 50KB for context)
if (stats.size > 50 * 1024) {
console.warn(
`Warning: File too large, skipping: ${filePath} (${Math.round(stats.size / 1024)}KB)`
);
continue;
}