mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: connect get-task and get-tasks to remote (#1346)
This commit is contained in:
7
.changeset/four-bugs-occur.md
Normal file
7
.changeset/four-bugs-occur.md
Normal 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
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
136
apps/mcp/src/tools/tasks/get-task.tool.ts
Normal file
136
apps/mcp/src/tools/tasks/get-task.tool.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
166
apps/mcp/src/tools/tasks/get-tasks.tool.ts
Normal file
166
apps/mcp/src/tools/tasks/get-tasks.tool.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
7
apps/mcp/src/tools/tasks/index.ts
Normal file
7
apps/mcp/src/tools/tasks/index.ts
Normal 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';
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
389
packages/tm-core/src/common/logger/logger.spec.ts
Normal file
389
packages/tm-core/src/common/logger/logger.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user