feat: Add task-mcp server for task management via MCP

Implements 10 MCP tools for task management:
- CRUD: task_add, task_list, task_show, task_close, task_update
- Search: task_similar (pgvector), task_context
- Relations: task_link, task_checklist_add, task_checklist_toggle

Uses PostgreSQL with pgvector for semantic search via LiteLLM embeddings.
Connects via SSH tunnel to docker-host:5435.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-08 20:58:14 +02:00
commit a03e9e065a
12 changed files with 1114 additions and 0 deletions

266
src/tools/crud.ts Normal file
View File

@@ -0,0 +1,266 @@
// CRUD operations for tasks
import { query, queryOne, execute, getNextTaskId, getProjectKey } from '../db.js';
import { getEmbedding, formatEmbedding } from '../embeddings.js';
import type { Task, ChecklistItem, TaskLink } from '../types.js';
interface TaskAddArgs {
title: string;
project?: string;
type?: string;
priority?: string;
description?: string;
}
interface TaskListArgs {
project?: string;
status?: string;
type?: string;
priority?: string;
limit?: number;
}
interface TaskUpdateArgs {
id: string;
status?: string;
priority?: string;
type?: string;
title?: string;
}
/**
* Create a new task
*/
export async function taskAdd(args: TaskAddArgs): Promise<string> {
const { title, project = 'Unknown', type = 'task', priority = 'P2', description = '' } = args;
// Get project key
const projectKey = await getProjectKey(project);
// Get next task ID
const taskId = await getNextTaskId(projectKey);
// Generate embedding
const embedText = description ? `${title}. ${description}` : title;
const embedding = await getEmbedding(embedText);
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
// Insert task
if (embeddingValue) {
await execute(
`INSERT INTO tasks (id, project, title, description, type, status, priority, embedding)
VALUES ($1, $2, $3, $4, $5, 'open', $6, $7)`,
[taskId, projectKey, title, description, type, priority, embeddingValue]
);
} else {
await execute(
`INSERT INTO tasks (id, project, title, description, type, status, priority)
VALUES ($1, $2, $3, $4, $5, 'open', $6)`,
[taskId, projectKey, title, description, type, priority]
);
}
return `Created: ${taskId}\n Title: ${title}\n Type: ${type}\n Priority: ${priority}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}`;
}
/**
* List tasks with filters
*/
export async function taskList(args: TaskListArgs): Promise<string> {
const { project, status, type, priority, limit = 20 } = args;
let whereClause = 'WHERE 1=1';
const params: unknown[] = [];
let paramIndex = 1;
if (project) {
const projectKey = await getProjectKey(project);
whereClause += ` AND project = $${paramIndex++}`;
params.push(projectKey);
}
if (status) {
whereClause += ` AND status = $${paramIndex++}`;
params.push(status);
}
if (type) {
whereClause += ` AND type = $${paramIndex++}`;
params.push(type);
}
if (priority) {
whereClause += ` AND priority = $${paramIndex++}`;
params.push(priority);
}
params.push(limit);
const tasks = await query<Task>(
`SELECT id, title, type, status, priority, project
FROM tasks
${whereClause}
ORDER BY
CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END,
created_at DESC
LIMIT $${paramIndex}`,
params
);
if (tasks.length === 0) {
return `No tasks found${project ? ` for project ${project}` : ''}`;
}
const lines = tasks.map(t => {
const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]';
const typeLabel = t.type !== 'task' ? ` [${t.type}]` : '';
return `${statusIcon} ${t.priority} ${t.id}: ${t.title}${typeLabel}`;
});
return `Tasks${project ? ` (${project})` : ''}:\n\n${lines.join('\n')}`;
}
/**
* Show task details
*/
export async function taskShow(id: string): Promise<string> {
const task = await queryOne<Task>(
`SELECT id, project, title, description, type, status, priority,
to_char(created_at, 'YYYY-MM-DD HH24:MI') as created,
to_char(updated_at, 'YYYY-MM-DD HH24:MI') as updated,
to_char(completed_at, 'YYYY-MM-DD HH24:MI') as completed
FROM tasks WHERE id = $1`,
[id]
);
if (!task) {
return `Task not found: ${id}`;
}
let output = `# ${task.id}\n\n`;
output += `**Title:** ${task.title}\n`;
output += `**Project:** ${task.project}\n`;
output += `**Type:** ${task.type}\n`;
output += `**Status:** ${task.status}\n`;
output += `**Priority:** ${task.priority}\n`;
output += `**Created:** ${(task as unknown as { created: string }).created}\n`;
output += `**Updated:** ${(task as unknown as { updated: string }).updated}\n`;
if ((task as unknown as { completed: string }).completed) {
output += `**Completed:** ${(task as unknown as { completed: string }).completed}\n`;
}
if (task.description) {
output += `\n**Description:**\n${task.description}\n`;
}
// Get checklist
const checklist = await query<ChecklistItem>(
`SELECT id, item, checked FROM task_checklist
WHERE task_id = $1 ORDER BY position, id`,
[id]
);
if (checklist.length > 0) {
const done = checklist.filter(c => c.checked).length;
output += `\n**Checklist:** (${done}/${checklist.length})\n`;
for (const item of checklist) {
output += ` ${item.checked ? '[x]' : '[ ]'} ${item.item} (#${item.id})\n`;
}
}
// Get dependencies
const blockedBy = await query<{ id: string; title: string }>(
`SELECT t.id, t.title FROM task_links l
JOIN tasks t ON t.id = l.from_task_id
WHERE l.to_task_id = $1 AND l.link_type = 'blocks'`,
[id]
);
const blocks = await query<{ id: string; title: string }>(
`SELECT t.id, t.title FROM task_links l
JOIN tasks t ON t.id = l.to_task_id
WHERE l.from_task_id = $1 AND l.link_type = 'blocks'`,
[id]
);
if (blockedBy.length > 0) {
output += `\n**Blocked by:**\n`;
for (const t of blockedBy) {
output += ` - ${t.id}: ${t.title}\n`;
}
}
if (blocks.length > 0) {
output += `\n**Blocks:**\n`;
for (const t of blocks) {
output += ` - ${t.id}: ${t.title}\n`;
}
}
return output;
}
/**
* Close a task
*/
export async function taskClose(id: string): Promise<string> {
const result = await execute(
`UPDATE tasks
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
WHERE id = $1`,
[id]
);
if (result === 0) {
return `Task not found: ${id}`;
}
return `Closed: ${id}`;
}
/**
* Update a task
*/
export async function taskUpdate(args: TaskUpdateArgs): Promise<string> {
const { id, status, priority, type, title } = args;
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (status) {
updates.push(`status = $${paramIndex++}`);
params.push(status);
if (status === 'completed') {
updates.push(`completed_at = NOW()`);
}
}
if (priority) {
updates.push(`priority = $${paramIndex++}`);
params.push(priority);
}
if (type) {
updates.push(`type = $${paramIndex++}`);
params.push(type);
}
if (title) {
updates.push(`title = $${paramIndex++}`);
params.push(title);
}
if (updates.length === 0) {
return 'No updates specified';
}
updates.push('updated_at = NOW()');
params.push(id);
const result = await execute(
`UPDATE tasks SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
params
);
if (result === 0) {
return `Task not found: ${id}`;
}
return `Updated: ${id}`;
}

138
src/tools/index.ts Normal file
View File

@@ -0,0 +1,138 @@
// Tool definitions for task-mcp
export const toolDefinitions = [
// CRUD Tools
{
name: 'task_add',
description: 'Create a new task with auto-generated ID and semantic embedding',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Task title (required)' },
project: { type: 'string', description: 'Project key (e.g., ST, VPN). Auto-detected from CWD if not provided.' },
type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'Task type (default: task)' },
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'Priority level (default: P2)' },
description: { type: 'string', description: 'Optional description' },
},
required: ['title'],
},
},
{
name: 'task_list',
description: 'List tasks with optional filters',
inputSchema: {
type: 'object',
properties: {
project: { type: 'string', description: 'Filter by project key' },
status: { type: 'string', enum: ['open', 'in_progress', 'blocked', 'completed'], description: 'Filter by status' },
type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'Filter by type' },
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'Filter by priority' },
limit: { type: 'number', description: 'Max results (default: 20)' },
},
},
},
{
name: 'task_show',
description: 'Show task details including checklist and dependencies',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Task ID (e.g., ST-1, VPN-45)' },
},
required: ['id'],
},
},
{
name: 'task_close',
description: 'Mark a task as completed',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Task ID to close' },
},
required: ['id'],
},
},
{
name: 'task_update',
description: 'Update task fields (status, priority, type, title)',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Task ID to update' },
status: { type: 'string', enum: ['open', 'in_progress', 'blocked', 'completed'], description: 'New status' },
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'New priority' },
type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'New type' },
title: { type: 'string', description: 'New title' },
},
required: ['id'],
},
},
// Semantic Search Tools
{
name: 'task_similar',
description: 'Find semantically similar tasks using pgvector',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
project: { type: 'string', description: 'Filter by project (optional)' },
limit: { type: 'number', description: 'Max results (default: 5)' },
},
required: ['query'],
},
},
{
name: 'task_context',
description: 'Get related tasks for current work context (useful for delegations)',
inputSchema: {
type: 'object',
properties: {
description: { type: 'string', description: 'Description of current work' },
project: { type: 'string', description: 'Current project' },
limit: { type: 'number', description: 'Max related tasks (default: 3)' },
},
required: ['description'],
},
},
// Relation Tools
{
name: 'task_link',
description: 'Create dependency between tasks',
inputSchema: {
type: 'object',
properties: {
from_id: { type: 'string', description: 'Source task ID' },
to_id: { type: 'string', description: 'Target task ID' },
link_type: { type: 'string', enum: ['blocks', 'relates_to', 'duplicates'], description: 'Relationship type' },
},
required: ['from_id', 'to_id', 'link_type'],
},
},
{
name: 'task_checklist_add',
description: 'Add a checklist item to a task',
inputSchema: {
type: 'object',
properties: {
task_id: { type: 'string', description: 'Task ID' },
item: { type: 'string', description: 'Checklist item text' },
},
required: ['task_id', 'item'],
},
},
{
name: 'task_checklist_toggle',
description: 'Toggle a checklist item (check/uncheck)',
inputSchema: {
type: 'object',
properties: {
item_id: { type: 'number', description: 'Checklist item ID' },
checked: { type: 'boolean', description: 'New checked state' },
},
required: ['item_id', 'checked'],
},
},
];

81
src/tools/relations.ts Normal file
View File

@@ -0,0 +1,81 @@
// Task relations: dependencies and checklists
import { query, queryOne, execute } from '../db.js';
interface TaskLinkArgs {
from_id: string;
to_id: string;
link_type: string;
}
interface ChecklistAddArgs {
task_id: string;
item: string;
}
interface ChecklistToggleArgs {
item_id: number;
checked: boolean;
}
/**
* Create a dependency between tasks
*/
export async function taskLink(args: TaskLinkArgs): Promise<string> {
const { from_id, to_id, link_type } = args;
try {
await execute(
`INSERT INTO task_links (from_task_id, to_task_id, link_type)
VALUES ($1, $2, $3)
ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`,
[from_id, to_id, link_type]
);
return `Linked: ${from_id} ${link_type} ${to_id}`;
} catch (error) {
return `Error creating link: ${error}`;
}
}
/**
* Add a checklist item to a task
*/
export async function checklistAdd(args: ChecklistAddArgs): Promise<string> {
const { task_id, item } = args;
// Get next position
const result = await queryOne<{ max: number }>(
`SELECT COALESCE(MAX(position), 0) + 1 as max
FROM task_checklist WHERE task_id = $1`,
[task_id]
);
const position = result?.max || 1;
await execute(
`INSERT INTO task_checklist (task_id, item, position)
VALUES ($1, $2, $3)`,
[task_id, item, position]
);
return `Added to ${task_id}: ${item}`;
}
/**
* Toggle a checklist item
*/
export async function checklistToggle(args: ChecklistToggleArgs): Promise<string> {
const { item_id, checked } = args;
const result = await execute(
`UPDATE task_checklist SET checked = $1 WHERE id = $2`,
[checked, item_id]
);
if (result === 0) {
return `Checklist item not found: ${item_id}`;
}
return `${checked ? 'Checked' : 'Unchecked'}: item #${item_id}`;
}

113
src/tools/search.ts Normal file
View File

@@ -0,0 +1,113 @@
// Semantic search operations
import { query, getProjectKey } from '../db.js';
import { getEmbedding, formatEmbedding } from '../embeddings.js';
import type { SimilarTask } from '../types.js';
interface TaskSimilarArgs {
query: string;
project?: string;
limit?: number;
}
interface TaskContextArgs {
description: string;
project?: string;
limit?: number;
}
/**
* Find semantically similar tasks using pgvector
*/
export async function taskSimilar(args: TaskSimilarArgs): Promise<string> {
const { query: searchQuery, project, limit = 5 } = args;
// Generate embedding for the query
const embedding = await getEmbedding(searchQuery);
if (!embedding) {
return 'Error: Could not generate embedding for search query';
}
const embeddingStr = formatEmbedding(embedding);
let whereClause = 'WHERE embedding IS NOT NULL';
const params: unknown[] = [embeddingStr, limit];
let paramIndex = 3;
if (project) {
const projectKey = await getProjectKey(project);
whereClause += ` AND project = $${paramIndex}`;
params.push(projectKey);
}
const results = await query<SimilarTask>(
`SELECT id, title, type, status, priority,
1 - (embedding <=> $1) as similarity
FROM tasks
${whereClause}
ORDER BY embedding <=> $1
LIMIT $2`,
params
);
if (results.length === 0) {
return 'No similar tasks found';
}
const lines = results.map(t => {
const pct = Math.round(t.similarity * 100);
const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]';
return `${statusIcon} ${pct}% ${t.id}: ${t.title} [${t.type}] [${t.priority}]`;
});
return `Similar tasks for "${searchQuery}":\n\n${lines.join('\n')}`;
}
/**
* Get related tasks for current work context
* Returns markdown suitable for injection into delegations
*/
export async function taskContext(args: TaskContextArgs): Promise<string> {
const { description, project, limit = 3 } = args;
// Generate embedding for the description
const embedding = await getEmbedding(description);
if (!embedding) {
return '';
}
const embeddingStr = formatEmbedding(embedding);
let whereClause = 'WHERE embedding IS NOT NULL AND status != \'completed\'';
const params: unknown[] = [embeddingStr, limit];
let paramIndex = 3;
if (project) {
const projectKey = await getProjectKey(project);
whereClause += ` AND project = $${paramIndex}`;
params.push(projectKey);
}
const results = await query<SimilarTask>(
`SELECT id, title, type, status, priority,
1 - (embedding <=> $1) as similarity
FROM tasks
${whereClause}
ORDER BY embedding <=> $1
LIMIT $2`,
params
);
if (results.length === 0) {
return '';
}
// Format as markdown for delegation context
let output = '## Related Tasks\n\n';
for (const t of results) {
const pct = Math.round(t.similarity * 100);
output += `- **${t.id}**: ${t.title} (${pct}% match, ${t.priority}, ${t.status})\n`;
}
return output;
}