@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.
@@ -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
+ }
@@ -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
+ }