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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Task MCP Server
|
||||
|
||||
Exposes task management tools via Model Context Protocol. Uses PostgreSQL with pgvector for semantic search.
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH tunnel to docker-host: `ssh -L 5435:localhost:5435 docker-host -N &`
|
||||
- PostgreSQL with pgvector on docker-host (litellm-pgvector container)
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to `~/.claude/mcp_servers.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"task-mcp": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/task-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5435",
|
||||
"DB_NAME": "litellm",
|
||||
"DB_USER": "litellm",
|
||||
"DB_PASSWORD": "litellm",
|
||||
"LLM_API_URL": "https://llm.agiliton.cloud",
|
||||
"LLM_API_KEY": "sk-master-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_add` | Create task with auto-generated ID and embedding |
|
||||
| `task_list` | List tasks with filters (project, status, type, priority) |
|
||||
| `task_show` | Show task details including checklist and dependencies |
|
||||
| `task_close` | Mark task as completed |
|
||||
| `task_update` | Update task fields |
|
||||
| `task_similar` | Find semantically similar tasks using pgvector |
|
||||
| `task_context` | Get related tasks for current work context |
|
||||
| `task_link` | Create dependency between tasks |
|
||||
| `task_checklist_add` | Add checklist item to task |
|
||||
| `task_checklist_toggle` | Toggle checklist item |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev # Run with tsx (no build)
|
||||
```
|
||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "task-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for task management with PostgreSQL/pgvector backend",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/pg": "^8.10.9",
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.7.0"
|
||||
}
|
||||
}
|
||||
120
src/db.ts
Normal file
120
src/db.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import pg from 'pg';
|
||||
const { Pool } = pg;
|
||||
|
||||
// Configuration from environment variables
|
||||
const config = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'litellm',
|
||||
user: process.env.DB_USER || 'litellm',
|
||||
password: process.env.DB_PASSWORD || 'litellm',
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
};
|
||||
|
||||
// Create connection pool
|
||||
const pool = new Pool(config);
|
||||
|
||||
// Log connection errors
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected database error:', err);
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute a query and return all rows
|
||||
*/
|
||||
export async function query<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const result = await pool.query(text, params);
|
||||
return result.rows as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return the first row
|
||||
*/
|
||||
export async function queryOne<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T | null> {
|
||||
const result = await pool.query(text, params);
|
||||
return (result.rows[0] as T) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query without returning results (INSERT, UPDATE, DELETE)
|
||||
*/
|
||||
export async function execute(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<number> {
|
||||
const result = await pool.query(text, params);
|
||||
return result.rowCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next task ID for a project
|
||||
*/
|
||||
export async function getNextTaskId(projectKey: string): Promise<string> {
|
||||
const result = await queryOne<{ next_id: number }>(
|
||||
`INSERT INTO task_sequences (project, next_id) VALUES ($1, 1)
|
||||
ON CONFLICT (project) DO UPDATE SET next_id = task_sequences.next_id + 1
|
||||
RETURNING next_id`,
|
||||
[projectKey]
|
||||
);
|
||||
return `${projectKey}-${result?.next_id || 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project key from name, or generate one
|
||||
*/
|
||||
export async function getProjectKey(projectName: string): Promise<string> {
|
||||
// First check if already registered
|
||||
const existing = await queryOne<{ key: string }>(
|
||||
`SELECT key FROM projects WHERE name = $1 LIMIT 1`,
|
||||
[projectName]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return existing.key;
|
||||
}
|
||||
|
||||
// Generate a key from the name (uppercase first letters)
|
||||
let generated = projectName.replace(/[a-z]/g, '').slice(0, 4);
|
||||
if (!generated) {
|
||||
generated = projectName.slice(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
// Register the new project
|
||||
await execute(
|
||||
`INSERT INTO projects (key, name) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO NOTHING`,
|
||||
[generated, projectName]
|
||||
);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
export async function testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await query('SELECT 1');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database connection failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection pool
|
||||
*/
|
||||
export async function close(): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
export default pool;
|
||||
58
src/embeddings.ts
Normal file
58
src/embeddings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Embeddings via LiteLLM API
|
||||
|
||||
const LLM_API_URL = process.env.LLM_API_URL || 'https://llm.agiliton.cloud';
|
||||
const LLM_API_KEY = process.env.LLM_API_KEY || '';
|
||||
|
||||
interface EmbeddingResponse {
|
||||
data: Array<{
|
||||
embedding: number[];
|
||||
index: number;
|
||||
}>;
|
||||
model: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embedding for text using LiteLLM API
|
||||
*/
|
||||
export async function getEmbedding(text: string): Promise<number[] | null> {
|
||||
if (!LLM_API_KEY) {
|
||||
console.error('LLM_API_KEY not set, skipping embedding');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${LLM_API_URL}/v1/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${LLM_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Embedding API error:', response.status, await response.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as EmbeddingResponse;
|
||||
return data.data?.[0]?.embedding || null;
|
||||
} catch (error) {
|
||||
console.error('Embedding generation failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format embedding array for PostgreSQL vector type
|
||||
*/
|
||||
export function formatEmbedding(embedding: number[]): string {
|
||||
return `[${embedding.join(',')}]`;
|
||||
}
|
||||
166
src/index.ts
Normal file
166
src/index.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Task MCP Server
|
||||
*
|
||||
* Exposes task management tools via Model Context Protocol.
|
||||
* Uses PostgreSQL with pgvector for semantic search.
|
||||
*
|
||||
* Requires SSH tunnel to docker-host:
|
||||
* ssh -L 5432:172.27.0.3:5432 docker-host -N &
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
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';
|
||||
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
{ name: 'task-mcp', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
// Register tool list handler
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: toolDefinitions,
|
||||
}));
|
||||
|
||||
// Register tool call handler
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
let result: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const a = args as any;
|
||||
|
||||
switch (name) {
|
||||
// CRUD
|
||||
case 'task_add':
|
||||
result = await taskAdd({
|
||||
title: a.title,
|
||||
project: a.project,
|
||||
type: a.type,
|
||||
priority: a.priority,
|
||||
description: a.description,
|
||||
});
|
||||
break;
|
||||
case 'task_list':
|
||||
result = await taskList({
|
||||
project: a.project,
|
||||
status: a.status,
|
||||
type: a.type,
|
||||
priority: a.priority,
|
||||
limit: a.limit,
|
||||
});
|
||||
break;
|
||||
case 'task_show':
|
||||
result = await taskShow(a.id);
|
||||
break;
|
||||
case 'task_close':
|
||||
result = await taskClose(a.id);
|
||||
break;
|
||||
case 'task_update':
|
||||
result = await taskUpdate({
|
||||
id: a.id,
|
||||
status: a.status,
|
||||
priority: a.priority,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
});
|
||||
break;
|
||||
|
||||
// Search
|
||||
case 'task_similar':
|
||||
result = await taskSimilar({
|
||||
query: a.query,
|
||||
project: a.project,
|
||||
limit: a.limit,
|
||||
});
|
||||
break;
|
||||
case 'task_context':
|
||||
result = await taskContext({
|
||||
description: a.description,
|
||||
project: a.project,
|
||||
limit: a.limit,
|
||||
});
|
||||
break;
|
||||
|
||||
// Relations
|
||||
case 'task_link':
|
||||
result = await taskLink({
|
||||
from_id: a.from_id,
|
||||
to_id: a.to_id,
|
||||
link_type: a.link_type,
|
||||
});
|
||||
break;
|
||||
case 'task_checklist_add':
|
||||
result = await checklistAdd({
|
||||
task_id: a.task_id,
|
||||
item: a.item,
|
||||
});
|
||||
break;
|
||||
case 'task_checklist_toggle':
|
||||
result = await checklistToggle({
|
||||
item_id: a.item_id,
|
||||
checked: a.checked,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: result }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Main entry point
|
||||
async function main() {
|
||||
// Test database connection
|
||||
const connected = await testConnection();
|
||||
if (!connected) {
|
||||
console.error('Failed to connect to database. Ensure SSH tunnel is active:');
|
||||
console.error(' ssh -L 5432:172.27.0.3:5432 docker-host -N &');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error('task-mcp: Connected to database');
|
||||
|
||||
// Set up cleanup
|
||||
process.on('SIGINT', async () => {
|
||||
await close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('task-mcp: Server started');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('task-mcp: Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
266
src/tools/crud.ts
Normal file
266
src/tools/crud.ts
Normal 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
138
src/tools/index.ts
Normal 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
81
src/tools/relations.ts
Normal 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
113
src/tools/search.ts
Normal 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;
|
||||
}
|
||||
71
src/types.ts
Normal file
71
src/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Task Management Types
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
project: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'task' | 'bug' | 'feature' | 'debt';
|
||||
status: 'open' | 'in_progress' | 'blocked' | 'completed';
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||
version_id?: string;
|
||||
epic_id?: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
completed_at?: Date;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
key: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ChecklistItem {
|
||||
id: number;
|
||||
task_id: string;
|
||||
item: string;
|
||||
checked: boolean;
|
||||
position: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface TaskLink {
|
||||
id: number;
|
||||
from_task_id: string;
|
||||
to_task_id: string;
|
||||
link_type: 'blocks' | 'relates_to' | 'duplicates';
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: string;
|
||||
project: string;
|
||||
version: string;
|
||||
build_number?: number;
|
||||
status: 'planned' | 'in_progress' | 'released' | 'archived';
|
||||
release_date?: Date;
|
||||
release_notes?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Epic {
|
||||
id: string;
|
||||
project: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'open' | 'in_progress' | 'completed';
|
||||
target_version_id?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface SimilarTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
similarity: number;
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user