From 25addf919f58b439dbc2f6ccf5be822b48280fa3 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:23:27 +0100 Subject: [PATCH] feat: connect get-task and get-tasks to remote (#1346) --- .changeset/four-bugs-occur.md | 7 + apps/mcp/src/index.ts | 1 + apps/mcp/src/shared/utils.ts | 19 +- apps/mcp/src/tools/tasks/get-task.tool.ts | 136 ++++++ apps/mcp/src/tools/tasks/get-tasks.tool.ts | 166 ++++++++ apps/mcp/src/tools/tasks/index.ts | 7 + .../src/core/direct-functions/list-tasks.js | 114 ----- .../src/core/direct-functions/show-task.js | 164 -------- mcp-server/src/core/task-master-core.js | 6 - mcp-server/src/tools/get-task.js | 152 ------- mcp-server/src/tools/get-tasks.js | 124 ------ mcp-server/src/tools/tool-registry.js | 14 +- .../common/interfaces/storage.interface.ts | 8 +- packages/tm-core/src/common/logger/index.ts | 2 +- .../tm-core/src/common/logger/logger.spec.ts | 389 ++++++++++++++++++ packages/tm-core/src/common/logger/logger.ts | 138 ++++++- .../modules/storage/adapters/api-storage.ts | 10 + .../adapters/file-storage/file-storage.ts | 8 + .../modules/tasks/services/task-service.ts | 14 +- packages/tm-core/src/tm-core.ts | 47 ++- 20 files changed, 937 insertions(+), 589 deletions(-) create mode 100644 .changeset/four-bugs-occur.md create mode 100644 apps/mcp/src/tools/tasks/get-task.tool.ts create mode 100644 apps/mcp/src/tools/tasks/get-tasks.tool.ts create mode 100644 apps/mcp/src/tools/tasks/index.ts delete mode 100644 mcp-server/src/core/direct-functions/list-tasks.js delete mode 100644 mcp-server/src/core/direct-functions/show-task.js delete mode 100644 mcp-server/src/tools/get-task.js delete mode 100644 mcp-server/src/tools/get-tasks.js create mode 100644 packages/tm-core/src/common/logger/logger.spec.ts diff --git a/.changeset/four-bugs-occur.md b/.changeset/four-bugs-occur.md new file mode 100644 index 00000000..26fcc259 --- /dev/null +++ b/.changeset/four-bugs-occur.md @@ -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 diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 93c31ab1..44cd7df5 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -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'; diff --git a/apps/mcp/src/shared/utils.ts b/apps/mcp/src/shared/utils.ts index 8d226f33..165bc1fd 100644 --- a/apps/mcp/src/shared/utils.ts +++ b/apps/mcp/src/shared/utils.ts @@ -45,14 +45,27 @@ export async function handleApiResult(options: { log?: any; errorPrefix?: string; projectRoot?: string; + tag?: string; // Optional tag/brief to use instead of reading from state.json }): Promise { - 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}`; diff --git a/apps/mcp/src/tools/tasks/get-task.tool.ts b/apps/mcp/src/tools/tasks/get-task.tool.ts new file mode 100644 index 00000000..39f258bf --- /dev/null +++ b/apps/mcp/src/tools/tasks/get-task.tool.ts @@ -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; + +/** + * 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 + }); + } + } + ) + }); +} diff --git a/apps/mcp/src/tools/tasks/get-tasks.tool.ts b/apps/mcp/src/tools/tasks/get-tasks.tool.ts new file mode 100644 index 00000000..1344e723 --- /dev/null +++ b/apps/mcp/src/tools/tasks/get-tasks.tool.ts @@ -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; + +/** + * 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 + ); + + 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 + ); + + 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 + }); + } + } + ) + }); +} diff --git a/apps/mcp/src/tools/tasks/index.ts b/apps/mcp/src/tools/tasks/index.ts new file mode 100644 index 00000000..c3105ac2 --- /dev/null +++ b/apps/mcp/src/tools/tasks/index.ts @@ -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'; diff --git a/mcp-server/src/core/direct-functions/list-tasks.js b/mcp-server/src/core/direct-functions/list-tasks.js deleted file mode 100644 index 9511f43b..00000000 --- a/mcp-server/src/core/direct-functions/list-tasks.js +++ /dev/null @@ -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} - 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 - } - }; - } -} diff --git a/mcp-server/src/core/direct-functions/show-task.js b/mcp-server/src/core/direct-functions/show-task.js deleted file mode 100644 index 9e168615..00000000 --- a/mcp-server/src/core/direct-functions/show-task.js +++ /dev/null @@ -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} - 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 - } - }; - } -} diff --git a/mcp-server/src/core/task-master-core.js b/mcp-server/src/core/task-master-core.js index 239838b0..bfb9dead 100644 --- a/mcp-server/src/core/task-master-core.js +++ b/mcp-server/src/core/task-master-core.js @@ -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, diff --git a/mcp-server/src/tools/get-task.js b/mcp-server/src/tools/get-task.js deleted file mode 100644 index e477cf1e..00000000 --- a/mcp-server/src/tools/get-task.js +++ /dev/null @@ -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}`); - } - }) - }); -} diff --git a/mcp-server/src/tools/get-tasks.js b/mcp-server/src/tools/get-tasks.js deleted file mode 100644 index c6162d79..00000000 --- a/mcp-server/src/tools/get-tasks.js +++ /dev/null @@ -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 diff --git a/mcp-server/src/tools/tool-registry.js b/mcp-server/src/tools/tool-registry.js index 845de603..90acd5b6 100644 --- a/mcp-server/src/tools/tool-registry.js +++ b/mcp-server/src/tools/tool-registry.js @@ -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, diff --git a/packages/tm-core/src/common/interfaces/storage.interface.ts b/packages/tm-core/src/common/interfaces/storage.interface.ts index e2d18d11..4f400797 100644 --- a/packages/tm-core/src/common/interfaces/storage.interface.ts +++ b/packages/tm-core/src/common/interfaces/storage.interface.ts @@ -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; abstract getStats(): Promise; abstract getStorageType(): 'file' | 'api'; - + abstract getCurrentBriefName(): string | null; /** * Utility method to generate backup filename * @param originalPath - Original file path diff --git a/packages/tm-core/src/common/logger/index.ts b/packages/tm-core/src/common/logger/index.ts index 4c500444..728621a7 100644 --- a/packages/tm-core/src/common/logger/index.ts +++ b/packages/tm-core/src/common/logger/index.ts @@ -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'; diff --git a/packages/tm-core/src/common/logger/logger.spec.ts b/packages/tm-core/src/common/logger/logger.spec.ts new file mode 100644 index 00000000..43cfb304 --- /dev/null +++ b/packages/tm-core/src/common/logger/logger.spec.ts @@ -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; + + 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; + } + }); + }); +}); diff --git a/packages/tm-core/src/common/logger/logger.ts b/packages/tm-core/src/common/logger/logger.ts index 09f8c807..7611f724 100644 --- a/packages/tm-core/src/common/logger/logger.ts +++ b/packages/tm-core/src/common/logger/logger.ts @@ -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; - private static readonly DEFAULT_CONFIG: Required = { + 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> { + getConfig(): Readonly< + LoggerConfig & { + level: LogLevel; + silent: boolean; + prefix: string; + timestamp: boolean; + colors: boolean; + mcpMode: boolean; + } + > { return { ...this.config }; } diff --git a/packages/tm-core/src/modules/storage/adapters/api-storage.ts b/packages/tm-core/src/modules/storage/adapters/api-storage.ts index e80ed67a..babfa274 100644 --- a/packages/tm-core/src/modules/storage/adapters/api-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/api-storage.ts @@ -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 diff --git a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts index ca0717db..e3d23a52 100644 --- a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts @@ -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 */ diff --git a/packages/tm-core/src/modules/tasks/services/task-service.ts b/packages/tm-core/src/modules/tasks/services/task-service.ts index b2397a57..94143f52 100644 --- a/packages/tm-core/src/modules/tasks/services/task-service.ts +++ b/packages/tm-core/src/modules/tasks/services/task-service.ts @@ -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 diff --git a/packages/tm-core/src/tm-core.ts b/packages/tm-core/src/tm-core.ts index a63de651..35ae76ff 100644 --- a/packages/tm-core/src/tm-core.ts +++ b/packages/tm-core/src/tm-core.ts @@ -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; + /** 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 { 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,