feat: connect get-task and get-tasks to remote (#1346)

This commit is contained in:
Ralph Khreish
2025-10-27 15:23:27 +01:00
committed by GitHub
parent 5381e21e57
commit 25addf919f
20 changed files with 937 additions and 589 deletions

View File

@@ -0,0 +1,7 @@
---
"task-master-ai": minor
---
remove file and complexity report parameter from get-tasks and get-task mcp tool
- In an effort to reduce complexity and context bloat for ai coding agents, we simplified the parameters of these tools

View File

@@ -4,5 +4,6 @@
*/
export * from './tools/autopilot/index.js';
export * from './tools/tasks/index.js';
export * from './shared/utils.js';
export * from './shared/types.js';

View File

@@ -45,14 +45,27 @@ export async function handleApiResult<T>(options: {
log?: any;
errorPrefix?: string;
projectRoot?: string;
tag?: string; // Optional tag/brief to use instead of reading from state.json
}): Promise<ContentResult> {
const { result, log, errorPrefix = 'API error', projectRoot } = options;
const {
result,
log,
errorPrefix = 'API error',
projectRoot,
tag: providedTag
} = options;
// Get version info for every response
const versionInfo = getVersionInfo();
// Get current tag if project root is provided
const currentTag = projectRoot ? getCurrentTag(projectRoot) : null;
// Use provided tag if available, otherwise get from state.json
// Note: For API storage, tm-core returns the brief name as the tag
const currentTag =
providedTag !== undefined
? providedTag
: projectRoot
? getCurrentTag(projectRoot)
: null;
if (!result.success) {
const errorMsg = result.error?.message || `Unknown ${errorPrefix}`;

View File

@@ -0,0 +1,136 @@
/**
* @fileoverview get-task MCP tool
* Get detailed information about a specific task by ID
*/
// TEMPORARY: Using zod/v3 for Draft-07 JSON Schema compatibility with FastMCP's zod-to-json-schema
// TODO: Revert to 'zod' when MCP spec issue is resolved (see PR #1323)
import { z } from 'zod/v3';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { createTmCore, type Task } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const GetTaskSchema = z.object({
id: z
.string()
.describe(
'Task ID(s) to get (can be comma-separated for multiple tasks)'
),
status: z
.string()
.optional()
.describe("Filter subtasks by status (e.g., 'pending', 'done')"),
projectRoot: z
.string()
.describe(
'Absolute path to the project root directory (Optional, usually from session)'
),
tag: z.string().optional().describe('Tag context to operate on')
});
type GetTaskArgs = z.infer<typeof GetTaskSchema>;
/**
* Register the get_task tool with the MCP server
*/
export function registerGetTaskTool(server: FastMCP) {
server.addTool({
name: 'get_task',
description: 'Get detailed information about a specific task',
parameters: GetTaskSchema,
execute: withNormalizedProjectRoot(
async (args: GetTaskArgs, context: MCPContext) => {
const { id, status, projectRoot, tag } = args;
try {
context.log.info(
`Getting task details for ID: ${id}${status ? ` (filtering subtasks by status: ${status})` : ''} in root: ${projectRoot}`
);
// Create tm-core with logging callback
const tmCore = await createTmCore({
projectPath: projectRoot,
loggerConfig: {
mcpMode: true,
logCallback: context.log
}
});
// Handle comma-separated IDs - parallelize for better performance
const taskIds = id.split(',').map((tid) => tid.trim());
const results = await Promise.all(
taskIds.map((taskId) => tmCore.tasks.get(taskId, tag))
);
const tasks: Task[] = [];
for (const result of results) {
if (!result.task) continue;
// If status filter is provided, filter subtasks (create copy to avoid mutation)
if (status && result.task.subtasks) {
const statusFilters = status
.split(',')
.map((s) => s.trim().toLowerCase());
const filteredSubtasks = result.task.subtasks.filter((st) =>
statusFilters.includes(String(st.status).toLowerCase())
);
tasks.push({ ...result.task, subtasks: filteredSubtasks });
} else {
tasks.push(result.task);
}
}
if (tasks.length === 0) {
context.log.warn(`No tasks found for ID(s): ${id}`);
return handleApiResult({
result: {
success: false,
error: {
message: `No tasks found for ID(s): ${id}`
}
},
log: context.log,
projectRoot
});
}
context.log.info(
`Successfully retrieved ${tasks.length} task(s) for ID(s): ${id}`
);
// Return single task if only one ID was requested, otherwise array
const responseData = taskIds.length === 1 ? tasks[0] : tasks;
return handleApiResult({
result: {
success: true,
data: responseData
},
log: context.log,
projectRoot,
tag
});
} catch (error: any) {
context.log.error(`Error in get-task: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get task: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,166 @@
/**
* @fileoverview get-tasks MCP tool
* Get all tasks from Task Master with optional filtering
*/
// TEMPORARY: Using zod/v3 for Draft-07 JSON Schema compatibility with FastMCP's zod-to-json-schema
// TODO: Revert to 'zod' when MCP spec issue is resolved (see PR #1323)
import { z } from 'zod/v3';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { createTmCore, type TaskStatus, type Task } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const GetTasksSchema = z.object({
projectRoot: z
.string()
.describe('The directory of the project. Must be an absolute path.'),
status: z
.string()
.optional()
.describe(
"Filter tasks by status (e.g., 'pending', 'done') or multiple statuses separated by commas (e.g., 'blocked,deferred')"
),
withSubtasks: z
.boolean()
.optional()
.describe('Include subtasks nested within their parent tasks in the response'),
tag: z.string().optional().describe('Tag context to operate on')
});
type GetTasksArgs = z.infer<typeof GetTasksSchema>;
/**
* Register the get_tasks tool with the MCP server
*/
export function registerGetTasksTool(server: FastMCP) {
server.addTool({
name: 'get_tasks',
description:
'Get all tasks from Task Master, optionally filtering by status and including subtasks.',
parameters: GetTasksSchema,
execute: withNormalizedProjectRoot(
async (args: GetTasksArgs, context: MCPContext) => {
const { projectRoot, status, withSubtasks, tag } = args;
try {
context.log.info(
`Getting tasks from ${projectRoot}${status ? ` with status filter: ${status}` : ''}${tag ? ` for tag: ${tag}` : ''}`
);
// Create tm-core with logging callback
const tmCore = await createTmCore({
projectPath: projectRoot,
loggerConfig: {
mcpMode: true,
logCallback: context.log
}
});
// Build filter
const filter =
status && status !== 'all'
? {
status: status
.split(',')
.map((s: string) => s.trim() as TaskStatus)
}
: undefined;
// Call tm-core tasks.list()
const result = await tmCore.tasks.list({
tag,
filter,
includeSubtasks: withSubtasks
});
context.log.info(
`Retrieved ${result.tasks?.length || 0} tasks (${result.filtered} filtered, ${result.total} total)`
);
// Calculate stats using reduce for cleaner code
const totalTasks = result.total;
const taskCounts = result.tasks.reduce(
(acc, task) => {
acc[task.status] = (acc[task.status] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const completionPercentage =
totalTasks > 0 ? ((taskCounts.done || 0) / totalTasks) * 100 : 0;
// Count subtasks using reduce
const subtaskCounts = result.tasks.reduce(
(acc, task) => {
task.subtasks?.forEach((st) => {
acc.total++;
acc[st.status] = (acc[st.status] || 0) + 1;
});
return acc;
},
{ total: 0 } as Record<string, number>
);
const subtaskCompletionPercentage =
subtaskCounts.total > 0
? ((subtaskCounts.done || 0) / subtaskCounts.total) * 100
: 0;
return handleApiResult({
result: {
success: true,
data: {
tasks: result.tasks as Task[],
filter: status || 'all',
stats: {
total: totalTasks,
completed: taskCounts.done || 0,
inProgress: taskCounts['in-progress'] || 0,
pending: taskCounts.pending || 0,
blocked: taskCounts.blocked || 0,
deferred: taskCounts.deferred || 0,
cancelled: taskCounts.cancelled || 0,
review: taskCounts.review || 0,
completionPercentage,
subtasks: {
total: subtaskCounts.total,
completed: subtaskCounts.done || 0,
inProgress: subtaskCounts['in-progress'] || 0,
pending: subtaskCounts.pending || 0,
blocked: subtaskCounts.blocked || 0,
deferred: subtaskCounts.deferred || 0,
cancelled: subtaskCounts.cancelled || 0,
completionPercentage: subtaskCompletionPercentage
}
}
}
},
log: context.log,
projectRoot,
tag: result.tag
});
} catch (error: any) {
context.log.error(`Error in get-tasks: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get tasks: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,7 @@
/**
* @fileoverview Tasks MCP tools index
* Exports all task-related tool registration functions
*/
export { registerGetTasksTool } from './get-tasks.tool.js';
export { registerGetTaskTool } from './get-task.tool.js';

View File

@@ -1,114 +0,0 @@
/**
* list-tasks.js
* Direct function implementation for listing tasks
*/
import { listTasks } from '../../../../scripts/modules/task-manager.js';
import {
enableSilentMode,
disableSilentMode
} from '../../../../scripts/modules/utils.js';
/**
* Direct function wrapper for listTasks with error handling and caching.
*
* @param {Object} args - Command arguments (now expecting tasksJsonPath explicitly).
* @param {string} args.tasksJsonPath - Path to the tasks.json file.
* @param {string} args.reportPath - Path to the report file.
* @param {string} args.status - Status of the task.
* @param {boolean} args.withSubtasks - Whether to include subtasks.
* @param {string} args.projectRoot - Project root path (for MCP/env fallback)
* @param {string} args.tag - Tag for the task (optional)
* @param {Object} log - Logger object.
* @returns {Promise<Object>} - Task list result { success: boolean, data?: any, error?: { code: string, message: string } }.
*/
export async function listTasksDirect(args, log, context = {}) {
// Destructure the explicit tasksJsonPath from args
const { tasksJsonPath, reportPath, status, withSubtasks, projectRoot, tag } =
args;
const { session } = context;
if (!tasksJsonPath) {
log.error('listTasksDirect called without tasksJsonPath');
return {
success: false,
error: {
code: 'MISSING_ARGUMENT',
message: 'tasksJsonPath is required'
}
};
}
// Use the explicit tasksJsonPath for cache key
const statusFilter = status || 'all';
const withSubtasksFilter = withSubtasks || false;
// Define the action function to be executed on cache miss
const coreListTasksAction = async () => {
try {
// Enable silent mode to prevent console logs from interfering with JSON response
enableSilentMode();
log.info(
`Executing core listTasks function for path: ${tasksJsonPath}, filter: ${statusFilter}, subtasks: ${withSubtasksFilter}`
);
// Pass the explicit tasksJsonPath to the core function
const resultData = listTasks(
tasksJsonPath,
statusFilter,
reportPath,
withSubtasksFilter,
'json',
{ projectRoot, session, tag }
);
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`
);
// Restore normal logging
disableSilentMode();
return { success: true, data: resultData };
} catch (error) {
// Make sure to restore normal logging even if there's an error
disableSilentMode();
log.error(`Core listTasks function failed: ${error.message}`);
return {
success: false,
error: {
code: 'LIST_TASKS_CORE_ERROR',
message: error.message || 'Failed to list tasks'
}
};
}
};
try {
const result = await coreListTasksAction();
log.info('listTasksDirect completed');
return result;
} catch (error) {
log.error(`Unexpected error during listTasks: ${error.message}`);
console.error(error.stack);
return {
success: false,
error: {
code: 'UNEXPECTED_ERROR',
message: error.message
}
};
}
}

View File

@@ -1,164 +0,0 @@
/**
* show-task.js
* Direct function implementation for showing task details
*/
import {
findTaskById,
readComplexityReport,
readJSON
} from '../../../../scripts/modules/utils.js';
import { findTasksPath } from '../utils/path-utils.js';
/**
* Direct function wrapper for getting task details.
*
* @param {Object} args - Command arguments.
* @param {string} args.id - Task ID to show.
* @param {string} [args.file] - Optional path to the tasks file (passed to findTasksPath).
* @param {string} args.reportPath - Explicit path to the complexity report file.
* @param {string} [args.status] - Optional status to filter subtasks by.
* @param {string} args.projectRoot - Absolute path to the project root directory (already normalized by tool).
* @param {string} [args.tag] - Tag for the task
* @param {Object} log - Logger object.
* @param {Object} context - Context object containing session data.
* @returns {Promise<Object>} - Result object with success status and data/error information.
*/
export async function showTaskDirect(args, log) {
// This function doesn't need session context since it only reads data
// Destructure projectRoot and other args. projectRoot is assumed normalized.
const { id, file, reportPath, status, projectRoot, tag } = args;
log.info(
`Showing task direct function. ID: ${id}, File: ${file}, Status Filter: ${status}, ProjectRoot: ${projectRoot}`
);
// --- Path Resolution using the passed (already normalized) projectRoot ---
let tasksJsonPath;
try {
// Use the projectRoot passed directly from args
tasksJsonPath = findTasksPath(
{ projectRoot: projectRoot, file: file },
log
);
log.info(`Resolved tasks path: ${tasksJsonPath}`);
} catch (error) {
log.error(`Error finding tasks.json: ${error.message}`);
return {
success: false,
error: {
code: 'TASKS_FILE_NOT_FOUND',
message: `Failed to find tasks.json: ${error.message}`
}
};
}
// --- End Path Resolution ---
// --- Rest of the function remains the same, using tasksJsonPath ---
try {
const tasksData = readJSON(tasksJsonPath, projectRoot, tag);
if (!tasksData || !tasksData.tasks) {
return {
success: false,
error: { code: 'INVALID_TASKS_DATA', message: 'Invalid tasks data' }
};
}
const complexityReport = readComplexityReport(reportPath);
// Parse comma-separated IDs
const taskIds = id
.split(',')
.map((taskId) => taskId.trim())
.filter((taskId) => taskId.length > 0);
if (taskIds.length === 0) {
return {
success: false,
error: {
code: 'INVALID_TASK_ID',
message: 'No valid task IDs provided'
}
};
}
// Handle single task ID (existing behavior)
if (taskIds.length === 1) {
const { task, originalSubtaskCount } = findTaskById(
tasksData.tasks,
taskIds[0],
complexityReport,
status
);
if (!task) {
return {
success: false,
error: {
code: 'TASK_NOT_FOUND',
message: `Task or subtask with ID ${taskIds[0]} not found`
}
};
}
log.info(`Successfully retrieved task ${taskIds[0]}.`);
const returnData = { ...task };
if (originalSubtaskCount !== null) {
returnData._originalSubtaskCount = originalSubtaskCount;
returnData._subtaskFilter = status;
}
return { success: true, data: returnData };
}
// Handle multiple task IDs
const foundTasks = [];
const notFoundIds = [];
taskIds.forEach((taskId) => {
const { task, originalSubtaskCount } = findTaskById(
tasksData.tasks,
taskId,
complexityReport,
status
);
if (task) {
const taskData = { ...task };
if (originalSubtaskCount !== null) {
taskData._originalSubtaskCount = originalSubtaskCount;
taskData._subtaskFilter = status;
}
foundTasks.push(taskData);
} else {
notFoundIds.push(taskId);
}
});
log.info(
`Successfully retrieved ${foundTasks.length} of ${taskIds.length} requested tasks.`
);
// Return multiple tasks with metadata
return {
success: true,
data: {
tasks: foundTasks,
requestedIds: taskIds,
foundCount: foundTasks.length,
notFoundIds: notFoundIds,
isMultiple: true
}
};
} catch (error) {
log.error(`Error showing task ${id}: ${error.message}`);
return {
success: false,
error: {
code: 'TASK_OPERATION_ERROR',
message: error.message
}
};
}
}

View File

@@ -5,7 +5,6 @@
*/
// Import direct function implementations
import { listTasksDirect } from './direct-functions/list-tasks.js';
import { getCacheStatsDirect } from './direct-functions/cache-stats.js';
import { parsePRDDirect } from './direct-functions/parse-prd.js';
import { updateTasksDirect } from './direct-functions/update-tasks.js';
@@ -13,7 +12,6 @@ import { updateTaskByIdDirect } from './direct-functions/update-task-by-id.js';
import { updateSubtaskByIdDirect } from './direct-functions/update-subtask-by-id.js';
import { generateTaskFilesDirect } from './direct-functions/generate-task-files.js';
import { setTaskStatusDirect } from './direct-functions/set-task-status.js';
import { showTaskDirect } from './direct-functions/show-task.js';
import { nextTaskDirect } from './direct-functions/next-task.js';
import { expandTaskDirect } from './direct-functions/expand-task.js';
import { addTaskDirect } from './direct-functions/add-task.js';
@@ -47,7 +45,6 @@ export { findTasksPath } from './utils/path-utils.js';
// Use Map for potential future enhancements like introspection or dynamic dispatch
export const directFunctions = new Map([
['listTasksDirect', listTasksDirect],
['getCacheStatsDirect', getCacheStatsDirect],
['parsePRDDirect', parsePRDDirect],
['updateTasksDirect', updateTasksDirect],
@@ -55,7 +52,6 @@ export const directFunctions = new Map([
['updateSubtaskByIdDirect', updateSubtaskByIdDirect],
['generateTaskFilesDirect', generateTaskFilesDirect],
['setTaskStatusDirect', setTaskStatusDirect],
['showTaskDirect', showTaskDirect],
['nextTaskDirect', nextTaskDirect],
['expandTaskDirect', expandTaskDirect],
['addTaskDirect', addTaskDirect],
@@ -87,7 +83,6 @@ export const directFunctions = new Map([
// Re-export all direct function implementations
export {
listTasksDirect,
getCacheStatsDirect,
parsePRDDirect,
updateTasksDirect,
@@ -95,7 +90,6 @@ export {
updateSubtaskByIdDirect,
generateTaskFilesDirect,
setTaskStatusDirect,
showTaskDirect,
nextTaskDirect,
expandTaskDirect,
addTaskDirect,

View File

@@ -1,152 +0,0 @@
/**
* tools/get-task.js
* Tool to get task details by ID
*/
// TEMPORARY: Using zod/v3 for Draft-07 JSON Schema compatibility with FastMCP's zod-to-json-schema
// TODO: Revert to 'zod' when MCP spec issue is resolved (see PR #1323)
import { z } from 'zod/v3';
import {
handleApiResult,
createErrorResponse,
withNormalizedProjectRoot
} from './utils.js';
import { showTaskDirect } from '../core/task-master-core.js';
import {
findTasksPath,
findComplexityReportPath
} from '../core/utils/path-utils.js';
import { resolveTag } from '../../../scripts/modules/utils.js';
/**
* Custom processor function that removes allTasks from the response
* @param {Object} data - The data returned from showTaskDirect
* @returns {Object} - The processed data with allTasks removed
*/
function processTaskResponse(data) {
if (!data) return data;
// If we have the expected structure with task and allTasks
if (typeof data === 'object' && data !== null && data.id && data.title) {
// If the data itself looks like the task object, return it
return data;
} else if (data.task) {
return data.task;
}
// If structure is unexpected, return as is
return data;
}
/**
* Register the get-task tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerShowTaskTool(server) {
server.addTool({
name: 'get_task',
description: 'Get detailed information about a specific task',
parameters: z.object({
id: z
.string()
.describe(
'Task ID(s) to get (can be comma-separated for multiple tasks)'
),
status: z
.string()
.optional()
.describe("Filter subtasks by status (e.g., 'pending', 'done')"),
file: z
.string()
.optional()
.describe('Path to the tasks file relative to project root'),
complexityReport: z
.string()
.optional()
.describe(
'Path to the complexity report file (relative to project root or absolute)'
),
projectRoot: z
.string()
.describe(
'Absolute path to the project root directory (Optional, usually from session)'
),
tag: z.string().optional().describe('Tag context to operate on')
}),
execute: withNormalizedProjectRoot(async (args, { log, session }) => {
const { id, file, status, projectRoot } = args;
try {
log.info(
`Getting task details for ID: ${id}${status ? ` (filtering subtasks by status: ${status})` : ''} in root: ${projectRoot}`
);
const resolvedTag = resolveTag({
projectRoot: args.projectRoot,
tag: args.tag
});
// Resolve the path to tasks.json using the NORMALIZED projectRoot from args
let tasksJsonPath;
try {
tasksJsonPath = findTasksPath(
{ projectRoot: projectRoot, file: file },
log
);
log.info(`Resolved tasks path: ${tasksJsonPath}`);
} catch (error) {
log.error(`Error finding tasks.json: ${error.message}`);
return createErrorResponse(
`Failed to find tasks.json: ${error.message}`
);
}
// Call the direct function, passing the normalized projectRoot
// Resolve the path to complexity report
let complexityReportPath;
try {
complexityReportPath = findComplexityReportPath(
{
projectRoot: projectRoot,
complexityReport: args.complexityReport,
tag: resolvedTag
},
log
);
} catch (error) {
log.error(`Error finding complexity report: ${error.message}`);
}
const result = await showTaskDirect(
{
tasksJsonPath: tasksJsonPath,
reportPath: complexityReportPath,
// Pass other relevant args
id: id,
status: status,
projectRoot: projectRoot,
tag: resolvedTag
},
log,
{ session }
);
if (result.success) {
log.info(`Successfully retrieved task details for ID: ${args.id}`);
} else {
log.error(`Failed to get task: ${result.error.message}`);
}
// Use our custom processor function
return handleApiResult(
result,
log,
'Error retrieving task details',
processTaskResponse,
projectRoot
);
} catch (error) {
log.error(`Error in get-task tool: ${error.message}\n${error.stack}`);
return createErrorResponse(`Failed to get task: ${error.message}`);
}
})
});
}

View File

@@ -1,124 +0,0 @@
/**
* tools/get-tasks.js
* Tool to get all tasks from Task Master
*/
// TEMPORARY: Using zod/v3 for Draft-07 JSON Schema compatibility with FastMCP's zod-to-json-schema
// TODO: Revert to 'zod' when MCP spec issue is resolved (see PR #1323)
import { z } from 'zod/v3';
import {
createErrorResponse,
handleApiResult,
withNormalizedProjectRoot
} from './utils.js';
import { listTasksDirect } from '../core/task-master-core.js';
import {
resolveTasksPath,
resolveComplexityReportPath
} from '../core/utils/path-utils.js';
import { resolveTag } from '../../../scripts/modules/utils.js';
/**
* Register the getTasks tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerListTasksTool(server) {
server.addTool({
name: 'get_tasks',
description:
'Get all tasks from Task Master, optionally filtering by status and including subtasks.',
parameters: z.object({
status: z
.string()
.optional()
.describe(
"Filter tasks by status (e.g., 'pending', 'done') or multiple statuses separated by commas (e.g., 'blocked,deferred')"
),
withSubtasks: z
.boolean()
.optional()
.describe(
'Include subtasks nested within their parent tasks in the response'
),
file: z
.string()
.optional()
.describe(
'Path to the tasks file (relative to project root or absolute)'
),
complexityReport: z
.string()
.optional()
.describe(
'Path to the complexity report file (relative to project root or absolute)'
),
projectRoot: z
.string()
.describe('The directory of the project. Must be an absolute path.'),
tag: z.string().optional().describe('Tag context to operate on')
}),
execute: withNormalizedProjectRoot(async (args, { log, session }) => {
try {
log.info(`Getting tasks with filters: ${JSON.stringify(args)}`);
const resolvedTag = resolveTag({
projectRoot: args.projectRoot,
tag: args.tag
});
// Resolve the path to tasks.json using new path utilities
let tasksJsonPath;
try {
tasksJsonPath = resolveTasksPath(args, log);
} catch (error) {
log.error(`Error finding tasks.json: ${error.message}`);
return createErrorResponse(
`Failed to find tasks.json: ${error.message}`
);
}
// Resolve the path to complexity report
let complexityReportPath;
try {
complexityReportPath = resolveComplexityReportPath(
{ ...args, tag: resolvedTag },
session
);
} catch (error) {
log.error(`Error finding complexity report: ${error.message}`);
// This is optional, so we don't fail the operation
complexityReportPath = null;
}
const result = await listTasksDirect(
{
tasksJsonPath: tasksJsonPath,
status: args.status,
withSubtasks: args.withSubtasks,
reportPath: complexityReportPath,
projectRoot: args.projectRoot,
tag: resolvedTag
},
log,
{ session }
);
log.info(
`Retrieved ${result.success ? result.data?.tasks?.length || 0 : 0} tasks`
);
return handleApiResult(
result,
log,
'Error getting tasks',
undefined,
args.projectRoot
);
} catch (error) {
log.error(`Error getting tasks: ${error.message}`);
return createErrorResponse(error.message);
}
})
});
}
// We no longer need the formatTasksResponse function as we're returning raw JSON data

View File

@@ -1,16 +1,14 @@
/**
* tool-registry.js
* Tool Registry Object Structure - Maps all 36 tool names to registration functions
* Tool Registry - Maps tool names to registration functions
*/
import { registerListTasksTool } from './get-tasks.js';
import { registerSetTaskStatusTool } from './set-task-status.js';
import { registerParsePRDTool } from './parse-prd.js';
import { registerUpdateTool } from './update.js';
import { registerUpdateTaskTool } from './update-task.js';
import { registerUpdateSubtaskTool } from './update-subtask.js';
import { registerGenerateTool } from './generate.js';
import { registerShowTaskTool } from './get-task.js';
import { registerNextTaskTool } from './next-task.js';
import { registerExpandTaskTool } from './expand-task.js';
import { registerAddTaskTool } from './add-task.js';
@@ -40,7 +38,7 @@ import { registerRulesTool } from './rules.js';
import { registerScopeUpTool } from './scope-up.js';
import { registerScopeDownTool } from './scope-down.js';
// Import TypeScript autopilot tools from apps/mcp
// Import TypeScript tools from apps/mcp
import {
registerAutopilotStartTool,
registerAutopilotResumeTool,
@@ -49,7 +47,9 @@ import {
registerAutopilotCompleteTool,
registerAutopilotCommitTool,
registerAutopilotFinalizeTool,
registerAutopilotAbortTool
registerAutopilotAbortTool,
registerGetTasksTool,
registerGetTaskTool
} from '@tm/mcp';
/**
@@ -67,8 +67,8 @@ export const toolRegistry = {
expand_all: registerExpandAllTool,
scope_up_task: registerScopeUpTool,
scope_down_task: registerScopeDownTool,
get_tasks: registerListTasksTool,
get_task: registerShowTaskTool,
get_tasks: registerGetTasksTool,
get_task: registerGetTaskTool,
next_task: registerNextTaskTool,
complexity_report: registerComplexityReportTool,
set_task_status: registerSetTaskStatusTool,

View File

@@ -187,6 +187,12 @@ export interface IStorage {
* @returns The type of storage implementation ('file' or 'api')
*/
getStorageType(): 'file' | 'api';
/**
* Get the current brief name (only applicable for API storage)
* @returns The brief name if using API storage with a selected brief, null otherwise
*/
getCurrentBriefName(): string | null;
}
/**
@@ -271,7 +277,7 @@ export abstract class BaseStorage implements IStorage {
abstract close(): Promise<void>;
abstract getStats(): Promise<StorageStats>;
abstract getStorageType(): 'file' | 'api';
abstract getCurrentBriefName(): string | null;
/**
* Utility method to generate backup filename
* @param originalPath - Original file path

View File

@@ -4,5 +4,5 @@
*/
export { Logger, LogLevel } from './logger.js';
export type { LoggerConfig } from './logger.js';
export type { LoggerConfig, LogCallback, LogObject } from './logger.js';
export { createLogger, getLogger, setGlobalLogger } from './factory.js';

View File

@@ -0,0 +1,389 @@
/**
* @fileoverview Tests for MCP logging integration
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Logger, LogLevel, type LogCallback } from './logger.js';
describe('Logger - MCP Integration', () => {
// Store original environment
let originalEnv: Record<string, string | undefined>;
beforeEach(() => {
// Save original environment
originalEnv = {
MCP_MODE: process.env.MCP_MODE,
TASK_MASTER_MCP: process.env.TASK_MASTER_MCP,
TASK_MASTER_SILENT: process.env.TASK_MASTER_SILENT,
TM_SILENT: process.env.TM_SILENT,
TASK_MASTER_LOG_LEVEL: process.env.TASK_MASTER_LOG_LEVEL,
TM_LOG_LEVEL: process.env.TM_LOG_LEVEL,
NO_COLOR: process.env.NO_COLOR,
TASK_MASTER_NO_COLOR: process.env.TASK_MASTER_NO_COLOR
};
// Clear environment variables for clean tests
delete process.env.MCP_MODE;
delete process.env.TASK_MASTER_MCP;
delete process.env.TASK_MASTER_SILENT;
delete process.env.TM_SILENT;
delete process.env.TASK_MASTER_LOG_LEVEL;
delete process.env.TM_LOG_LEVEL;
delete process.env.NO_COLOR;
delete process.env.TASK_MASTER_NO_COLOR;
});
afterEach(() => {
// Restore original environment
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe('Callback-based logging', () => {
it('should call callback instead of console when logCallback is provided', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
logCallback: mockCallback
});
logger.info('Test message');
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('Test message')
);
});
it('should call callback for all log levels', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.DEBUG,
logCallback: mockCallback
});
logger.error('Error message');
logger.warn('Warning message');
logger.info('Info message');
logger.debug('Debug message');
expect(mockCallback).toHaveBeenNthCalledWith(
1,
'error',
expect.stringContaining('Error message')
);
expect(mockCallback).toHaveBeenNthCalledWith(
2,
'warn',
expect.stringContaining('Warning message')
);
expect(mockCallback).toHaveBeenNthCalledWith(
3,
'info',
expect.stringContaining('Info message')
);
expect(mockCallback).toHaveBeenNthCalledWith(
4,
'debug',
expect.stringContaining('Debug message')
);
});
it('should respect log level with callback', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.WARN,
logCallback: mockCallback
});
logger.debug('Debug message');
logger.info('Info message');
logger.warn('Warning message');
logger.error('Error message');
// Only warn and error should be logged
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenNthCalledWith(
1,
'warn',
expect.stringContaining('Warning message')
);
expect(mockCallback).toHaveBeenNthCalledWith(
2,
'error',
expect.stringContaining('Error message')
);
});
it('should handle raw log() calls with callback', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
logCallback: mockCallback
});
logger.log('Raw message', 'with args');
expect(mockCallback).toHaveBeenCalledWith('log', 'Raw message with args');
});
});
describe('MCP mode with callback', () => {
it('should not silence logs when mcpMode=true and callback is provided', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
mcpMode: true,
logCallback: mockCallback
});
logger.info('Test message');
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('Test message')
);
});
it('should silence logs when mcpMode=true and no callback', () => {
const consoleSpy = vi.spyOn(console, 'log');
const logger = new Logger({
level: LogLevel.INFO,
mcpMode: true
// No callback
});
logger.info('Test message');
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Child loggers', () => {
it('should inherit callback from parent', () => {
const mockCallback = vi.fn();
const parent = new Logger({
level: LogLevel.INFO,
logCallback: mockCallback
});
const child = parent.child('child');
child.info('Child message');
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('[child]')
);
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('Child message')
);
});
it('should allow child to override callback', () => {
const parentCallback = vi.fn();
const childCallback = vi.fn();
const parent = new Logger({
level: LogLevel.INFO,
logCallback: parentCallback
});
const child = parent.child('child', {
logCallback: childCallback
});
parent.info('Parent message');
child.info('Child message');
expect(parentCallback).toHaveBeenCalledTimes(1);
expect(childCallback).toHaveBeenCalledTimes(1);
});
});
describe('Configuration updates', () => {
it('should allow updating logCallback via setConfig', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
logCallback: callback1
});
logger.info('Message 1');
expect(callback1).toHaveBeenCalledTimes(1);
logger.setConfig({ logCallback: callback2 });
logger.info('Message 2');
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
});
it('should maintain mcpMode behavior when updating config', () => {
const callback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
mcpMode: true
});
// Initially silent (no callback)
logger.info('Message 1');
expect(callback).not.toHaveBeenCalled();
// Add callback - should start logging
logger.setConfig({ logCallback: callback });
logger.info('Message 2');
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe('Formatting with callback', () => {
it('should include prefix in callback messages', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
prefix: 'test-prefix',
logCallback: mockCallback
});
logger.info('Test message');
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('[test-prefix]')
);
});
it('should include timestamp when enabled', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
timestamp: true,
logCallback: mockCallback
});
logger.info('Test message');
const [[, message]] = mockCallback.mock.calls;
// Message should contain ISO timestamp pattern
expect(message).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('should format additional arguments', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.INFO,
logCallback: mockCallback
});
const data = { key: 'value' };
logger.info('Test message', data, 'string arg');
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('Test message')
);
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('"key"')
);
expect(mockCallback).toHaveBeenCalledWith(
'info',
expect.stringContaining('string arg')
);
});
});
describe('Edge cases', () => {
it('should handle null/undefined callback gracefully', () => {
const logger = new Logger({
level: LogLevel.INFO,
logCallback: undefined
});
const consoleSpy = vi.spyOn(console, 'log');
// Should fallback to console
logger.info('Test message');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should not call callback when level is SILENT', () => {
const mockCallback = vi.fn();
const logger = new Logger({
level: LogLevel.SILENT,
logCallback: mockCallback
});
logger.error('Error');
logger.warn('Warning');
logger.info('Info');
logger.debug('Debug');
expect(mockCallback).not.toHaveBeenCalled();
});
it('should propagate callback errors', () => {
const errorCallback: LogCallback = () => {
throw new Error('Callback error');
};
const logger = new Logger({
level: LogLevel.INFO,
logCallback: errorCallback
});
// Should throw
expect(() => {
logger.info('Test message');
}).toThrow('Callback error');
});
});
describe('Environment variable detection', () => {
it('should detect MCP mode from environment', () => {
const originalEnv = process.env.MCP_MODE;
process.env.MCP_MODE = 'true';
const logger = new Logger({
level: LogLevel.INFO
});
const config = logger.getConfig();
expect(config.mcpMode).toBe(true);
expect(config.silent).toBe(true); // Should be silent without callback
// Cleanup
if (originalEnv === undefined) {
delete process.env.MCP_MODE;
} else {
process.env.MCP_MODE = originalEnv;
}
});
it('should detect log level from environment', () => {
const originalEnv = process.env.TASK_MASTER_LOG_LEVEL;
process.env.TASK_MASTER_LOG_LEVEL = 'DEBUG';
const logger = new Logger();
const config = logger.getConfig();
expect(config.level).toBe(LogLevel.DEBUG);
// Cleanup
if (originalEnv === undefined) {
delete process.env.TASK_MASTER_LOG_LEVEL;
} else {
process.env.TASK_MASTER_LOG_LEVEL = originalEnv;
}
});
});
});

View File

@@ -12,25 +12,52 @@ export enum LogLevel {
DEBUG = 4
}
/**
* Log object interface (e.g., MCP context.log)
*/
export interface LogObject {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
debug: (message: string) => void;
}
/**
* Log callback can be either a function or a log object
*/
export type LogCallback =
| ((level: string, message: string) => void)
| LogObject;
export interface LoggerConfig {
level?: LogLevel;
silent?: boolean;
prefix?: string;
timestamp?: boolean;
colors?: boolean;
// MCP mode silences all output
// MCP mode silences all output (unless logCallback is provided)
mcpMode?: boolean;
// Callback function or object for logging (useful for MCP integration)
logCallback?: LogCallback;
}
export class Logger {
private config: Required<LoggerConfig>;
private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = {
private config: LoggerConfig & {
level: LogLevel;
silent: boolean;
prefix: string;
timestamp: boolean;
colors: boolean;
mcpMode: boolean;
};
private static readonly DEFAULT_CONFIG = {
level: LogLevel.SILENT,
silent: false,
prefix: '',
timestamp: false,
colors: true,
mcpMode: false
mcpMode: false,
logCallback: undefined as LogCallback | undefined
};
constructor(config: LoggerConfig = {}) {
@@ -80,8 +107,8 @@ export class Logger {
...envConfig
};
// MCP mode overrides everything to be silent
if (this.config.mcpMode) {
// MCP mode overrides to silent ONLY if no callback is provided
if (this.config.mcpMode && !this.config.logCallback) {
this.config.silent = true;
}
}
@@ -90,6 +117,12 @@ export class Logger {
* Check if logging is enabled for a given level
*/
private shouldLog(level: LogLevel): boolean {
// If a callback is provided, route logs through it while still respecting the configured level
if (this.config.logCallback) {
return level <= this.config.level;
}
// Otherwise, respect silent/mcpMode flags
if (this.config.silent || this.config.mcpMode) {
return false;
}
@@ -157,12 +190,66 @@ export class Logger {
return formatted;
}
/**
* Check if callback is a log object (has info/warn/error/debug methods)
*/
private isLogObject(callback: LogCallback): callback is LogObject {
return (
typeof callback === 'object' &&
callback !== null &&
'info' in callback &&
'warn' in callback &&
'error' in callback &&
'debug' in callback
);
}
/**
* Output a log message either to console or callback
*/
private output(
level: LogLevel,
levelName: string,
message: string,
...args: any[]
): void {
const formatted = this.formatMessage(level, message, ...args);
// Use callback if available
if (this.config.logCallback) {
// If callback is a log object, call the appropriate method
if (this.isLogObject(this.config.logCallback)) {
const method = levelName.toLowerCase() as keyof LogObject;
if (method in this.config.logCallback) {
this.config.logCallback[method](formatted);
}
} else {
// Otherwise it's a function callback
this.config.logCallback(levelName.toLowerCase(), formatted);
}
return;
}
// Otherwise use console
switch (level) {
case LogLevel.ERROR:
console.error(formatted);
break;
case LogLevel.WARN:
console.warn(formatted);
break;
default:
console.log(formatted);
break;
}
}
/**
* Log an error message
*/
error(message: string, ...args: any[]): void {
if (!this.shouldLog(LogLevel.ERROR)) return;
console.error(this.formatMessage(LogLevel.ERROR, message, ...args));
this.output(LogLevel.ERROR, 'ERROR', message, ...args);
}
/**
@@ -170,7 +257,7 @@ export class Logger {
*/
warn(message: string, ...args: any[]): void {
if (!this.shouldLog(LogLevel.WARN)) return;
console.warn(this.formatMessage(LogLevel.WARN, message, ...args));
this.output(LogLevel.WARN, 'WARN', message, ...args);
}
/**
@@ -178,7 +265,7 @@ export class Logger {
*/
info(message: string, ...args: any[]): void {
if (!this.shouldLog(LogLevel.INFO)) return;
console.log(this.formatMessage(LogLevel.INFO, message, ...args));
this.output(LogLevel.INFO, 'INFO', message, ...args);
}
/**
@@ -186,7 +273,7 @@ export class Logger {
*/
debug(message: string, ...args: any[]): void {
if (!this.shouldLog(LogLevel.DEBUG)) return;
console.log(this.formatMessage(LogLevel.DEBUG, message, ...args));
this.output(LogLevel.DEBUG, 'DEBUG', message, ...args);
}
/**
@@ -194,6 +281,22 @@ export class Logger {
* Useful for CLI output that should appear as-is
*/
log(message: string, ...args: any[]): void {
// If callback is provided, use it for raw logs too
if (this.config.logCallback) {
const fullMessage =
args.length > 0 ? [message, ...args].join(' ') : message;
// If callback is a log object, use info method for raw logs
if (this.isLogObject(this.config.logCallback)) {
this.config.logCallback.info(fullMessage);
} else {
// Otherwise it's a function callback
this.config.logCallback('log', fullMessage);
}
return;
}
// Otherwise, respect silent/mcpMode
if (this.config.silent || this.config.mcpMode) return;
if (args.length > 0) {
@@ -212,8 +315,8 @@ export class Logger {
...config
};
// MCP mode always overrides to silent
if (this.config.mcpMode) {
// MCP mode overrides to silent ONLY if no callback is provided
if (this.config.mcpMode && !this.config.logCallback) {
this.config.silent = true;
}
}
@@ -221,7 +324,16 @@ export class Logger {
/**
* Get current configuration
*/
getConfig(): Readonly<Required<LoggerConfig>> {
getConfig(): Readonly<
LoggerConfig & {
level: LogLevel;
silent: boolean;
prefix: string;
timestamp: boolean;
colors: boolean;
mcpMode: boolean;
}
> {
return { ...this.config };
}

View File

@@ -149,6 +149,16 @@ export class ApiStorage implements IStorage {
return 'api';
}
/**
* Get the current brief name
* @returns The brief name if a brief is selected, null otherwise
*/
getCurrentBriefName(): string | null {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
return context?.briefName || null;
}
/**
* Load tags into cache
* In our API-based system, "tags" represent briefs

View File

@@ -55,6 +55,14 @@ export class FileStorage implements IStorage {
return 'file';
}
/**
* Get the current brief name (not applicable for file storage)
* @returns null (file storage doesn't use briefs)
*/
getCurrentBriefName(): null {
return null;
}
/**
* Get statistics about the storage
*/

View File

@@ -26,7 +26,7 @@ export interface TaskListResult {
total: number;
/** Number of tasks after filtering */
filtered: number;
/** The tag these tasks belong to (only present if explicitly provided) */
/** The tag/brief name for these tasks (brief name for API storage, tag for file storage) */
tag?: string;
/** Storage type being used */
storageType: StorageType;
@@ -153,12 +153,20 @@ export class TaskService {
// Convert back to plain objects
const tasks = filteredEntities.map((entity) => entity.toJSON());
// For API storage, use brief name. For file storage, use tag.
// This way consumers don't need to know about the difference.
const storageType = this.getStorageType();
const tagOrBrief =
storageType === 'api'
? this.storage.getCurrentBriefName() || tag
: tag;
return {
tasks,
total: allTasks.length,
filtered: filteredEntities.length,
tag: tag, // Return the actual tag being used (either explicitly provided or active tag)
storageType: this.getStorageType()
tag: tagOrBrief, // For API: brief name, For file: tag
storageType
};
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't log it as an internal error

View File

@@ -17,6 +17,11 @@ import {
TaskMasterError
} from './common/errors/task-master-error.js';
import type { IConfiguration } from './common/interfaces/configuration.interface.js';
import {
createLogger,
type LoggerConfig,
type Logger
} from './common/logger/index.js';
/**
* Options for creating TmCore instance
@@ -26,12 +31,14 @@ export interface TmCoreOptions {
projectPath: string;
/** Optional configuration overrides */
configuration?: Partial<IConfiguration>;
/** Optional logger configuration for MCP integration and debugging */
loggerConfig?: LoggerConfig;
}
/**
* TmCore - Unified facade providing access to all Task Master domains
*
* @example
* @example Basic usage
* ```typescript
* const tmcore = await createTmCore({ projectPath: process.cwd() });
*
@@ -43,11 +50,35 @@ export interface TmCoreOptions {
* const modelConfig = tmcore.config.getModelConfig();
* await tmcore.integration.exportTasks({ ... });
* ```
*
* @example MCP integration with logging
* ```typescript
* import { LogLevel } from '@tm/core/logger';
*
* // In MCP tool execute function
* async function execute(args, log) {
* const tmcore = await createTmCore({
* projectPath: args.projectRoot,
* loggerConfig: {
* level: LogLevel.INFO,
* mcpMode: true,
* logCallback: log // MCP log function
* }
* });
*
* // All internal logging will now be sent to MCP
* const tasks = await tmcore.tasks.list();
*
* // You can also log custom messages
* tmcore.logger.info('Operation completed');
* }
* ```
*/
export class TmCore {
// Core infrastructure
private readonly _projectPath: string;
private _configManager!: ConfigManager;
private _logger!: Logger;
// Private writable properties
private _tasks!: TasksDomain;
@@ -76,6 +107,9 @@ export class TmCore {
get integration(): IntegrationDomain {
return this._integration;
}
get logger(): Logger {
return this._logger;
}
/**
* Create and initialize a new TmCore instance
@@ -124,6 +158,9 @@ export class TmCore {
*/
private async initialize(): Promise<void> {
try {
// Initialize logger first (before anything else that might log)
this._logger = createLogger(this._options.loggerConfig);
// Create config manager
this._configManager = await ConfigManager.create(this._projectPath);
@@ -142,7 +179,15 @@ export class TmCore {
// Initialize domains that need async setup
await this._tasks.initialize();
// Log successful initialization
this._logger.info('TmCore initialized successfully');
} catch (error) {
// Log error if logger is available
if (this._logger) {
this._logger.error('Failed to initialize TmCore:', error);
}
throw new TaskMasterError(
'Failed to initialize TmCore',
ERROR_CODES.INTERNAL_ERROR,