board_delete_task
Delete a task and optionally its subtasks. Default safety restricts deletion to completed tasks to prevent accidental removal. Irreversible action.
Instructions
Hard-delete a task and optionally its subtasks. Safety guard: by default only allows deleting tasks with status=done (prevents deleting in-progress work). Pass require_done=false to override. Also deletes associated activity_log entries. This is irreversible — cannot be undone.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| task_id | Yes | Task ID to delete | |
| require_done | No | If true (default), refuse to delete unless the task's status is 'done'. Pass false to force-delete a non-done task. | |
| cascade_subtasks | No | If true, also delete all tasks with parent_task_id == task_id (each child also subject to require_done check). Default false — subtasks are orphaned but kept. |
Implementation Reference
- src/tools/tasks.ts:701-818 (handler)The handler function for board_delete_task. Receives task_id, require_done, and cascade_subtasks params. Fetches the task doc from Firestore, checks if it exists, validates require_done guard, optionally cascades to subtasks, deletes associated activity_log entries, deletes the task docs, and writes an audit entry.
async ({ task_id, require_done, cascade_subtasks }) => { const requireDone = require_done ?? true; const cascade = cascade_subtasks ?? false; 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 data = taskSnap.data() ?? {}; if (requireDone && data.status !== "done") { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Task ${task_id} has status="${data.status}". Set status to "done" first, or pass require_done=false to force delete. Refusing to delete in-progress work.`, }), }, ], }; } const toDelete: string[] = [task_id]; if (cascade) { const childrenSnap = await db .collection("tasks") .where("parent_task_id", "==", task_id) .get(); for (const child of childrenSnap.docs) { const childData = child.data() ?? {}; if (requireDone && childData.status !== "done") { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Subtask ${child.id} has status="${childData.status}". Parent ${task_id} not deleted. Either complete subtasks first or pass require_done=false.`, }), }, ], }; } toDelete.push(child.id); } } // Gather activity_log entries for the tasks being deleted. const activityRefs: FirebaseFirestore.DocumentReference[] = []; for (const id of toDelete) { const activitySnap = await db .collection("activity_log") .where("task_id", "==", id) .get(); for (const doc of activitySnap.docs) activityRefs.push(doc.ref); } // Delete in order: activity_log entries first, task docs last. If the // multi-batch loop fails mid-way, we'd rather have a task doc with // truncated history than an orphaned history pointing at a deleted // task_id. Task deletions go in the final batch so they're atomic // with the most recent history writes. Firestore batch limit is 500 // writes; chunk at 450 to leave headroom. const CHUNK = 450; // Activity log first (in chunks). for (let i = 0; i < activityRefs.length; i += CHUNK) { const batch = db.batch(); for (const ref of activityRefs.slice(i, i + CHUNK)) batch.delete(ref); await batch.commit(); } // Task docs last, all together (≤100 cascade limit, well under 500). const taskBatch = db.batch(); for (const id of toDelete) taskBatch.delete(db.collection("tasks").doc(id)); await taskBatch.commit(); // Write a final audit entry for the deletion itself. Because the task // is gone, this entry has task_id=null and details capture what was removed. await db.collection("activity_log").add({ task_id: null, session_id: null, agent_name: "system", action: "updated", details: `Deleted task${cascade && toDelete.length > 1 ? `s` : ""}: ${toDelete.join(", ")}`, metadata: { deleted_task_ids: toDelete, deleted_activity_count: activityRefs.length, require_done: requireDone, cascade_subtasks: cascade, }, created_at: Timestamp.now(), }); return { content: [ { type: "text" as const, text: JSON.stringify( { deleted_task_ids: toDelete, deleted_activity_entries: activityRefs.length, message: `Deleted ${toDelete.length} task(s) and ${activityRefs.length} activity log entries.`, }, null, 2 ), }, ], }; } ); - src/tools/tasks.ts:686-700 (schema)Input schema for board_delete_task defining three parameters: task_id (string, required), require_done (boolean, optional, defaults to true), and cascade_subtasks (boolean, optional, defaults to false).
{ task_id: z.string().describe("Task ID to delete"), require_done: z .boolean() .optional() .describe( "If true (default), refuse to delete unless the task's status is 'done'. Pass false to force-delete a non-done task." ), cascade_subtasks: z .boolean() .optional() .describe( "If true, also delete all tasks with parent_task_id == task_id (each child also subject to require_done check). Default false — subtasks are orphaned but kept." ), }, - src/tools/tasks.ts:683-818 (registration)Registration of 'board_delete_task' tool via server.tool() with description, schema, and handler. Part of the registerTaskTools function.
server.tool( "board_delete_task", "Hard-delete a task and optionally its subtasks. Safety guard: by default only allows deleting tasks with status=done (prevents deleting in-progress work). Pass require_done=false to override. Also deletes associated activity_log entries. This is irreversible — cannot be undone.", { task_id: z.string().describe("Task ID to delete"), require_done: z .boolean() .optional() .describe( "If true (default), refuse to delete unless the task's status is 'done'. Pass false to force-delete a non-done task." ), cascade_subtasks: z .boolean() .optional() .describe( "If true, also delete all tasks with parent_task_id == task_id (each child also subject to require_done check). Default false — subtasks are orphaned but kept." ), }, async ({ task_id, require_done, cascade_subtasks }) => { const requireDone = require_done ?? true; const cascade = cascade_subtasks ?? false; 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 data = taskSnap.data() ?? {}; if (requireDone && data.status !== "done") { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Task ${task_id} has status="${data.status}". Set status to "done" first, or pass require_done=false to force delete. Refusing to delete in-progress work.`, }), }, ], }; } const toDelete: string[] = [task_id]; if (cascade) { const childrenSnap = await db .collection("tasks") .where("parent_task_id", "==", task_id) .get(); for (const child of childrenSnap.docs) { const childData = child.data() ?? {}; if (requireDone && childData.status !== "done") { return { content: [ { type: "text" as const, text: JSON.stringify({ error: `Subtask ${child.id} has status="${childData.status}". Parent ${task_id} not deleted. Either complete subtasks first or pass require_done=false.`, }), }, ], }; } toDelete.push(child.id); } } // Gather activity_log entries for the tasks being deleted. const activityRefs: FirebaseFirestore.DocumentReference[] = []; for (const id of toDelete) { const activitySnap = await db .collection("activity_log") .where("task_id", "==", id) .get(); for (const doc of activitySnap.docs) activityRefs.push(doc.ref); } // Delete in order: activity_log entries first, task docs last. If the // multi-batch loop fails mid-way, we'd rather have a task doc with // truncated history than an orphaned history pointing at a deleted // task_id. Task deletions go in the final batch so they're atomic // with the most recent history writes. Firestore batch limit is 500 // writes; chunk at 450 to leave headroom. const CHUNK = 450; // Activity log first (in chunks). for (let i = 0; i < activityRefs.length; i += CHUNK) { const batch = db.batch(); for (const ref of activityRefs.slice(i, i + CHUNK)) batch.delete(ref); await batch.commit(); } // Task docs last, all together (≤100 cascade limit, well under 500). const taskBatch = db.batch(); for (const id of toDelete) taskBatch.delete(db.collection("tasks").doc(id)); await taskBatch.commit(); // Write a final audit entry for the deletion itself. Because the task // is gone, this entry has task_id=null and details capture what was removed. await db.collection("activity_log").add({ task_id: null, session_id: null, agent_name: "system", action: "updated", details: `Deleted task${cascade && toDelete.length > 1 ? `s` : ""}: ${toDelete.join(", ")}`, metadata: { deleted_task_ids: toDelete, deleted_activity_count: activityRefs.length, require_done: requireDone, cascade_subtasks: cascade, }, created_at: Timestamp.now(), }); return { content: [ { type: "text" as const, text: JSON.stringify( { deleted_task_ids: toDelete, deleted_activity_entries: activityRefs.length, message: `Deleted ${toDelete.length} task(s) and ${activityRefs.length} activity log entries.`, }, null, 2 ), }, ], }; } ); - src/tools/tasks.ts:5-5 (helper)The registerTaskTools function that wraps all task tool registrations, including board_delete_task. Called from src/index.ts:29.
export function registerTaskTools(server: McpServer, db: Firestore) { - src/index.ts:29-29 (registration)Where registerTaskTools is invoked, registering all task tools including board_delete_task on the MCP server.
registerTaskTools(server, db);