@dalmasonto/taskflow-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.js +109 -0
- package/dist/helpers.d.ts +21 -0
- package/dist/helpers.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +54 -0
- package/dist/sse.d.ts +7 -0
- package/dist/sse.js +282 -0
- package/dist/tools/activity.d.ts +28 -0
- package/dist/tools/activity.js +56 -0
- package/dist/tools/agent.d.ts +14 -0
- package/dist/tools/agent.js +85 -0
- package/dist/tools/analytics.d.ts +21 -0
- package/dist/tools/analytics.js +108 -0
- package/dist/tools/notifications.d.ts +31 -0
- package/dist/tools/notifications.js +59 -0
- package/dist/tools/projects.d.ts +55 -0
- package/dist/tools/projects.js +112 -0
- package/dist/tools/settings.d.ts +19 -0
- package/dist/tools/settings.js +52 -0
- package/dist/tools/tasks.d.ts +103 -0
- package/dist/tools/tasks.js +351 -0
- package/dist/tools/timer.d.ts +37 -0
- package/dist/tools/timer.js +131 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +24 -0
- package/package.json +56 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getDb } from '../db.js';
|
|
3
|
+
import { logActivity, errorResponse, successResponse, now, broadcastChange } from '../helpers.js';
|
|
4
|
+
import { TaskStatus, TaskPriority, LinkSchema, VALID_TRANSITIONS } from '../types.js';
|
|
5
|
+
function parseTask(row) {
|
|
6
|
+
return {
|
|
7
|
+
...row,
|
|
8
|
+
dependencies: JSON.parse(row.dependencies),
|
|
9
|
+
links: JSON.parse(row.links),
|
|
10
|
+
tags: JSON.parse(row.tags),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function detectCycle(taskId, proposedDeps) {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
const allTasks = db.prepare('SELECT id, dependencies FROM tasks').all();
|
|
16
|
+
// Build adjacency list: task -> its dependencies (task depends on dep means edge task->dep)
|
|
17
|
+
// A cycle means: taskId depends on X which depends on Y ... which depends on taskId
|
|
18
|
+
// We need to check: from any of proposedDeps, can we reach taskId by following dependencies?
|
|
19
|
+
// Actually, the direction matters. If task A depends on B, it means B must be done before A.
|
|
20
|
+
// A cycle: A depends on B, B depends on A.
|
|
21
|
+
// Adjacency: A -> [B], B -> [A]. Starting from A's deps [B], follow B's deps [A], found A = cycle.
|
|
22
|
+
const adj = new Map();
|
|
23
|
+
for (const t of allTasks) {
|
|
24
|
+
const deps = JSON.parse(t.dependencies);
|
|
25
|
+
if (t.id === taskId) {
|
|
26
|
+
// Use proposed deps instead of current
|
|
27
|
+
adj.set(t.id, proposedDeps);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
adj.set(t.id, deps);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// If task doesn't exist yet (create), add it
|
|
34
|
+
if (!adj.has(taskId)) {
|
|
35
|
+
adj.set(taskId, proposedDeps);
|
|
36
|
+
}
|
|
37
|
+
// DFS from taskId following dependency edges to see if we return to taskId
|
|
38
|
+
const visited = new Set();
|
|
39
|
+
const inStack = new Set();
|
|
40
|
+
function dfs(node) {
|
|
41
|
+
if (inStack.has(node))
|
|
42
|
+
return true; // back-edge = cycle
|
|
43
|
+
if (visited.has(node))
|
|
44
|
+
return false;
|
|
45
|
+
visited.add(node);
|
|
46
|
+
inStack.add(node);
|
|
47
|
+
const deps = adj.get(node) || [];
|
|
48
|
+
for (const dep of deps) {
|
|
49
|
+
if (dfs(dep))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
inStack.delete(node);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return dfs(taskId);
|
|
56
|
+
}
|
|
57
|
+
// ─── exported handler functions ───────────────────────────────────────
|
|
58
|
+
export async function createTask(params) {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
const { title, description, status = 'not_started', priority = 'medium', project_id, dependencies = [], tags = [], links = [], due_date, estimated_time, } = params;
|
|
61
|
+
// Validate project_id
|
|
62
|
+
if (project_id != null) {
|
|
63
|
+
const proj = db.prepare('SELECT id FROM projects WHERE id = ?').get(project_id);
|
|
64
|
+
if (!proj)
|
|
65
|
+
return errorResponse('Project not found', 'NOT_FOUND');
|
|
66
|
+
}
|
|
67
|
+
// Validate dependencies exist
|
|
68
|
+
if (dependencies.length > 0) {
|
|
69
|
+
const placeholders = dependencies.map(() => '?').join(',');
|
|
70
|
+
const existing = db.prepare(`SELECT id FROM tasks WHERE id IN (${placeholders})`).all(...dependencies);
|
|
71
|
+
if (existing.length !== dependencies.length) {
|
|
72
|
+
return errorResponse('One or more dependency task IDs do not exist', 'VALIDATION_ERROR');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Check for cycles — we need a temporary ID; use max(id)+1 as placeholder
|
|
76
|
+
if (dependencies.length > 0) {
|
|
77
|
+
const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM tasks').get();
|
|
78
|
+
if (detectCycle(maxRow.next_id, dependencies)) {
|
|
79
|
+
return errorResponse('Adding these dependencies would create a cycle', 'CYCLE_DETECTED');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const ts = now();
|
|
83
|
+
const result = db.prepare(`INSERT INTO tasks (title, description, status, priority, project_id, dependencies, links, tags, due_date, estimated_time, created_at, updated_at)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(title, description ?? null, status, priority, project_id ?? null, JSON.stringify(dependencies), JSON.stringify(links), JSON.stringify(tags), due_date ?? null, estimated_time ?? null, ts, ts);
|
|
85
|
+
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
|
|
86
|
+
logActivity('task_created', title, { entityType: 'task', entityId: task.id });
|
|
87
|
+
const createdTask = parseTask(task);
|
|
88
|
+
broadcastChange('task', 'task_created', createdTask);
|
|
89
|
+
return successResponse(createdTask);
|
|
90
|
+
}
|
|
91
|
+
export async function listTasks(params) {
|
|
92
|
+
const db = getDb();
|
|
93
|
+
const conditions = [];
|
|
94
|
+
const values = [];
|
|
95
|
+
if (params.status) {
|
|
96
|
+
conditions.push('status = ?');
|
|
97
|
+
values.push(params.status);
|
|
98
|
+
}
|
|
99
|
+
if (params.project_id != null) {
|
|
100
|
+
conditions.push('project_id = ?');
|
|
101
|
+
values.push(params.project_id);
|
|
102
|
+
}
|
|
103
|
+
if (params.priority) {
|
|
104
|
+
conditions.push('priority = ?');
|
|
105
|
+
values.push(params.priority);
|
|
106
|
+
}
|
|
107
|
+
let sql = 'SELECT * FROM tasks';
|
|
108
|
+
if (conditions.length > 0) {
|
|
109
|
+
sql += ' WHERE ' + conditions.join(' AND ');
|
|
110
|
+
}
|
|
111
|
+
let rows = db.prepare(sql).all(...values);
|
|
112
|
+
// Filter by tag in-memory (JSON column)
|
|
113
|
+
if (params.tag) {
|
|
114
|
+
rows = rows.filter(r => {
|
|
115
|
+
const tags = JSON.parse(r.tags);
|
|
116
|
+
return tags.includes(params.tag);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return successResponse(rows.map(parseTask));
|
|
120
|
+
}
|
|
121
|
+
export async function getTask(params) {
|
|
122
|
+
const db = getDb();
|
|
123
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
124
|
+
if (!row)
|
|
125
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
126
|
+
const timeRow = db.prepare(`SELECT
|
|
127
|
+
COUNT(*) AS session_count,
|
|
128
|
+
COALESCE(SUM((julianday(COALESCE(end, datetime('now'))) - julianday(start)) * 86400000), 0) AS total_time
|
|
129
|
+
FROM sessions WHERE task_id = ?`).get(params.id);
|
|
130
|
+
return successResponse({
|
|
131
|
+
...parseTask(row),
|
|
132
|
+
total_time: Math.round(timeRow.total_time),
|
|
133
|
+
session_count: timeRow.session_count,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export async function updateTask(params) {
|
|
137
|
+
const db = getDb();
|
|
138
|
+
const old = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
139
|
+
if (!old)
|
|
140
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
141
|
+
const oldParsed = parseTask(old);
|
|
142
|
+
// Validate dependencies
|
|
143
|
+
const newDeps = params.dependencies ?? oldParsed.dependencies;
|
|
144
|
+
if (params.dependencies && params.dependencies.length > 0) {
|
|
145
|
+
const placeholders = params.dependencies.map(() => '?').join(',');
|
|
146
|
+
const existing = db.prepare(`SELECT id FROM tasks WHERE id IN (${placeholders})`).all(...params.dependencies);
|
|
147
|
+
if (existing.length !== params.dependencies.length) {
|
|
148
|
+
return errorResponse('One or more dependency task IDs do not exist', 'VALIDATION_ERROR');
|
|
149
|
+
}
|
|
150
|
+
// Cycle detection
|
|
151
|
+
if (detectCycle(params.id, params.dependencies)) {
|
|
152
|
+
return errorResponse('Adding these dependencies would create a cycle', 'CYCLE_DETECTED');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Validate project_id if changing
|
|
156
|
+
if (params.project_id != null) {
|
|
157
|
+
const proj = db.prepare('SELECT id FROM projects WHERE id = ?').get(params.project_id);
|
|
158
|
+
if (!proj)
|
|
159
|
+
return errorResponse('Project not found', 'NOT_FOUND');
|
|
160
|
+
}
|
|
161
|
+
const ts = now();
|
|
162
|
+
const updates = {};
|
|
163
|
+
if (params.title !== undefined)
|
|
164
|
+
updates.title = params.title;
|
|
165
|
+
if (params.description !== undefined)
|
|
166
|
+
updates.description = params.description;
|
|
167
|
+
if (params.status !== undefined)
|
|
168
|
+
updates.status = params.status;
|
|
169
|
+
if (params.priority !== undefined)
|
|
170
|
+
updates.priority = params.priority;
|
|
171
|
+
if (params.project_id !== undefined)
|
|
172
|
+
updates.project_id = params.project_id;
|
|
173
|
+
if (params.dependencies !== undefined)
|
|
174
|
+
updates.dependencies = JSON.stringify(params.dependencies);
|
|
175
|
+
if (params.tags !== undefined)
|
|
176
|
+
updates.tags = JSON.stringify(params.tags);
|
|
177
|
+
if (params.links !== undefined)
|
|
178
|
+
updates.links = JSON.stringify(params.links);
|
|
179
|
+
if (params.due_date !== undefined)
|
|
180
|
+
updates.due_date = params.due_date;
|
|
181
|
+
if (params.estimated_time !== undefined)
|
|
182
|
+
updates.estimated_time = params.estimated_time;
|
|
183
|
+
if (Object.keys(updates).length === 0) {
|
|
184
|
+
return successResponse(oldParsed);
|
|
185
|
+
}
|
|
186
|
+
updates.updated_at = ts;
|
|
187
|
+
const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
|
|
188
|
+
const vals = Object.values(updates);
|
|
189
|
+
db.prepare(`UPDATE tasks SET ${setClauses} WHERE id = ?`).run(...vals, params.id);
|
|
190
|
+
// Log granular changes for deps, tags, links
|
|
191
|
+
if (params.dependencies !== undefined) {
|
|
192
|
+
const added = params.dependencies.filter(d => !oldParsed.dependencies.includes(d));
|
|
193
|
+
const removed = oldParsed.dependencies.filter(d => !params.dependencies.includes(d));
|
|
194
|
+
for (const d of added) {
|
|
195
|
+
logActivity('dependency_added', oldParsed.title, { detail: `Dependency ${d} added`, entityType: 'task', entityId: params.id });
|
|
196
|
+
}
|
|
197
|
+
for (const d of removed) {
|
|
198
|
+
logActivity('dependency_removed', oldParsed.title, { detail: `Dependency ${d} removed`, entityType: 'task', entityId: params.id });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (params.tags !== undefined) {
|
|
202
|
+
const added = params.tags.filter(t => !oldParsed.tags.includes(t));
|
|
203
|
+
const removed = oldParsed.tags.filter(t => !params.tags.includes(t));
|
|
204
|
+
for (const t of added) {
|
|
205
|
+
logActivity('tag_added', oldParsed.title, { detail: `Tag "${t}" added`, entityType: 'task', entityId: params.id });
|
|
206
|
+
}
|
|
207
|
+
for (const t of removed) {
|
|
208
|
+
logActivity('tag_removed', oldParsed.title, { detail: `Tag "${t}" removed`, entityType: 'task', entityId: params.id });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (params.links !== undefined) {
|
|
212
|
+
const oldUrls = new Set(oldParsed.links.map(l => l.url));
|
|
213
|
+
const newUrls = new Set(params.links.map(l => l.url));
|
|
214
|
+
const added = params.links.filter(l => !oldUrls.has(l.url));
|
|
215
|
+
const removed = oldParsed.links.filter(l => !newUrls.has(l.url));
|
|
216
|
+
for (const l of added) {
|
|
217
|
+
logActivity('link_added', oldParsed.title, { detail: `Link "${l.label}" added`, entityType: 'task', entityId: params.id });
|
|
218
|
+
}
|
|
219
|
+
for (const l of removed) {
|
|
220
|
+
logActivity('task_unlinked', oldParsed.title, { detail: `Link "${l.label}" removed`, entityType: 'task', entityId: params.id });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
224
|
+
const updatedTask = parseTask(updated);
|
|
225
|
+
broadcastChange('task', 'task_updated', updatedTask);
|
|
226
|
+
return successResponse(updatedTask);
|
|
227
|
+
}
|
|
228
|
+
export async function updateTaskStatus(params) {
|
|
229
|
+
const db = getDb();
|
|
230
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
231
|
+
if (!row)
|
|
232
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
233
|
+
const currentStatus = row.status;
|
|
234
|
+
const allowed = VALID_TRANSITIONS[currentStatus];
|
|
235
|
+
if (!allowed || !allowed.includes(params.status)) {
|
|
236
|
+
return errorResponse(`Cannot transition from "${currentStatus}" to "${params.status}"`, 'INVALID_TRANSITION');
|
|
237
|
+
}
|
|
238
|
+
const ts = now();
|
|
239
|
+
db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run(params.status, ts, params.id);
|
|
240
|
+
// Log appropriate action
|
|
241
|
+
let statusAction;
|
|
242
|
+
if (params.status === 'done') {
|
|
243
|
+
logActivity('task_completed', row.title, { entityType: 'task', entityId: params.id });
|
|
244
|
+
statusAction = 'task_completed';
|
|
245
|
+
}
|
|
246
|
+
else if (params.status === 'partial_done') {
|
|
247
|
+
logActivity('task_partial_done', row.title, { entityType: 'task', entityId: params.id });
|
|
248
|
+
statusAction = 'task_partial_done';
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
logActivity('task_status_changed', row.title, {
|
|
252
|
+
detail: `${currentStatus} -> ${params.status}`,
|
|
253
|
+
entityType: 'task',
|
|
254
|
+
entityId: params.id,
|
|
255
|
+
});
|
|
256
|
+
statusAction = 'task_status_changed';
|
|
257
|
+
}
|
|
258
|
+
const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
259
|
+
const updatedTask = parseTask(updated);
|
|
260
|
+
broadcastChange('task', statusAction, updatedTask);
|
|
261
|
+
return successResponse(updatedTask);
|
|
262
|
+
}
|
|
263
|
+
export async function deleteTask(params) {
|
|
264
|
+
const db = getDb();
|
|
265
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(params.id);
|
|
266
|
+
if (!row)
|
|
267
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
268
|
+
db.prepare('DELETE FROM tasks WHERE id = ?').run(params.id);
|
|
269
|
+
logActivity('task_deleted', row.title, { entityType: 'task', entityId: params.id });
|
|
270
|
+
broadcastChange('task', 'task_deleted', { id: params.id });
|
|
271
|
+
return successResponse({ deleted: true, id: params.id });
|
|
272
|
+
}
|
|
273
|
+
export async function bulkCreateTasks(params) {
|
|
274
|
+
const db = getDb();
|
|
275
|
+
const ts = now();
|
|
276
|
+
const created = [];
|
|
277
|
+
const insertStmt = db.prepare(`INSERT INTO tasks (title, description, status, priority, project_id, dependencies, links, tags, due_date, estimated_time, created_at, updated_at)
|
|
278
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
279
|
+
const transaction = db.transaction(() => {
|
|
280
|
+
for (const t of params.tasks) {
|
|
281
|
+
const result = insertStmt.run(t.title, t.description ?? null, t.status ?? 'not_started', t.priority ?? 'medium', t.project_id ?? null, JSON.stringify(t.dependencies ?? []), '[]', JSON.stringify(t.tags ?? []), null, null, ts, ts);
|
|
282
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
|
|
283
|
+
created.push(parseTask(row));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
transaction();
|
|
287
|
+
logActivity('tasks_bulk_created', `${created.length} tasks created`, {
|
|
288
|
+
detail: created.map(t => t.title).join(', '),
|
|
289
|
+
});
|
|
290
|
+
const createdTasks = created;
|
|
291
|
+
broadcastChange('task', 'tasks_bulk_created', { tasks: createdTasks });
|
|
292
|
+
return successResponse(createdTasks);
|
|
293
|
+
}
|
|
294
|
+
export async function searchTasks(params) {
|
|
295
|
+
const db = getDb();
|
|
296
|
+
const like = `%${params.query}%`;
|
|
297
|
+
const rows = db.prepare(`SELECT * FROM tasks WHERE title LIKE ? COLLATE NOCASE OR description LIKE ? COLLATE NOCASE`).all(like, like);
|
|
298
|
+
return successResponse(rows.map(parseTask));
|
|
299
|
+
}
|
|
300
|
+
// ─── MCP registration ─────────────────────────────────────────────────
|
|
301
|
+
export function registerTaskTools(server) {
|
|
302
|
+
server.tool('create_task', 'Create a new task. Use this when starting new work to keep TaskFlow in sync. Supports dependencies, tags, links, and time estimates.', {
|
|
303
|
+
title: z.string(),
|
|
304
|
+
description: z.string().optional(),
|
|
305
|
+
status: TaskStatus.optional(),
|
|
306
|
+
priority: TaskPriority.optional(),
|
|
307
|
+
project_id: z.number().optional(),
|
|
308
|
+
dependencies: z.array(z.number()).optional(),
|
|
309
|
+
tags: z.array(z.string()).optional(),
|
|
310
|
+
links: z.array(LinkSchema).optional(),
|
|
311
|
+
due_date: z.string().optional(),
|
|
312
|
+
estimated_time: z.number().optional(),
|
|
313
|
+
}, async (params) => createTask(params));
|
|
314
|
+
server.tool('list_tasks', 'List tasks with optional filters. Use at conversation start to see what is in progress or blocked. Filter by status, project, priority, or tag.', {
|
|
315
|
+
status: TaskStatus.optional(),
|
|
316
|
+
project_id: z.number().optional(),
|
|
317
|
+
priority: TaskPriority.optional(),
|
|
318
|
+
tag: z.string().optional(),
|
|
319
|
+
}, async (params) => listTasks(params));
|
|
320
|
+
server.tool('get_task', 'Get a task by ID with time tracking info. Read the description carefully — it often contains implementation details and acceptance criteria.', { id: z.number() }, async (params) => getTask(params));
|
|
321
|
+
server.tool('update_task', 'Update task fields. Use this to add details, update descriptions with progress notes, or adjust priority as you learn more.', {
|
|
322
|
+
id: z.number(),
|
|
323
|
+
title: z.string().optional(),
|
|
324
|
+
description: z.string().optional(),
|
|
325
|
+
status: TaskStatus.optional(),
|
|
326
|
+
priority: TaskPriority.optional(),
|
|
327
|
+
project_id: z.number().optional(),
|
|
328
|
+
dependencies: z.array(z.number()).optional(),
|
|
329
|
+
tags: z.array(z.string()).optional(),
|
|
330
|
+
links: z.array(LinkSchema).optional(),
|
|
331
|
+
due_date: z.string().optional(),
|
|
332
|
+
estimated_time: z.number().optional(),
|
|
333
|
+
}, async (params) => updateTask(params));
|
|
334
|
+
server.tool('update_task_status', 'Update task status with transition validation. Use when a task becomes blocked, is partially done, or needs to be reopened.', {
|
|
335
|
+
id: z.number(),
|
|
336
|
+
status: TaskStatus,
|
|
337
|
+
}, async (params) => updateTaskStatus(params));
|
|
338
|
+
server.tool('delete_task', 'Delete a task by ID. Use sparingly — prefer updating status to "done" instead of deleting.', { id: z.number() }, async (params) => deleteTask(params));
|
|
339
|
+
server.tool('bulk_create_tasks', 'Create multiple tasks in a single transaction. Useful when breaking down a feature into subtasks.', {
|
|
340
|
+
tasks: z.array(z.object({
|
|
341
|
+
title: z.string(),
|
|
342
|
+
description: z.string().optional(),
|
|
343
|
+
priority: TaskPriority.optional(),
|
|
344
|
+
project_id: z.number().optional(),
|
|
345
|
+
status: TaskStatus.optional(),
|
|
346
|
+
dependencies: z.array(z.number()).optional(),
|
|
347
|
+
tags: z.array(z.string()).optional(),
|
|
348
|
+
})),
|
|
349
|
+
}, async (params) => bulkCreateTasks(params));
|
|
350
|
+
server.tool('search_tasks', 'Search tasks by title or description. Use this to find tasks related to your current work before creating duplicates.', { query: z.string() }, async (params) => searchTasks(params));
|
|
351
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function startTimer(params: {
|
|
3
|
+
task_id: number;
|
|
4
|
+
}): Promise<{
|
|
5
|
+
content: {
|
|
6
|
+
type: "text";
|
|
7
|
+
text: string;
|
|
8
|
+
}[];
|
|
9
|
+
}>;
|
|
10
|
+
export declare function pauseTimer(params: {
|
|
11
|
+
task_id: number;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
content: {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}[];
|
|
17
|
+
}>;
|
|
18
|
+
export declare function stopTimer(params: {
|
|
19
|
+
task_id: number;
|
|
20
|
+
final_status?: string;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
content: {
|
|
23
|
+
type: "text";
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
}>;
|
|
27
|
+
export declare function listSessions(params: {
|
|
28
|
+
task_id?: number;
|
|
29
|
+
start_date?: string;
|
|
30
|
+
end_date?: string;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
content: {
|
|
33
|
+
type: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}[];
|
|
36
|
+
}>;
|
|
37
|
+
export declare function registerTimerTools(server: McpServer): void;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getDb } from '../db.js';
|
|
3
|
+
import { logActivity, errorResponse, successResponse, now, broadcastChange } from '../helpers.js';
|
|
4
|
+
import { VALID_TRANSITIONS } from '../types.js';
|
|
5
|
+
// ─── exported handler functions ───────────────────────────────────────
|
|
6
|
+
export async function startTimer(params) {
|
|
7
|
+
const db = getDb();
|
|
8
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(params.task_id);
|
|
9
|
+
if (!task)
|
|
10
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
11
|
+
// Check no open session exists
|
|
12
|
+
const openSession = db.prepare('SELECT id FROM sessions WHERE task_id = ? AND end IS NULL').get(params.task_id);
|
|
13
|
+
if (openSession)
|
|
14
|
+
return errorResponse('A timer session is already active for this task', 'SESSION_ALREADY_ACTIVE');
|
|
15
|
+
// Check valid transition to in_progress
|
|
16
|
+
const currentStatus = task.status;
|
|
17
|
+
const allowed = VALID_TRANSITIONS[currentStatus];
|
|
18
|
+
if (!allowed || !allowed.includes('in_progress')) {
|
|
19
|
+
return errorResponse(`Cannot transition from "${currentStatus}" to "in_progress"`, 'INVALID_TRANSITION');
|
|
20
|
+
}
|
|
21
|
+
const ts = now();
|
|
22
|
+
// Create session
|
|
23
|
+
const result = db.prepare('INSERT INTO sessions (task_id, start, end) VALUES (?, ?, ?)').run(params.task_id, ts, null);
|
|
24
|
+
// Update task status to in_progress
|
|
25
|
+
db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run('in_progress', ts, params.task_id);
|
|
26
|
+
logActivity('timer_started', task.title, { entityType: 'task', entityId: params.task_id });
|
|
27
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
|
28
|
+
broadcastChange('timer', 'timer_started', { task_id: params.task_id, session, task_status: 'in_progress' });
|
|
29
|
+
return successResponse(session);
|
|
30
|
+
}
|
|
31
|
+
export async function pauseTimer(params) {
|
|
32
|
+
const db = getDb();
|
|
33
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(params.task_id);
|
|
34
|
+
if (!task)
|
|
35
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
36
|
+
// Find open session
|
|
37
|
+
const session = db.prepare('SELECT * FROM sessions WHERE task_id = ? AND end IS NULL').get(params.task_id);
|
|
38
|
+
if (!session)
|
|
39
|
+
return errorResponse('No active timer session for this task', 'NO_ACTIVE_SESSION');
|
|
40
|
+
const endTime = now();
|
|
41
|
+
const duration = new Date(endTime).getTime() - new Date(session.start).getTime();
|
|
42
|
+
// Close session
|
|
43
|
+
db.prepare('UPDATE sessions SET end = ? WHERE id = ?').run(endTime, session.id);
|
|
44
|
+
// Update task to paused
|
|
45
|
+
db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run('paused', endTime, params.task_id);
|
|
46
|
+
logActivity('timer_paused', task.title, {
|
|
47
|
+
detail: `Duration: ${duration}ms`,
|
|
48
|
+
entityType: 'task',
|
|
49
|
+
entityId: params.task_id,
|
|
50
|
+
});
|
|
51
|
+
const pausedSession = { ...session, end: endTime, duration };
|
|
52
|
+
broadcastChange('timer', 'timer_paused', { task_id: params.task_id, session: pausedSession, task_status: 'paused' });
|
|
53
|
+
return successResponse(pausedSession);
|
|
54
|
+
}
|
|
55
|
+
export async function stopTimer(params) {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(params.task_id);
|
|
58
|
+
if (!task)
|
|
59
|
+
return errorResponse('Task not found', 'NOT_FOUND');
|
|
60
|
+
// Find open session
|
|
61
|
+
const session = db.prepare('SELECT * FROM sessions WHERE task_id = ? AND end IS NULL').get(params.task_id);
|
|
62
|
+
if (!session)
|
|
63
|
+
return errorResponse('No active timer session for this task', 'NO_ACTIVE_SESSION');
|
|
64
|
+
const finalStatus = params.final_status ?? 'done';
|
|
65
|
+
const endTime = now();
|
|
66
|
+
const duration = new Date(endTime).getTime() - new Date(session.start).getTime();
|
|
67
|
+
// Close session
|
|
68
|
+
db.prepare('UPDATE sessions SET end = ? WHERE id = ?').run(endTime, session.id);
|
|
69
|
+
// Update task to final_status
|
|
70
|
+
db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run(finalStatus, endTime, params.task_id);
|
|
71
|
+
logActivity('timer_stopped', task.title, {
|
|
72
|
+
detail: `Duration: ${duration}ms, final status: ${finalStatus}`,
|
|
73
|
+
entityType: 'task',
|
|
74
|
+
entityId: params.task_id,
|
|
75
|
+
});
|
|
76
|
+
// Also log status-specific action
|
|
77
|
+
if (finalStatus === 'done') {
|
|
78
|
+
logActivity('task_completed', task.title, { entityType: 'task', entityId: params.task_id });
|
|
79
|
+
}
|
|
80
|
+
else if (finalStatus === 'partial_done') {
|
|
81
|
+
logActivity('task_partial_done', task.title, { entityType: 'task', entityId: params.task_id });
|
|
82
|
+
}
|
|
83
|
+
const stoppedSession = { ...session, end: endTime, duration };
|
|
84
|
+
broadcastChange('timer', 'timer_stopped', { task_id: params.task_id, session: stoppedSession, task_status: finalStatus });
|
|
85
|
+
return successResponse(stoppedSession);
|
|
86
|
+
}
|
|
87
|
+
export async function listSessions(params) {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const conditions = [];
|
|
90
|
+
const values = [];
|
|
91
|
+
if (params.task_id != null) {
|
|
92
|
+
conditions.push('task_id = ?');
|
|
93
|
+
values.push(params.task_id);
|
|
94
|
+
}
|
|
95
|
+
if (params.start_date) {
|
|
96
|
+
conditions.push('start >= ?');
|
|
97
|
+
values.push(params.start_date);
|
|
98
|
+
}
|
|
99
|
+
if (params.end_date) {
|
|
100
|
+
conditions.push('start <= ?');
|
|
101
|
+
values.push(params.end_date);
|
|
102
|
+
}
|
|
103
|
+
let sql = 'SELECT * FROM sessions';
|
|
104
|
+
if (conditions.length > 0) {
|
|
105
|
+
sql += ' WHERE ' + conditions.join(' AND ');
|
|
106
|
+
}
|
|
107
|
+
sql += ' ORDER BY start ASC';
|
|
108
|
+
const rows = db.prepare(sql).all(...values);
|
|
109
|
+
const nowMs = Date.now();
|
|
110
|
+
const sessions = rows.map(row => {
|
|
111
|
+
const startMs = new Date(row.start).getTime();
|
|
112
|
+
const endMs = row.end ? new Date(row.end).getTime() : nowMs;
|
|
113
|
+
const duration = endMs - startMs;
|
|
114
|
+
return { ...row, duration };
|
|
115
|
+
});
|
|
116
|
+
return successResponse(sessions);
|
|
117
|
+
}
|
|
118
|
+
// ─── MCP registration ─────────────────────────────────────────────────
|
|
119
|
+
export function registerTimerTools(server) {
|
|
120
|
+
server.tool('start_timer', 'Start a timer session for a task. Call this before beginning work on any task to track focused time. Automatically transitions the task to "in_progress".', { task_id: z.number() }, async (params) => startTimer(params));
|
|
121
|
+
server.tool('pause_timer', 'Pause the active timer session for a task. Call when switching context or waiting for user input. The task transitions to "paused".', { task_id: z.number() }, async (params) => pauseTimer(params));
|
|
122
|
+
server.tool('stop_timer', 'Stop the active timer and set a final status. Call when finishing work — use "done" if complete, "partial_done" if more remains, "blocked" if stuck.', {
|
|
123
|
+
task_id: z.number(),
|
|
124
|
+
final_status: z.enum(['done', 'partial_done', 'blocked']).optional(),
|
|
125
|
+
}, async (params) => stopTimer(params));
|
|
126
|
+
server.tool('list_sessions', 'List timer sessions with optional filters. Use to review time spent on a task or across a date range.', {
|
|
127
|
+
task_id: z.number().optional(),
|
|
128
|
+
start_date: z.string().optional(),
|
|
129
|
+
end_date: z.string().optional(),
|
|
130
|
+
}, async (params) => listSessions(params));
|
|
131
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const TaskStatus: z.ZodEnum<{
|
|
3
|
+
not_started: "not_started";
|
|
4
|
+
in_progress: "in_progress";
|
|
5
|
+
paused: "paused";
|
|
6
|
+
blocked: "blocked";
|
|
7
|
+
partial_done: "partial_done";
|
|
8
|
+
done: "done";
|
|
9
|
+
}>;
|
|
10
|
+
export type TaskStatus = z.infer<typeof TaskStatus>;
|
|
11
|
+
export declare const TaskPriority: z.ZodEnum<{
|
|
12
|
+
low: "low";
|
|
13
|
+
medium: "medium";
|
|
14
|
+
high: "high";
|
|
15
|
+
critical: "critical";
|
|
16
|
+
}>;
|
|
17
|
+
export type TaskPriority = z.infer<typeof TaskPriority>;
|
|
18
|
+
export declare const ProjectType: z.ZodEnum<{
|
|
19
|
+
active_project: "active_project";
|
|
20
|
+
project_idea: "project_idea";
|
|
21
|
+
}>;
|
|
22
|
+
export type ProjectType = z.infer<typeof ProjectType>;
|
|
23
|
+
export declare const NotificationType: z.ZodEnum<{
|
|
24
|
+
info: "info";
|
|
25
|
+
success: "success";
|
|
26
|
+
warning: "warning";
|
|
27
|
+
error: "error";
|
|
28
|
+
}>;
|
|
29
|
+
export type NotificationType = z.infer<typeof NotificationType>;
|
|
30
|
+
export declare const ActivityAction: z.ZodEnum<{
|
|
31
|
+
task_created: "task_created";
|
|
32
|
+
task_deleted: "task_deleted";
|
|
33
|
+
task_status_changed: "task_status_changed";
|
|
34
|
+
task_completed: "task_completed";
|
|
35
|
+
task_partial_done: "task_partial_done";
|
|
36
|
+
timer_started: "timer_started";
|
|
37
|
+
timer_paused: "timer_paused";
|
|
38
|
+
timer_stopped: "timer_stopped";
|
|
39
|
+
project_created: "project_created";
|
|
40
|
+
project_deleted: "project_deleted";
|
|
41
|
+
project_updated: "project_updated";
|
|
42
|
+
tasks_bulk_created: "tasks_bulk_created";
|
|
43
|
+
settings_saved: "settings_saved";
|
|
44
|
+
data_seeded: "data_seeded";
|
|
45
|
+
data_cleared: "data_cleared";
|
|
46
|
+
task_linked: "task_linked";
|
|
47
|
+
task_unlinked: "task_unlinked";
|
|
48
|
+
dependency_added: "dependency_added";
|
|
49
|
+
dependency_removed: "dependency_removed";
|
|
50
|
+
link_added: "link_added";
|
|
51
|
+
tag_added: "tag_added";
|
|
52
|
+
tag_removed: "tag_removed";
|
|
53
|
+
debug_log: "debug_log";
|
|
54
|
+
}>;
|
|
55
|
+
export type ActivityAction = z.infer<typeof ActivityAction>;
|
|
56
|
+
export declare const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]>;
|
|
57
|
+
export type ErrorCode = 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR' | 'CYCLE_DETECTED' | 'SESSION_ALREADY_ACTIVE' | 'NO_ACTIVE_SESSION';
|
|
58
|
+
export declare const LinkSchema: z.ZodObject<{
|
|
59
|
+
label: z.ZodString;
|
|
60
|
+
url: z.ZodString;
|
|
61
|
+
}, z.core.$strip>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const TaskStatus = z.enum([
|
|
3
|
+
'not_started', 'in_progress', 'paused', 'blocked', 'partial_done', 'done'
|
|
4
|
+
]);
|
|
5
|
+
export const TaskPriority = z.enum(['low', 'medium', 'high', 'critical']);
|
|
6
|
+
export const ProjectType = z.enum(['active_project', 'project_idea']);
|
|
7
|
+
export const NotificationType = z.enum(['info', 'success', 'warning', 'error']);
|
|
8
|
+
export const ActivityAction = z.enum([
|
|
9
|
+
'task_created', 'task_deleted', 'task_status_changed', 'task_completed',
|
|
10
|
+
'task_partial_done', 'timer_started', 'timer_paused', 'timer_stopped',
|
|
11
|
+
'project_created', 'project_deleted', 'project_updated',
|
|
12
|
+
'tasks_bulk_created', 'settings_saved', 'data_seeded', 'data_cleared',
|
|
13
|
+
'task_linked', 'task_unlinked', 'dependency_added', 'dependency_removed',
|
|
14
|
+
'link_added', 'tag_added', 'tag_removed', 'debug_log',
|
|
15
|
+
]);
|
|
16
|
+
export const VALID_TRANSITIONS = {
|
|
17
|
+
not_started: ['in_progress', 'blocked'],
|
|
18
|
+
in_progress: ['paused', 'blocked', 'partial_done', 'done'],
|
|
19
|
+
paused: ['in_progress', 'blocked', 'partial_done', 'done'],
|
|
20
|
+
blocked: ['not_started', 'in_progress'],
|
|
21
|
+
partial_done: ['in_progress', 'done'],
|
|
22
|
+
done: ['in_progress'],
|
|
23
|
+
};
|
|
24
|
+
export const LinkSchema = z.object({ label: z.string(), url: z.string() });
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dalmasonto/taskflow-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for TaskFlow — manage projects, tasks, timers, analytics via AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"taskflow-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"mcp",
|
|
12
|
+
"model-context-protocol",
|
|
13
|
+
"task-manager",
|
|
14
|
+
"ai-agent",
|
|
15
|
+
"claude",
|
|
16
|
+
"cursor",
|
|
17
|
+
"windsurf",
|
|
18
|
+
"productivity",
|
|
19
|
+
"time-tracking",
|
|
20
|
+
"sqlite"
|
|
21
|
+
],
|
|
22
|
+
"author": "dalmasonto",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/dalmasonto/task_flow",
|
|
27
|
+
"directory": "mcp-server"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/dalmasonto/task_flow#mcp-server-ai-agent-tools",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc",
|
|
32
|
+
"dev": "tsc --watch",
|
|
33
|
+
"start": "node dist/index.js",
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "latest",
|
|
40
|
+
"better-sqlite3": "latest",
|
|
41
|
+
"zod": "latest"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/better-sqlite3": "latest",
|
|
45
|
+
"@types/node": "latest",
|
|
46
|
+
"typescript": "latest",
|
|
47
|
+
"vitest": "latest"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18"
|
|
55
|
+
}
|
|
56
|
+
}
|