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 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-09 06:34:16 +02:00
parent 3fc8f2d5e0
commit 2c7a2de5b3
5 changed files with 306 additions and 1 deletions

View File

@@ -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}`);

211
src/tools/epics.ts Normal file
View File

@@ -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<string> {
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<string> {
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<string> {
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<Epic & { task_count: number; open_count: number }>(
`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<string> {
const epic = await queryOne<Epic & { created: string }>(
`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<Task>(
`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<string> {
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<string> {
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`;
}

View File

@@ -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
{

View File

@@ -93,3 +93,50 @@ export async function checklistToggle(args: ChecklistToggleArgs): Promise<string
return `${checked ? 'Checked' : 'Unchecked'}: item #${item_id}`;
}
interface ResolveDuplicateArgs {
duplicate_id: string;
dominant_id: string;
}
/**
* Resolve a duplicate issue by closing it and linking to the dominant issue
* - Closes the duplicate task (sets status to completed)
* - Creates bidirectional "duplicates" link between the two tasks
*/
export async function taskResolveDuplicate(args: ResolveDuplicateArgs): Promise<string> {
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}`;
}
}

3
start.sh Executable file
View File

@@ -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