diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e633d4c..075b2e2 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -63,12 +63,11 @@ jobs: - name: Create GitHub Release if: steps.publish.outputs.type != 'none' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: ncipollo/release-action@v1 with: - tag_name: v${{ steps.package_version.outputs.version }} - release_name: Release v${{ steps.package_version.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + tag: v${{ steps.package_version.outputs.version }} + name: Release v${{ steps.package_version.outputs.version }} body: | ${{ steps.pr_message.outputs.message }} diff --git a/index.ts b/index.ts index 5c095ac..07a42a1 100644 --- a/index.ts +++ b/index.ts @@ -10,7 +10,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot const server = new Server( { name: "task-manager-server", - version: "1.1.0" + version: "1.1.1" }, { capabilities: { diff --git a/package-lock.json b/package-lock.json index b73956c..7d0321f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "taskqueue-mcp", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taskqueue-mcp", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.7.0", diff --git a/package.json b/package.json index 44eb5cf..43aff66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "taskqueue-mcp", - "version": "1.1.0", + "version": "1.1.1", "description": "Task Queue MCP Server", "author": "Christopher C. Smith (christopher.smith@promptlytechnologies.com)", "main": "dist/index.js", diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts new file mode 100644 index 0000000..9d77d0b --- /dev/null +++ b/src/server/toolExecutors.ts @@ -0,0 +1,413 @@ +import { TaskManager } from "./TaskManager.js"; +import { ErrorCode } from "../types/index.js"; +import { createError } from "../utils/errors.js"; + +/** + * Interface defining the contract for tool executors. + * Each tool executor is responsible for executing a specific tool's logic + * and handling its input validation and response formatting. + */ +interface ToolExecutor { + /** The name of the tool this executor handles */ + name: string; + + /** + * Executes the tool's logic with the given arguments + * @param taskManager The TaskManager instance to use for task-related operations + * @param args The arguments passed to the tool as a key-value record + * @returns A promise that resolves to the tool's response, containing an array of text content + */ + execute: ( + taskManager: TaskManager, + args: Record + ) => Promise<{ + content: Array<{ type: "text"; text: string }>; + isError?: boolean; + }>; +} + +// ---------------------- UTILITY FUNCTIONS ---------------------- + +/** + * Formats any data into the standard tool response format. + */ +function formatToolResponse(data: unknown): { content: Array<{ type: "text"; text: string }> } { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +/** + * Throws an error if a required parameter is not present or not a string. + */ +function validateRequiredStringParam(param: unknown, paramName: string): string { + if (typeof param !== "string" || !param) { + throw createError(ErrorCode.MissingParameter, `Missing or invalid required parameter: ${paramName}`); + } + return param; +} + +/** + * Validates that a project ID parameter exists and is a string. + */ +function validateProjectId(projectId: unknown): string { + return validateRequiredStringParam(projectId, "projectId"); +} + +/** + * Validates that a task ID parameter exists and is a string. + */ +function validateTaskId(taskId: unknown): string { + return validateRequiredStringParam(taskId, "taskId"); +} + +/** + * Throws an error if tasks is not defined or not an array. + */ +function validateTaskList(tasks: unknown): void { + if (!Array.isArray(tasks)) { + throw createError(ErrorCode.MissingParameter, "Missing required parameter: tasks"); + } +} + +/** + * Validates an optional “state” parameter against the allowed states. + */ +function validateOptionalStateParam( + state: unknown, + validStates: Array +): string | undefined { + if (state === undefined) return undefined; + if (typeof state === "string" && validStates.includes(state)) return state; + throw createError( + ErrorCode.InvalidArgument, + `Invalid state parameter. Must be one of: ${validStates.join(", ")}` + ); +} + +/** + * Validates an array of task objects, ensuring each has required fields. + */ +function validateTaskObjects( + tasks: unknown, + errorPrefix?: string +): Array<{ + title: string; + description: string; + toolRecommendations?: string; + ruleRecommendations?: string; +}> { + validateTaskList(tasks); + const taskArray = tasks as Array; + + return taskArray.map((task, index) => { + if (!task || typeof task !== "object") { + throw createError( + ErrorCode.InvalidArgument, + `${errorPrefix || "Task"} at index ${index} must be an object.` + ); + } + + const t = task as Record; + const title = validateRequiredStringParam(t.title, `title in task at index ${index}`); + const description = validateRequiredStringParam(t.description, `description in task at index ${index}`); + + return { + title, + description, + toolRecommendations: t.toolRecommendations ? String(t.toolRecommendations) : undefined, + ruleRecommendations: t.ruleRecommendations ? String(t.ruleRecommendations) : undefined, + }; + }); +} + +// ---------------------- TOOL EXECUTOR MAP ---------------------- + +export const toolExecutorMap: Map = new Map(); + +// ---------------------- TOOL EXECUTORS ---------------------- + +/** + * Tool executor for listing projects with optional state filtering + */ +const listProjectsToolExecutor: ToolExecutor = { + name: "list_projects", + async execute(taskManager, args) { + const state = validateOptionalStateParam(args.state, [ + "open", + "pending_approval", + "completed", + "all", + ]); + + const result = await taskManager.listProjects(state as any); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(listProjectsToolExecutor.name, listProjectsToolExecutor); + +/** + * Tool executor for creating new projects with tasks + */ +const createProjectToolExecutor: ToolExecutor = { + name: "create_project", + async execute(taskManager, args) { + const initialPrompt = validateRequiredStringParam(args.initialPrompt, "initialPrompt"); + const validatedTasks = validateTaskObjects(args.tasks, "Task"); + + const projectPlan = args.projectPlan ? String(args.projectPlan) : undefined; + const autoApprove = args.autoApprove === true; + + const result = await taskManager.createProject( + initialPrompt, + validatedTasks, + projectPlan, + autoApprove + ); + + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(createProjectToolExecutor.name, createProjectToolExecutor); + +/** + * Tool executor for getting the next task in a project + */ +const getNextTaskToolExecutor: ToolExecutor = { + name: "get_next_task", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const result = await taskManager.getNextTask(projectId); + + // Ensure backward compatibility with integration tests + if (result.status === "next_task" && result.data) { + return formatToolResponse({ + status: "next_task", + task: result.data, + message: result.data.message, + }); + } + + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(getNextTaskToolExecutor.name, getNextTaskToolExecutor); + +/** + * Tool executor for updating a task + */ +const updateTaskToolExecutor: ToolExecutor = { + name: "update_task", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const taskId = validateTaskId(args.taskId); + + const updates: Record = {}; + + // Optional fields + if (args.title !== undefined) { + updates.title = validateRequiredStringParam(args.title, "title"); + } + if (args.description !== undefined) { + updates.description = validateRequiredStringParam(args.description, "description"); + } + if (args.toolRecommendations !== undefined) { + if (typeof args.toolRecommendations !== "string") { + throw createError( + ErrorCode.InvalidArgument, + "Invalid toolRecommendations: must be a string" + ); + } + updates.toolRecommendations = args.toolRecommendations; + } + if (args.ruleRecommendations !== undefined) { + if (typeof args.ruleRecommendations !== "string") { + throw createError( + ErrorCode.InvalidArgument, + "Invalid ruleRecommendations: must be a string" + ); + } + updates.ruleRecommendations = args.ruleRecommendations; + } + + // Status transitions + if (args.status !== undefined) { + const status = args.status; + if ( + typeof status !== "string" || + !["not started", "in progress", "done"].includes(status) + ) { + throw createError( + ErrorCode.InvalidArgument, + "Invalid status: must be one of 'not started', 'in progress', 'done'" + ); + } + if (status === "done") { + updates.completedDetails = validateRequiredStringParam( + args.completedDetails, + "completedDetails (required when status = 'done')" + ); + } + updates.status = status; + } + + const result = await taskManager.updateTask(projectId, taskId, updates); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(updateTaskToolExecutor.name, updateTaskToolExecutor); + +/** + * Tool executor for reading project details + */ +const readProjectToolExecutor: ToolExecutor = { + name: "read_project", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const result = await taskManager.readProject(projectId); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(readProjectToolExecutor.name, readProjectToolExecutor); + +/** + * Tool executor for deleting projects + */ +const deleteProjectToolExecutor: ToolExecutor = { + name: "delete_project", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + + const projectIndex = taskManager["data"].projects.findIndex( + (p) => p.projectId === projectId + ); + if (projectIndex === -1) { + return formatToolResponse({ + status: "error", + message: "Project not found", + }); + } + + // Remove project and save + taskManager["data"].projects.splice(projectIndex, 1); + await taskManager["saveTasks"](); + + return formatToolResponse({ + status: "project_deleted", + message: `Project ${projectId} has been deleted.`, + }); + }, +}; +toolExecutorMap.set(deleteProjectToolExecutor.name, deleteProjectToolExecutor); + +/** + * Tool executor for adding tasks to a project + */ +const addTasksToProjectToolExecutor: ToolExecutor = { + name: "add_tasks_to_project", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const tasks = validateTaskObjects(args.tasks, "Task"); + + const result = await taskManager.addTasksToProject(projectId, tasks); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(addTasksToProjectToolExecutor.name, addTasksToProjectToolExecutor); + +/** + * Tool executor for finalizing (completing) projects + */ +const finalizeProjectToolExecutor: ToolExecutor = { + name: "finalize_project", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const result = await taskManager.approveProjectCompletion(projectId); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(finalizeProjectToolExecutor.name, finalizeProjectToolExecutor); + +/** + * Tool executor for listing tasks with optional projectId and state + */ +const listTasksToolExecutor: ToolExecutor = { + name: "list_tasks", + async execute(taskManager, args) { + const projectId = args.projectId !== undefined ? validateProjectId(args.projectId) : undefined; + const state = validateOptionalStateParam(args.state, [ + "open", + "pending_approval", + "completed", + "all", + ]); + + const result = await taskManager.listTasks(projectId, state as any); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(listTasksToolExecutor.name, listTasksToolExecutor); + +/** + * Tool executor for reading task details + */ +const readTaskToolExecutor: ToolExecutor = { + name: "read_task", + async execute(taskManager, args) { + const taskId = validateTaskId(args.taskId); + const result = await taskManager.openTaskDetails(taskId); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); + +/** + * Tool executor for creating an individual task in a project + */ +const createTaskToolExecutor: ToolExecutor = { + name: "create_task", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const title = validateRequiredStringParam(args.title, "title"); + const description = validateRequiredStringParam(args.description, "description"); + + const singleTask = { + title, + description, + toolRecommendations: args.toolRecommendations ? String(args.toolRecommendations) : undefined, + ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined, + }; + + const result = await taskManager.addTasksToProject(projectId, [singleTask]); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(createTaskToolExecutor.name, createTaskToolExecutor); + +/** + * Tool executor for deleting tasks + */ +const deleteTaskToolExecutor: ToolExecutor = { + name: "delete_task", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const taskId = validateTaskId(args.taskId); + + const result = await taskManager.deleteTask(projectId, taskId); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(deleteTaskToolExecutor.name, deleteTaskToolExecutor); + +/** + * Tool executor for approving completed tasks + */ +const approveTaskToolExecutor: ToolExecutor = { + name: "approve_task", + async execute(taskManager, args) { + const projectId = validateProjectId(args.projectId); + const taskId = validateTaskId(args.taskId); + + const result = await taskManager.approveTaskCompletion(projectId, taskId); + return formatToolResponse(result); + }, +}; +toolExecutorMap.set(approveTaskToolExecutor.name, approveTaskToolExecutor); \ No newline at end of file diff --git a/src/server/tools.ts b/src/server/tools.ts index 4453b4f..45db77e 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -2,10 +2,15 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { TaskManager } from "./TaskManager.js"; import { ErrorCode } from "../types/index.js"; import { createError, normalizeError } from "../utils/errors.js"; +import { toolExecutorMap } from "./toolExecutors.js"; // ---------------------- PROJECT TOOLS ---------------------- -// List Projects +/** + * List Projects Tool + * @param {object} args - A JSON object containing the arguments + * @see {listProjectsToolExecutor} + */ const listProjectsTool: Tool = { name: "list_projects", description: "List all projects in the system and their basic information (ID, initial prompt, task counts), optionally filtered by state (open, pending_approval, completed, all).", @@ -22,7 +27,11 @@ const listProjectsTool: Tool = { }, }; -// Read Project +/** + * Read Project Tool + * @param {object} args - A JSON object containing the arguments + * @see {readProjectToolExecutor} + */ const readProjectTool: Tool = { name: "read_project", description: "Read all information for a given project, by its ID, including its tasks' statuses.", @@ -38,7 +47,11 @@ const readProjectTool: Tool = { }, }; -// Create Project +/** + * Create Project Tool + * @param {object} args - A JSON object containing the arguments + * @see {createProjectToolExecutor} + */ const createProjectTool: Tool = { name: "create_project", description: "Create a new project with an initial prompt and a list of tasks. This is typically the first step in any workflow.", @@ -88,7 +101,11 @@ const createProjectTool: Tool = { }, }; -// Delete Project +/** + * Delete Project Tool + * @param {object} args - A JSON object containing the arguments + * @see {deleteProjectToolExecutor} + */ const deleteProjectTool: Tool = { name: "delete_project", description: "Delete a project and all its associated tasks.", @@ -104,7 +121,11 @@ const deleteProjectTool: Tool = { }, }; -// Add Tasks to Project +/** + * Add Tasks to Project Tool + * @param {object} args - A JSON object containing the arguments + * @see {addTasksToProjectToolExecutor} + */ const addTasksToProjectTool: Tool = { name: "add_tasks_to_project", description: "Add new tasks to an existing project.", @@ -146,7 +167,11 @@ const addTasksToProjectTool: Tool = { }, }; -// Finalize Project (Mark as Complete) +/** + * Finalize Project Tool + * @param {object} args - A JSON object containing the arguments + * @see {finalizeProjectToolExecutor} + */ const finalizeProjectTool: Tool = { name: "finalize_project", description: "Mark a project as complete. Can only be called when all tasks are both done and approved. This is typically the last step in a project workflow.", @@ -164,7 +189,11 @@ const finalizeProjectTool: Tool = { // ---------------------- TASK TOOLS ---------------------- -// List Tasks +/** + * List Tasks Tool + * @param {object} args - A JSON object containing the arguments + * @see {listTasksToolExecutor} + */ const listTasksTool: Tool = { name: "list_tasks", description: "List all tasks, optionally filtered by project ID and/or state (open, pending_approval, completed, all). Tasks may include tool and rule recommendations to guide their completion.", @@ -185,7 +214,11 @@ const listTasksTool: Tool = { }, }; -// Read Task +/** + * Read Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {readTaskToolExecutor} + */ const readTaskTool: Tool = { name: "read_task", description: "Get details of a specific task by its ID. The task may include toolRecommendations and ruleRecommendations fields that should be used to guide task completion.", @@ -201,7 +234,11 @@ const readTaskTool: Tool = { }, }; -// Create Task +/** + * Create Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {createTaskToolExecutor} + */ const createTaskTool: Tool = { name: "create_task", description: "Create a new task within an existing project. You can optionally include tool and rule recommendations to guide task completion.", @@ -233,7 +270,11 @@ const createTaskTool: Tool = { } }; -// Update Task +/** + * Update Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {updateTaskToolExecutor} + */ const updateTaskTool: Tool = { name: "update_task", description: "Modify a task's properties. Note: (1) completedDetails are required when setting status to 'done', (2) approved tasks cannot be modified, (3) status must follow valid transitions: not started → in progress → done. You can also update tool and rule recommendations to guide task completion.", @@ -278,7 +319,11 @@ const updateTaskTool: Tool = { }, }; -// Delete Task +/** + * Delete Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {deleteTaskToolExecutor} + */ const deleteTaskTool: Tool = { name: "delete_task", description: "Remove a task from a project.", @@ -298,7 +343,11 @@ const deleteTaskTool: Tool = { }, }; -// Approve Task +/** + * Approve Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {approveTaskToolExecutor} + */ const approveTaskTool: Tool = { name: "approve_task", description: "Approve a completed task. Tasks must be marked as 'done' with completedDetails before approval. Note: This is a CLI-only operation that requires human intervention.", @@ -318,7 +367,11 @@ const approveTaskTool: Tool = { } }; -// Get Next Task +/** + * Get Next Task Tool + * @param {object} args - A JSON object containing the arguments + * @see {getNextTaskToolExecutor} + */ const getNextTaskTool: Tool = { name: "get_next_task", description: "Get the next task to be done in a project. Returns the first non-approved task in sequence, regardless of status. The task may include toolRecommendations and ruleRecommendations fields that should be used to guide task completion.", @@ -352,252 +405,31 @@ export const ALL_TOOLS: Tool[] = [ getNextTaskTool, ]; -// Error handling wrapper for tool execution +/** + * Executes a tool with error handling and standardized response formatting. + * Uses the toolExecutorMap to look up and execute the appropriate tool executor. + * + * @param toolName The name of the tool to execute + * @param args The arguments to pass to the tool + * @param taskManager The TaskManager instance to use + * @returns A promise that resolves to the tool's response + * @throws {Error} If the tool is not found or if execution fails + */ export async function executeToolWithErrorHandling( toolName: string, args: Record, taskManager: TaskManager ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> { try { - switch (toolName) { - case "list_projects": { - const result = await taskManager.listProjects(); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "read_project": { - const projectId = String(args.projectId); - if (!projectId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameter: projectId" - ); - } - const result = await taskManager.readProject(projectId); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "create_project": { - const initialPrompt = String(args.initialPrompt || ""); - if (!initialPrompt || !args.tasks || !Array.isArray(args.tasks)) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: initialPrompt and/or tasks" - ); - } - const projectPlan = args.projectPlan ? String(args.projectPlan) : undefined; - const autoApprove = args.autoApprove === true; - - const result = await taskManager.createProject( - initialPrompt, - args.tasks, - projectPlan, - autoApprove - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "delete_project": { - const projectId = String(args.projectId); - if (!projectId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameter: projectId" - ); - } - // Use the private data and saveTasks via indexing since there's no explicit delete method - const projectIndex = taskManager["data"].projects.findIndex((p) => p.projectId === projectId); - if (projectIndex === -1) { - return { - content: [{ type: "text", text: JSON.stringify({ status: "error", message: "Project not found" }, null, 2) }], - }; - } - - taskManager["data"].projects.splice(projectIndex, 1); - await taskManager["saveTasks"](); - return { - content: [{ type: "text", text: JSON.stringify({ - status: "project_deleted", - message: `Project ${projectId} has been deleted.` - }, null, 2) }], - }; - } - - case "add_tasks_to_project": { - const projectId = String(args.projectId); - if (!projectId || !args.tasks || !Array.isArray(args.tasks)) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: projectId and/or tasks" - ); - } - const result = await taskManager.addTasksToProject(projectId, args.tasks); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "finalize_project": { - const projectId = String(args.projectId); - if (!projectId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameter: projectId" - ); - } - const result = await taskManager.approveProjectCompletion(projectId); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - // Task tools - case "list_tasks": { - const projectId = args.projectId ? String(args.projectId) : undefined; - const state = args.state ? String(args.state as string) : undefined; - const result = await taskManager.listTasks(projectId, state as any); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "read_task": { - const taskId = String(args.taskId); - if (!taskId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameter: taskId" - ); - } - const result = await taskManager.openTaskDetails(taskId); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "create_task": { - const projectId = String(args.projectId); - const title = String(args.title || ""); - const description = String(args.description || ""); - - if (!projectId || !title || !description) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: projectId, title, and/or description" - ); - } - - const result = await taskManager.addTasksToProject(projectId, [{ - title, - description, - toolRecommendations: args.toolRecommendations ? String(args.toolRecommendations) : undefined, - ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined - }]); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "update_task": { - const projectId = String(args.projectId); - const taskId = String(args.taskId); - - if (!projectId || !taskId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: projectId and/or taskId" - ); - } - - const updates = Object.fromEntries( - Object.entries({ - title: args.title !== undefined ? String(args.title) : undefined, - description: args.description !== undefined ? String(args.description) : undefined, - status: args.status !== undefined ? String(args.status) as "not started" | "in progress" | "done" : undefined, - completedDetails: args.completedDetails !== undefined ? String(args.completedDetails) : undefined, - toolRecommendations: args.toolRecommendations !== undefined ? String(args.toolRecommendations) : undefined, - ruleRecommendations: args.ruleRecommendations !== undefined ? String(args.ruleRecommendations) : undefined - }).filter(([_, value]) => value !== undefined) - ); - - const result = await taskManager.updateTask(projectId, taskId, updates); - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "delete_task": { - const projectId = String(args.projectId); - const taskId = String(args.taskId); - - if (!projectId || !taskId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: projectId and/or taskId" - ); - } - const result = await taskManager.deleteTask(projectId, taskId); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "approve_task": { - const projectId = String(args.projectId); - const taskId = String(args.taskId); - - if (!projectId || !taskId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameters: projectId and/or taskId" - ); - } - const result = await taskManager.approveTaskCompletion(projectId, taskId); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "get_next_task": { - const projectId = String(args.projectId); - if (!projectId) { - throw createError( - ErrorCode.MissingParameter, - "Missing required parameter: projectId" - ); - } - const result = await taskManager.getNextTask(projectId); - - // Ensure backward compatibility with integration tests - // by adding a task property that refers to the data - if (result.status === "next_task" && result.data) { - return { - content: [{ type: "text", text: JSON.stringify({ - status: "next_task", - task: result.data, - message: result.data.message - }, null, 2) }], - }; - } - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - default: - throw createError( - ErrorCode.InvalidArgument, - `Unknown tool: ${toolName}` - ); + const executor = toolExecutorMap.get(toolName); + if (!executor) { + throw createError( + ErrorCode.InvalidArgument, + `Unknown tool: ${toolName}` + ); } + + return await executor.execute(taskManager, args); } catch (error) { const standardError = normalizeError(error); return { diff --git a/tests/unit/toolExecutors.test.ts b/tests/unit/toolExecutors.test.ts new file mode 100644 index 0000000..eb7356a --- /dev/null +++ b/tests/unit/toolExecutors.test.ts @@ -0,0 +1,611 @@ +import { jest, describe, it, expect } from '@jest/globals'; +import { TaskManager } from '../../src/server/TaskManager.js'; +import { toolExecutorMap } from '../../src/server/toolExecutors.js'; +import { ErrorCode } from '../../src/types/index.js'; +import { Task } from '../../src/types/index.js'; + +// Mock TaskManager +jest.mock('../../src/server/TaskManager.js'); + +type SaveTasksFn = () => Promise; + +describe('Tool Executors', () => { + let taskManager: jest.Mocked; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Create a new mock instance + taskManager = { + listProjects: jest.fn(), + createProject: jest.fn(), + getNextTask: jest.fn(), + updateTask: jest.fn(), + readProject: jest.fn(), + deleteProject: jest.fn(), + addTasksToProject: jest.fn(), + approveProjectCompletion: jest.fn(), + listTasks: jest.fn(), + openTaskDetails: jest.fn(), + deleteTask: jest.fn(), + approveTaskCompletion: jest.fn() + } as unknown as jest.Mocked; + }); + + // Utility Function Tests + describe('Utility Functions', () => { + describe('validateProjectId', () => { + it('should throw error for missing projectId', async () => { + const executor = toolExecutorMap.get('read_project')!; + await expect(executor.execute(taskManager, {})) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('projectId') + }); + }); + + it('should throw error for non-string projectId', async () => { + const executor = toolExecutorMap.get('read_project')!; + await expect(executor.execute(taskManager, { projectId: 123 })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('projectId') + }); + }); + }); + + describe('validateTaskId', () => { + it('should throw error for missing taskId', async () => { + const executor = toolExecutorMap.get('read_task')!; + await expect(executor.execute(taskManager, {})) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('taskId') + }); + }); + + it('should throw error for non-string taskId', async () => { + const executor = toolExecutorMap.get('read_task')!; + await expect(executor.execute(taskManager, { taskId: 123 })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('taskId') + }); + }); + }); + + describe('validateTaskList', () => { + it('should throw error for missing tasks', async () => { + const executor = toolExecutorMap.get('create_project')!; + await expect(executor.execute(taskManager, { initialPrompt: 'test' })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('tasks') + }); + }); + + it('should throw error for non-array tasks', async () => { + const executor = toolExecutorMap.get('create_project')!; + await expect(executor.execute(taskManager, { initialPrompt: 'test', tasks: 'not an array' })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('tasks') + }); + }); + }); + }); + + // Tool Executor Tests + describe('listProjects Tool Executor', () => { + it('should call taskManager.listProjects with no state', async () => { + const executor = toolExecutorMap.get('list_projects')!; + taskManager.listProjects.mockResolvedValue({ + status: 'success', + data: { + message: 'Projects listed successfully', + projects: [] + } + }); + + await executor.execute(taskManager, {}); + + expect(taskManager.listProjects).toHaveBeenCalledWith(undefined); + }); + + it('should call taskManager.listProjects with valid state', async () => { + const executor = toolExecutorMap.get('list_projects')!; + taskManager.listProjects.mockResolvedValue({ + status: 'success', + data: { + message: 'Projects listed successfully', + projects: [] + } + }); + + await executor.execute(taskManager, { state: 'open' }); + + expect(taskManager.listProjects).toHaveBeenCalledWith('open'); + }); + + it('should throw error for invalid state', async () => { + const executor = toolExecutorMap.get('list_projects')!; + + await expect(executor.execute(taskManager, { state: 'invalid' })) + .rejects + .toMatchObject({ + code: ErrorCode.InvalidArgument, + message: expect.stringContaining('state') + }); + }); + }); + + describe('createProject Tool Executor', () => { + const validTask = { + title: 'Test Task', + description: 'Test Description' + }; + + it('should create project with minimal valid input', async () => { + const executor = toolExecutorMap.get('create_project')!; + taskManager.createProject.mockResolvedValue({ + status: 'success', + data: { + projectId: 'test-proj', + totalTasks: 1, + tasks: [{ id: 'task-1', ...validTask }], + message: 'Project created successfully' + } + }); + + await executor.execute(taskManager, { + initialPrompt: 'Test Prompt', + tasks: [validTask] + }); + + expect(taskManager.createProject).toHaveBeenCalledWith( + 'Test Prompt', + [validTask], + undefined, + false + ); + }); + + it('should create project with all optional fields', async () => { + const executor = toolExecutorMap.get('create_project')!; + const taskWithRecommendations = { + ...validTask, + toolRecommendations: 'Use tool X', + ruleRecommendations: 'Follow rule Y' + }; + + taskManager.createProject.mockResolvedValue({ + status: 'success', + data: { + projectId: 'test-proj', + totalTasks: 1, + tasks: [{ id: 'task-1', ...taskWithRecommendations }], + message: 'Project created successfully' + } + }); + + await executor.execute(taskManager, { + initialPrompt: 'Test Prompt', + projectPlan: 'Test Plan', + tasks: [taskWithRecommendations] + }); + + expect(taskManager.createProject).toHaveBeenCalledWith( + 'Test Prompt', + [taskWithRecommendations], + 'Test Plan', + false + ); + }); + + it('should throw error for invalid task object', async () => { + const executor = toolExecutorMap.get('create_project')!; + + await expect(executor.execute(taskManager, { + initialPrompt: 'Test Prompt', + tasks: [{ title: 'Missing Description' }] + })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('description') + }); + }); + }); + + describe('getNextTask Tool Executor', () => { + it('should get next task successfully', async () => { + const executor = toolExecutorMap.get('get_next_task')!; + const mockTask: Task = { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'not started', + approved: false, + completedDetails: '' + }; + + taskManager.getNextTask.mockResolvedValue({ + status: 'next_task', + data: mockTask + }); + + const result = await executor.execute(taskManager, { projectId: 'proj-1' }); + + expect(taskManager.getNextTask).toHaveBeenCalledWith('proj-1'); + expect(result.content[0].text).toContain('task-1'); + }); + + it('should handle no next task', async () => { + const executor = toolExecutorMap.get('get_next_task')!; + taskManager.getNextTask.mockResolvedValue({ + status: 'all_tasks_done', + data: { message: 'All tasks completed' } + }); + + const result = await executor.execute(taskManager, { projectId: 'proj-1' }); + + expect(taskManager.getNextTask).toHaveBeenCalledWith('proj-1'); + expect(result.content[0].text).toContain('all_tasks_done'); + }); + }); + + describe('updateTask Tool Executor', () => { + const mockTask: Task = { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'not started', + approved: false, + completedDetails: '' + }; + + it('should update task with valid status transition', async () => { + const executor = toolExecutorMap.get('update_task')!; + taskManager.updateTask.mockResolvedValue({ + status: 'success', + data: { ...mockTask, status: 'in progress' } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + taskId: 'task-1', + status: 'in progress' + }); + + expect(taskManager.updateTask).toHaveBeenCalledWith('proj-1', 'task-1', { + status: 'in progress' + }); + }); + + it('should require completedDetails when status is done', async () => { + const executor = toolExecutorMap.get('update_task')!; + + await expect(executor.execute(taskManager, { + projectId: 'proj-1', + taskId: 'task-1', + status: 'done' + })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('completedDetails') + }); + }); + + it('should update task with all optional fields', async () => { + const executor = toolExecutorMap.get('update_task')!; + taskManager.updateTask.mockResolvedValue({ + status: 'success', + data: { + ...mockTask, + title: 'New Title', + description: 'New Description', + toolRecommendations: 'New Tools', + ruleRecommendations: 'New Rules' + } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + taskId: 'task-1', + title: 'New Title', + description: 'New Description', + toolRecommendations: 'New Tools', + ruleRecommendations: 'New Rules' + }); + + expect(taskManager.updateTask).toHaveBeenCalledWith('proj-1', 'task-1', { + title: 'New Title', + description: 'New Description', + toolRecommendations: 'New Tools', + ruleRecommendations: 'New Rules' + }); + }); + }); + + describe('readProject Tool Executor', () => { + it('should read project successfully', async () => { + const executor = toolExecutorMap.get('read_project')!; + const mockProject = { + projectId: 'proj-1', + initialPrompt: 'Test Project', + projectPlan: '', + completed: false, + tasks: [] as Task[] + }; + + taskManager.readProject.mockResolvedValue({ + status: 'success', + data: mockProject + }); + + const result = await executor.execute(taskManager, { projectId: 'proj-1' }); + + expect(taskManager.readProject).toHaveBeenCalledWith('proj-1'); + expect(result.content[0].text).toContain('proj-1'); + }); + }); + + describe('deleteProject Tool Executor', () => { + it('should delete project successfully', async () => { + const executor = toolExecutorMap.get('delete_project')!; + taskManager['data'] = { + projects: [{ + projectId: 'proj-1', + initialPrompt: 'Test Project', + projectPlan: '', + completed: false, + tasks: [] + }] + }; + taskManager['saveTasks'] = jest.fn(async () => Promise.resolve()); + + const result = await executor.execute(taskManager, { projectId: 'proj-1' }); + + expect(taskManager['saveTasks']).toHaveBeenCalled(); + expect(result.content[0].text).toContain('project_deleted'); + }); + + it('should handle non-existent project', async () => { + const executor = toolExecutorMap.get('delete_project')!; + taskManager['data'] = { + projects: [] + }; + + const result = await executor.execute(taskManager, { projectId: 'non-existent' }); + + expect(result.content[0].text).toContain('Project not found'); + }); + }); + + describe('addTasksToProject Tool Executor', () => { + const validTasks = [ + { title: 'Task 1', description: 'Description 1' }, + { title: 'Task 2', description: 'Description 2', toolRecommendations: 'Tool X', ruleRecommendations: 'Rule Y' } + ]; + + it('should add tasks successfully', async () => { + const executor = toolExecutorMap.get('add_tasks_to_project')!; + taskManager.addTasksToProject.mockResolvedValue({ + status: 'success', + data: { + message: 'Tasks added successfully', + newTasks: [ + { id: 'task-1', title: 'Task 1', description: 'Description 1' } + ] + } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + tasks: validTasks + }); + + expect(taskManager.addTasksToProject).toHaveBeenCalledWith('proj-1', validTasks); + }); + + it('should throw error for invalid task in array', async () => { + const executor = toolExecutorMap.get('add_tasks_to_project')!; + const invalidTasks = [ + { title: 'Task 1' } // missing description + ]; + + await expect(executor.execute(taskManager, { + projectId: 'proj-1', + tasks: invalidTasks + })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('description') + }); + }); + }); + + describe('finalizeProject Tool Executor', () => { + it('should finalize project successfully', async () => { + const executor = toolExecutorMap.get('finalize_project')!; + taskManager.approveProjectCompletion.mockResolvedValue({ + status: 'success', + data: { + projectId: 'proj-1', + message: 'Project finalized successfully' + } + }); + + await executor.execute(taskManager, { projectId: 'proj-1' }); + + expect(taskManager.approveProjectCompletion).toHaveBeenCalledWith('proj-1'); + }); + }); + + describe('listTasks Tool Executor', () => { + it('should list tasks with no filters', async () => { + const executor = toolExecutorMap.get('list_tasks')!; + taskManager.listTasks.mockResolvedValue({ + status: 'success', + data: { + message: 'Tasks listed successfully', + tasks: [] + } + }); + + await executor.execute(taskManager, {}); + + expect(taskManager.listTasks).toHaveBeenCalledWith(undefined, undefined); + }); + + it('should list tasks with projectId filter', async () => { + const executor = toolExecutorMap.get('list_tasks')!; + await executor.execute(taskManager, { projectId: 'proj-1' }); + expect(taskManager.listTasks).toHaveBeenCalledWith('proj-1', undefined); + }); + + it('should list tasks with state filter', async () => { + const executor = toolExecutorMap.get('list_tasks')!; + await executor.execute(taskManager, { state: 'open' }); + expect(taskManager.listTasks).toHaveBeenCalledWith(undefined, 'open'); + }); + + it('should throw error for invalid state', async () => { + const executor = toolExecutorMap.get('list_tasks')!; + await expect(executor.execute(taskManager, { state: 'invalid' })) + .rejects + .toMatchObject({ + code: ErrorCode.InvalidArgument, + message: expect.stringContaining('state') + }); + }); + }); + + describe('readTask Tool Executor', () => { + it('should read task successfully', async () => { + const executor = toolExecutorMap.get('read_task')!; + const mockTask = { + projectId: 'proj-1', + initialPrompt: 'Test Project', + projectPlan: '', + completed: false, + task: { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'not started' as const, + approved: false, + completedDetails: '' + } + }; + + taskManager.openTaskDetails.mockResolvedValue({ + status: 'success', + data: mockTask + }); + + const result = await executor.execute(taskManager, { taskId: 'task-1' }); + + expect(taskManager.openTaskDetails).toHaveBeenCalledWith('task-1'); + expect(result.content[0].text).toContain('task-1'); + }); + }); + + describe('createTask Tool Executor', () => { + it('should create task successfully', async () => { + const executor = toolExecutorMap.get('create_task')!; + const taskData = { + title: 'New Task', + description: 'Task Description', + toolRecommendations: 'Tool X', + ruleRecommendations: 'Rule Y' + }; + + taskManager.addTasksToProject.mockResolvedValue({ + status: 'success', + data: { + message: 'Task created successfully', + newTasks: [ + { id: 'task-1', title: 'New Task', description: 'Task Description' } + ] + } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + ...taskData + }); + + expect(taskManager.addTasksToProject).toHaveBeenCalledWith('proj-1', [taskData]); + }); + + it('should throw error for missing title', async () => { + const executor = toolExecutorMap.get('create_task')!; + await expect(executor.execute(taskManager, { + projectId: 'proj-1', + description: 'Description' + })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('title') + }); + }); + + it('should throw error for missing description', async () => { + const executor = toolExecutorMap.get('create_task')!; + await expect(executor.execute(taskManager, { + projectId: 'proj-1', + title: 'Title' + })) + .rejects + .toMatchObject({ + code: ErrorCode.MissingParameter, + message: expect.stringContaining('description') + }); + }); + }); + + describe('deleteTask Tool Executor', () => { + it('should delete task successfully', async () => { + const executor = toolExecutorMap.get('delete_task')!; + taskManager.deleteTask.mockResolvedValue({ + status: 'success', + data: { message: 'Task deleted successfully' } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + taskId: 'task-1' + }); + + expect(taskManager.deleteTask).toHaveBeenCalledWith('proj-1', 'task-1'); + }); + }); + + describe('approveTask Tool Executor', () => { + it('should approve task successfully', async () => { + const executor = toolExecutorMap.get('approve_task')!; + taskManager.approveTaskCompletion.mockResolvedValue({ + status: 'success', + data: { message: 'Task approved successfully' } + }); + + await executor.execute(taskManager, { + projectId: 'proj-1', + taskId: 'task-1' + }); + + expect(taskManager.approveTaskCompletion).toHaveBeenCalledWith('proj-1', 'task-1'); + }); + }); +}); \ No newline at end of file