board_update_task
Update a task's status, assignment, priority, RIPER mode, or project. Move tasks between projects while preserving subtask relationships manually.
Instructions
Update a task's status, assignment, priority, RIPER mode, project, or other fields. Pass project_id to move the task to a different project (the target project must exist; subtasks are NOT auto-moved — caller must move them separately if needed).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| task_id | Yes | Task ID to update | |
| status | No | New status | |
| priority | No | New priority | |
| assigned_agent | No | New agent assignment (empty string to unassign) | |
| riper_mode | No | New RIPER mode | |
| title | No | Updated title | |
| description | No | Updated description | |
| depends_on | No | Updated dependency list | |
| metadata | No | Metadata to merge | |
| project_id | No | Move task to this project. Target project must exist. Subtasks are NOT auto-moved — their parent_task_id link will cross projects unless the caller also moves them. Returns a warning in the message if the task has subtasks still in the source project. |
Implementation Reference
- src/tools/tasks.ts:230-429 (handler)Handler function for board_update_task: fetches the task, validates existence, handles field updates (status, priority, assigned_agent, riper_mode, title, description, depends_on, metadata), supports project reassignment with target validation and subtask orphan warnings, manages started_at/completed_at timestamps based on status transitions, and writes an activity_log entry. Returns { id, changes, warnings?, message }.
async ({ task_id, status, priority, assigned_agent, riper_mode, title, description, depends_on, metadata, project_id, }) => { const taskRef = db.collection("tasks").doc(task_id); const taskSnap = await taskRef.get(); if (!taskSnap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Task ${task_id} not found` }), }, ], }; } const oldData = taskSnap.data()!; const now = Timestamp.now(); const updates: Record<string, unknown> = { updated_at: now }; const changes: string[] = []; const warnings: string[] = []; // Project reassignment — validate target project exists before writing. // Uses a single-field subtask query (parent_task_id only) then JS-side // project_id filtering to avoid requiring a Firestore composite index. let projectMoveApplied = false; if (project_id !== undefined && project_id !== oldData.project_id) { const targetProjSnap = await db.collection("projects").doc(project_id).get(); if (!targetProjSnap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Target project ${project_id} not found. Task not moved.`, }), }, ], }; } // Resolve names for the audit log (both projects). Old project name // is best-effort: legacy tasks may have no project_id, or the old // project may have been deleted since the task was created. let oldProjName: string = "(none)"; if (oldData.project_id) { const oldProjSnap = await db .collection("projects") .doc(oldData.project_id) .get(); oldProjName = oldProjSnap.exists ? (oldProjSnap.data()?.name ?? oldData.project_id) : `${oldData.project_id} (deleted)`; } const newProjName = targetProjSnap.data()?.name ?? project_id; const oldProjIdDisplay = oldData.project_id ?? "none"; updates.project_id = project_id; changes.push( `project: ${oldProjName} (${oldProjIdDisplay}) → ${newProjName} (${project_id})` ); projectMoveApplied = true; // Detect subtasks that'd be orphaned in the source project after the // move. Query by parent_task_id only (single field, no index needed), // then filter by source project_id in JS. Skip entirely when the task // is a legacy row with no source project_id — undefined in a Firestore // .where clause would throw. const subtaskSnap = await db .collection("tasks") .where("parent_task_id", "==", task_id) .get(); if (oldData.project_id && !subtaskSnap.empty) { const stillInSource = subtaskSnap.docs.filter( (d) => d.data().project_id === oldData.project_id ); if (stillInSource.length > 0) { warnings.push( `${stillInSource.length} subtask(s) still in source project ${oldData.project_id}. Move them separately if desired.` ); } } } if (status !== undefined) { updates.status = status; changes.push(`status: ${oldData.status} → ${status}`); // Set started_at when first moving to in_progress (preserve on re-entry from blocked/review) if (status === "in_progress" && !oldData.started_at) { updates.started_at = now; } // Clear started_at if task is sent back to todo/backlog (genuinely un-started) if ((status === "todo" || status === "backlog") && oldData.started_at) { updates.started_at = null; } if (status === "done" && oldData.status !== "done") { updates.completed_at = now; } else if (status !== "done" && oldData.status === "done") { updates.completed_at = null; } } if (priority !== undefined) { updates.priority = priority; changes.push(`priority: ${oldData.priority} → ${priority}`); } if (assigned_agent !== undefined) { updates.assigned_agent = assigned_agent === "" ? null : assigned_agent; changes.push( `assigned: ${oldData.assigned_agent ?? "none"} → ${assigned_agent || "none"}` ); } if (riper_mode !== undefined) { updates.riper_mode = riper_mode; changes.push(`riper_mode: ${oldData.riper_mode ?? "none"} → ${riper_mode}`); } if (title !== undefined) { updates.title = title; changes.push(`title updated`); } if (description !== undefined) { updates.description = description; changes.push(`description updated`); } if (depends_on !== undefined) { updates.depends_on = depends_on; changes.push(`dependencies updated`); } if (metadata !== undefined) { updates.metadata = { ...oldData.metadata, ...metadata }; changes.push(`metadata updated`); } await taskRef.update(updates); const action = status === "done" ? "completed" : status === "blocked" ? "blocked" : assigned_agent !== undefined ? "claimed" : riper_mode !== undefined ? "mode_changed" : "updated"; const logMetadata: Record<string, unknown> = {}; if (updates.project_id !== undefined) { logMetadata.project_id_from = oldData.project_id ?? null; logMetadata.project_id_to = updates.project_id; } await db.collection("activity_log").add({ task_id, session_id: null, agent_name: (assigned_agent !== undefined && assigned_agent !== "") ? assigned_agent : oldData.assigned_agent ?? "system", action, details: changes.join(", "), metadata: logMetadata, created_at: now, }); const responseMessage = warnings.length > 0 ? `Task updated: ${changes.join(", ")}. Warnings: ${warnings.join("; ")}` : `Task updated: ${changes.join(", ")}`; return { content: [ { type: "text" as const, text: JSON.stringify( { id: task_id, changes, warnings: warnings.length > 0 ? warnings : undefined, message: responseMessage, }, null, 2 ), }, ], }; } ); - src/tools/tasks.ts:195-229 (schema)Input schema (Zod) for board_update_task — defines all updatable fields with their types and descriptions.
{ task_id: z.string().describe("Task ID to update"), status: z .enum(["backlog", "todo", "in_progress", "blocked", "review", "done"]) .optional() .describe("New status"), priority: z .enum(["critical", "high", "medium", "low"]) .optional() .describe("New priority"), assigned_agent: z .string() .optional() .describe("New agent assignment (empty string to unassign)"), riper_mode: z .enum(["research", "innovate", "plan", "execute", "review", "commit"]) .optional() .describe("New RIPER mode"), title: z.string().optional().describe("Updated title"), description: z.string().optional().describe("Updated description"), depends_on: z .array(z.string()) .optional() .describe("Updated dependency list"), metadata: z .record(z.string(), z.unknown()) .optional() .describe("Metadata to merge"), project_id: z .string() .optional() .describe( "Move task to this project. Target project must exist. Subtasks are NOT auto-moved — their parent_task_id link will cross projects unless the caller also moves them. Returns a warning in the message if the task has subtasks still in the source project." ), }, - src/tools/tasks.ts:192-429 (registration)Registration of board_update_task via server.tool() inside registerTaskTools(), exported from src/tools/tasks.ts and called from src/index.ts.
server.tool( "board_update_task", "Update a task's status, assignment, priority, RIPER mode, project, or other fields. Pass project_id to move the task to a different project (the target project must exist; subtasks are NOT auto-moved — caller must move them separately if needed).", { task_id: z.string().describe("Task ID to update"), status: z .enum(["backlog", "todo", "in_progress", "blocked", "review", "done"]) .optional() .describe("New status"), priority: z .enum(["critical", "high", "medium", "low"]) .optional() .describe("New priority"), assigned_agent: z .string() .optional() .describe("New agent assignment (empty string to unassign)"), riper_mode: z .enum(["research", "innovate", "plan", "execute", "review", "commit"]) .optional() .describe("New RIPER mode"), title: z.string().optional().describe("Updated title"), description: z.string().optional().describe("Updated description"), depends_on: z .array(z.string()) .optional() .describe("Updated dependency list"), metadata: z .record(z.string(), z.unknown()) .optional() .describe("Metadata to merge"), project_id: z .string() .optional() .describe( "Move task to this project. Target project must exist. Subtasks are NOT auto-moved — their parent_task_id link will cross projects unless the caller also moves them. Returns a warning in the message if the task has subtasks still in the source project." ), }, async ({ task_id, status, priority, assigned_agent, riper_mode, title, description, depends_on, metadata, project_id, }) => { const taskRef = db.collection("tasks").doc(task_id); const taskSnap = await taskRef.get(); if (!taskSnap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Task ${task_id} not found` }), }, ], }; } const oldData = taskSnap.data()!; const now = Timestamp.now(); const updates: Record<string, unknown> = { updated_at: now }; const changes: string[] = []; const warnings: string[] = []; // Project reassignment — validate target project exists before writing. // Uses a single-field subtask query (parent_task_id only) then JS-side // project_id filtering to avoid requiring a Firestore composite index. let projectMoveApplied = false; if (project_id !== undefined && project_id !== oldData.project_id) { const targetProjSnap = await db.collection("projects").doc(project_id).get(); if (!targetProjSnap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Target project ${project_id} not found. Task not moved.`, }), }, ], }; } // Resolve names for the audit log (both projects). Old project name // is best-effort: legacy tasks may have no project_id, or the old // project may have been deleted since the task was created. let oldProjName: string = "(none)"; if (oldData.project_id) { const oldProjSnap = await db .collection("projects") .doc(oldData.project_id) .get(); oldProjName = oldProjSnap.exists ? (oldProjSnap.data()?.name ?? oldData.project_id) : `${oldData.project_id} (deleted)`; } const newProjName = targetProjSnap.data()?.name ?? project_id; const oldProjIdDisplay = oldData.project_id ?? "none"; updates.project_id = project_id; changes.push( `project: ${oldProjName} (${oldProjIdDisplay}) → ${newProjName} (${project_id})` ); projectMoveApplied = true; // Detect subtasks that'd be orphaned in the source project after the // move. Query by parent_task_id only (single field, no index needed), // then filter by source project_id in JS. Skip entirely when the task // is a legacy row with no source project_id — undefined in a Firestore // .where clause would throw. const subtaskSnap = await db .collection("tasks") .where("parent_task_id", "==", task_id) .get(); if (oldData.project_id && !subtaskSnap.empty) { const stillInSource = subtaskSnap.docs.filter( (d) => d.data().project_id === oldData.project_id ); if (stillInSource.length > 0) { warnings.push( `${stillInSource.length} subtask(s) still in source project ${oldData.project_id}. Move them separately if desired.` ); } } } if (status !== undefined) { updates.status = status; changes.push(`status: ${oldData.status} → ${status}`); // Set started_at when first moving to in_progress (preserve on re-entry from blocked/review) if (status === "in_progress" && !oldData.started_at) { updates.started_at = now; } // Clear started_at if task is sent back to todo/backlog (genuinely un-started) if ((status === "todo" || status === "backlog") && oldData.started_at) { updates.started_at = null; } if (status === "done" && oldData.status !== "done") { updates.completed_at = now; } else if (status !== "done" && oldData.status === "done") { updates.completed_at = null; } } if (priority !== undefined) { updates.priority = priority; changes.push(`priority: ${oldData.priority} → ${priority}`); } if (assigned_agent !== undefined) { updates.assigned_agent = assigned_agent === "" ? null : assigned_agent; changes.push( `assigned: ${oldData.assigned_agent ?? "none"} → ${assigned_agent || "none"}` ); } if (riper_mode !== undefined) { updates.riper_mode = riper_mode; changes.push(`riper_mode: ${oldData.riper_mode ?? "none"} → ${riper_mode}`); } if (title !== undefined) { updates.title = title; changes.push(`title updated`); } if (description !== undefined) { updates.description = description; changes.push(`description updated`); } if (depends_on !== undefined) { updates.depends_on = depends_on; changes.push(`dependencies updated`); } if (metadata !== undefined) { updates.metadata = { ...oldData.metadata, ...metadata }; changes.push(`metadata updated`); } await taskRef.update(updates); const action = status === "done" ? "completed" : status === "blocked" ? "blocked" : assigned_agent !== undefined ? "claimed" : riper_mode !== undefined ? "mode_changed" : "updated"; const logMetadata: Record<string, unknown> = {}; if (updates.project_id !== undefined) { logMetadata.project_id_from = oldData.project_id ?? null; logMetadata.project_id_to = updates.project_id; } await db.collection("activity_log").add({ task_id, session_id: null, agent_name: (assigned_agent !== undefined && assigned_agent !== "") ? assigned_agent : oldData.assigned_agent ?? "system", action, details: changes.join(", "), metadata: logMetadata, created_at: now, }); const responseMessage = warnings.length > 0 ? `Task updated: ${changes.join(", ")}. Warnings: ${warnings.join("; ")}` : `Task updated: ${changes.join(", ")}`; return { content: [ { type: "text" as const, text: JSON.stringify( { id: task_id, changes, warnings: warnings.length > 0 ? warnings : undefined, message: responseMessage, }, null, 2 ), }, ], }; } ); - src/tools/tasks.ts:1-1 (helper)Imports for board_update_task: McpServer (tool registration), Firestore/Timestamp (database and timestamps), and Zod (schema validation).
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";