From 2c7a2de5b34d24c0d138c448046ae4b77bcc74e5 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Fri, 9 Jan 2026 06:34:16 +0200 Subject: [PATCH] feat(task-mcp): Add task_resolve_duplicate tool Combines close + link operations for duplicate issues: - Closes the duplicate task (sets status to completed) - Creates bidirectional 'duplicates' link to dominant task Usage: task_resolve_duplicate(duplicate_id, dominant_id) Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 34 ++++++- src/tools/epics.ts | 211 +++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 12 +++ src/tools/relations.ts | 47 +++++++++ start.sh | 3 + 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/tools/epics.ts create mode 100755 start.sh diff --git a/src/index.ts b/src/index.ts index ed7e1a0..025bc5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,8 @@ import { testConnection, close } from './db.js'; import { toolDefinitions } from './tools/index.js'; import { taskAdd, taskList, taskShow, taskClose, taskUpdate } from './tools/crud.js'; import { taskSimilar, taskContext } from './tools/search.js'; -import { taskLink, checklistAdd, checklistToggle } from './tools/relations.js'; +import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js'; +import { epicAdd, epicList, epicShow, epicAssign } from './tools/epics.js'; // Create MCP server const server = new Server( @@ -114,6 +115,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { checked: a.checked, }); break; + case 'task_resolve_duplicate': + result = await taskResolveDuplicate({ + duplicate_id: a.duplicate_id, + dominant_id: a.dominant_id, + }); + break; + + // Epics + case 'epic_add': + result = await epicAdd({ + title: a.title, + project: a.project, + description: a.description, + }); + break; + case 'epic_list': + result = await epicList({ + project: a.project, + status: a.status, + limit: a.limit, + }); + break; + case 'epic_show': + result = await epicShow(a.id); + break; + case 'epic_assign': + result = await epicAssign({ + task_id: a.task_id, + epic_id: a.epic_id, + }); + break; default: throw new Error(`Unknown tool: ${name}`); diff --git a/src/tools/epics.ts b/src/tools/epics.ts new file mode 100644 index 0000000..32a4440 --- /dev/null +++ b/src/tools/epics.ts @@ -0,0 +1,211 @@ +// Epic operations for task management + +import { query, queryOne, execute, getProjectKey } from '../db.js'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; +import type { Epic, Task } from '../types.js'; + +interface EpicAddArgs { + title: string; + project?: string; + description?: string; +} + +interface EpicListArgs { + project?: string; + status?: string; + limit?: number; +} + +interface EpicAssignArgs { + task_id: string; + epic_id: string; +} + +/** + * Get next epic ID for a project + */ +async function getNextEpicId(projectKey: string): Promise { + const result = await queryOne<{ next_id: number }>( + `INSERT INTO epic_sequences (project, next_id) VALUES ($1, 1) + ON CONFLICT (project) DO UPDATE SET next_id = epic_sequences.next_id + 1 + RETURNING next_id`, + [projectKey] + ); + return `${projectKey}-E${result?.next_id || 1}`; +} + +/** + * Create a new epic + */ +export async function epicAdd(args: EpicAddArgs): Promise { + const { title, project = 'Unknown', description = '' } = args; + + // Get project key + const projectKey = await getProjectKey(project); + + // Get next epic ID + const epicId = await getNextEpicId(projectKey); + + // Generate embedding + const embedText = description ? `${title}. ${description}` : title; + const embedding = await getEmbedding(embedText); + const embeddingValue = embedding ? formatEmbedding(embedding) : null; + + // Insert epic + if (embeddingValue) { + await execute( + `INSERT INTO epics (id, project, title, description, embedding) + VALUES ($1, $2, $3, $4, $5)`, + [epicId, projectKey, title, description, embeddingValue] + ); + } else { + await execute( + `INSERT INTO epics (id, project, title, description) + VALUES ($1, $2, $3, $4)`, + [epicId, projectKey, title, description] + ); + } + + return `Created epic: ${epicId}\n Title: ${title}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}`; +} + +/** + * List epics with filters + */ +export async function epicList(args: EpicListArgs): Promise { + const { project, status, limit = 20 } = args; + + let whereClause = 'WHERE 1=1'; + const params: unknown[] = []; + let paramIndex = 1; + + if (project) { + const projectKey = await getProjectKey(project); + whereClause += ` AND e.project = $${paramIndex++}`; + params.push(projectKey); + } + if (status) { + whereClause += ` AND e.status = $${paramIndex++}`; + params.push(status); + } + + params.push(limit); + + const epics = await query( + `SELECT e.id, e.title, e.status, e.project, + COUNT(t.id) as task_count, + COUNT(t.id) FILTER (WHERE t.status != 'completed') as open_count + FROM epics e + LEFT JOIN tasks t ON t.epic_id = e.id + ${whereClause} + GROUP BY e.id, e.title, e.status, e.project, e.created_at + ORDER BY + CASE e.status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 ELSE 2 END, + e.created_at DESC + LIMIT $${paramIndex}`, + params + ); + + if (epics.length === 0) { + return `No epics found${project ? ` for project ${project}` : ''}`; + } + + const lines = epics.map(e => { + const statusIcon = e.status === 'completed' ? '[x]' : e.status === 'in_progress' ? '[>]' : '[ ]'; + const progress = e.task_count > 0 ? ` (${e.task_count - e.open_count}/${e.task_count} done)` : ''; + return `${statusIcon} ${e.id}: ${e.title}${progress}`; + }); + + return `Epics${project ? ` (${project})` : ''}:\n\n${lines.join('\n')}`; +} + +/** + * Show epic details with tasks + */ +export async function epicShow(id: string): Promise { + const epic = await queryOne( + `SELECT id, project, title, description, status, + to_char(created_at, 'YYYY-MM-DD HH24:MI') as created + FROM epics WHERE id = $1`, + [id] + ); + + if (!epic) { + return `Epic not found: ${id}`; + } + + let output = `# ${epic.id}\n\n`; + output += `**Title:** ${epic.title}\n`; + output += `**Project:** ${epic.project}\n`; + output += `**Status:** ${epic.status}\n`; + output += `**Created:** ${epic.created}\n`; + + if (epic.description) { + output += `\n**Description:**\n${epic.description}\n`; + } + + // Get tasks in this epic + const tasks = await query( + `SELECT id, title, status, priority, type + FROM tasks + WHERE epic_id = $1 + ORDER BY + CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, + CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END`, + [id] + ); + + if (tasks.length > 0) { + const done = tasks.filter(t => t.status === 'completed').length; + output += `\n**Tasks:** (${done}/${tasks.length} done)\n`; + for (const t of tasks) { + const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]'; + output += ` ${statusIcon} ${t.priority} ${t.id}: ${t.title}\n`; + } + } else { + output += `\n**Tasks:** None assigned\n`; + } + + return output; +} + +/** + * Assign a task to an epic + */ +export async function epicAssign(args: EpicAssignArgs): Promise { + const { task_id, epic_id } = args; + + // Verify epic exists + const epic = await queryOne<{ id: string }>(`SELECT id FROM epics WHERE id = $1`, [epic_id]); + if (!epic) { + return `Epic not found: ${epic_id}`; + } + + // Update task + const result = await execute( + `UPDATE tasks SET epic_id = $1, updated_at = NOW() WHERE id = $2`, + [epic_id, task_id] + ); + + if (result === 0) { + return `Task not found: ${task_id}`; + } + + return `Assigned ${task_id} to epic ${epic_id}`; +} + +/** + * Unassign a task from its epic + */ +export async function epicUnassign(task_id: string): Promise { + const result = await execute( + `UPDATE tasks SET epic_id = NULL, updated_at = NOW() WHERE id = $1`, + [task_id] + ); + + if (result === 0) { + return `Task not found: ${task_id}`; + } + + return `Unassigned ${task_id} from its epic`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 8c1fee6..ccb05e4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -135,6 +135,18 @@ export const toolDefinitions = [ required: ['item_id', 'checked'], }, }, + { + name: 'task_resolve_duplicate', + description: 'Resolve a duplicate issue by closing it and linking to the dominant issue', + inputSchema: { + type: 'object', + properties: { + duplicate_id: { type: 'string', description: 'The duplicate task ID to close' }, + dominant_id: { type: 'string', description: 'The dominant/original task ID to keep' }, + }, + required: ['duplicate_id', 'dominant_id'], + }, + }, // Epic Tools { diff --git a/src/tools/relations.ts b/src/tools/relations.ts index ab60723..c640683 100644 --- a/src/tools/relations.ts +++ b/src/tools/relations.ts @@ -93,3 +93,50 @@ export async function checklistToggle(args: ChecklistToggleArgs): Promise { + const { duplicate_id, dominant_id } = args; + + try { + // Close the duplicate task + const closeResult = await execute( + `UPDATE tasks + SET status = 'completed', completed_at = NOW(), updated_at = NOW() + WHERE id = $1`, + [duplicate_id] + ); + + if (closeResult === 0) { + return `Duplicate task not found: ${duplicate_id}`; + } + + // Create bidirectional duplicates link + await execute( + `INSERT INTO task_links (from_task_id, to_task_id, link_type) + VALUES ($1, $2, 'duplicates') + ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`, + [duplicate_id, dominant_id] + ); + + await execute( + `INSERT INTO task_links (from_task_id, to_task_id, link_type) + VALUES ($1, $2, 'duplicates') + ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`, + [dominant_id, duplicate_id] + ); + + return `Resolved duplicate: ${duplicate_id} → ${dominant_id}\n Closed: ${duplicate_id}\n Linked: duplicates ${dominant_id}`; + } catch (error) { + return `Error resolving duplicate: ${error}`; + } +} diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..45272ac --- /dev/null +++ b/start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Wrapper script for task-mcp that logs errors +exec node /Users/christian.gick/Development/Infrastructure/mcp-servers/task-mcp/dist/index.js 2>> /tmp/task-mcp.log