vector-task-mcp 1.0.0__py3-none-any.whl
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.
- main.py +913 -0
- src/__init__.py +44 -0
- src/embeddings.py +68 -0
- src/models.py +162 -0
- src/security.py +712 -0
- src/task_store.py +1021 -0
- vector_task_mcp-1.0.0.dist-info/METADATA +640 -0
- vector_task_mcp-1.0.0.dist-info/RECORD +12 -0
- vector_task_mcp-1.0.0.dist-info/WHEEL +5 -0
- vector_task_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- vector_task_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- vector_task_mcp-1.0.0.dist-info/top_level.txt +2 -0
main.py
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# /// script
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "mcp>=0.3.0",
|
|
6
|
+
# "sqlite-vec>=0.1.6",
|
|
7
|
+
# "sentence-transformers>=2.2.2"
|
|
8
|
+
# ]
|
|
9
|
+
# requires-python = ">=3.8"
|
|
10
|
+
# ///
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
Vector Task MCP Server - Main Entry Point
|
|
14
|
+
==========================================
|
|
15
|
+
|
|
16
|
+
A secure, vector-based task management server using sqlite-vec for semantic search.
|
|
17
|
+
Stores and retrieves tasks with vector embeddings for intelligent task retrieval.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python main.py --working-dir /path/to/project
|
|
21
|
+
|
|
22
|
+
Task database stored in: {working_dir}/memory/tasks.db
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Dict, Any
|
|
29
|
+
|
|
30
|
+
# Add src to path for imports
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
|
32
|
+
|
|
33
|
+
from mcp.server.fastmcp import FastMCP
|
|
34
|
+
|
|
35
|
+
# Import our modules
|
|
36
|
+
from src.models import Config
|
|
37
|
+
from src.security import validate_working_dir, SecurityError, validate_task_list_params
|
|
38
|
+
from src.task_store import TaskStore
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_working_dir() -> Path:
|
|
42
|
+
"""Get working directory from command line arguments"""
|
|
43
|
+
if "--working-dir" in sys.argv:
|
|
44
|
+
idx = sys.argv.index("--working-dir")
|
|
45
|
+
if idx + 1 < len(sys.argv):
|
|
46
|
+
return validate_working_dir(sys.argv[idx + 1])
|
|
47
|
+
# Default to current directory
|
|
48
|
+
return validate_working_dir(".")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_server() -> FastMCP:
|
|
52
|
+
"""Create and configure the MCP server"""
|
|
53
|
+
|
|
54
|
+
# Initialize task store
|
|
55
|
+
try:
|
|
56
|
+
working_dir = get_working_dir()
|
|
57
|
+
memory_dir = working_dir / "memory"
|
|
58
|
+
memory_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
task_db_path = memory_dir / "tasks.db"
|
|
60
|
+
task_store = TaskStore(task_db_path)
|
|
61
|
+
print(f"Task database initialized: {task_db_path}", file=sys.stderr)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"Failed to initialize task store: {e}", file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
# Create FastMCP server
|
|
67
|
+
mcp = FastMCP(Config.SERVER_NAME)
|
|
68
|
+
|
|
69
|
+
# ===============================================================================
|
|
70
|
+
# TASK MANAGEMENT TOOLS
|
|
71
|
+
# ===============================================================================
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def task_create(
|
|
75
|
+
title: str,
|
|
76
|
+
content: str,
|
|
77
|
+
parent_id: int = None,
|
|
78
|
+
comment: str = None,
|
|
79
|
+
priority: str = None,
|
|
80
|
+
tags: list[str] = None
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Create new task with vector embedding for semantic search.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
title: Task title (max 200 chars)
|
|
87
|
+
content: Task description/details (max 10K chars)
|
|
88
|
+
parent_id: Optional parent task ID for subtasks
|
|
89
|
+
comment: Optional comment/note for the task
|
|
90
|
+
priority: Optional task priority (low, medium, high, critical, default: medium)
|
|
91
|
+
tags: Optional list of tags for organization (max 10)
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
result = task_store.create_task(title, content, parent_id, comment, priority, tags)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
except SecurityError as e:
|
|
98
|
+
return {
|
|
99
|
+
"success": False,
|
|
100
|
+
"error": "Security validation failed",
|
|
101
|
+
"message": str(e)
|
|
102
|
+
}
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return {
|
|
105
|
+
"success": False,
|
|
106
|
+
"error": "Task creation failed",
|
|
107
|
+
"message": str(e)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@mcp.tool()
|
|
111
|
+
def task_create_bulk(tasks: list[dict]) -> dict[str, Any]:
|
|
112
|
+
"""
|
|
113
|
+
Create multiple tasks in bulk with vector embeddings.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
tasks: List of task objects with fields:
|
|
117
|
+
- title (required): Task title (max 200 chars)
|
|
118
|
+
- content (required): Task description (max 10K chars)
|
|
119
|
+
- parent_id (optional): Parent task ID for subtasks
|
|
120
|
+
- comment (optional): Comment/note for the task
|
|
121
|
+
- tags (optional): List of tags for organization (max 10)
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
tasks = [
|
|
125
|
+
{"title": "Task 1", "content": "Description", "parent_id": None, "comment": "Note", "tags": ["backend", "api"]},
|
|
126
|
+
{"title": "Task 2", "content": "Description", "parent_id": 1, "comment": None, "tags": ["frontend"]}
|
|
127
|
+
]
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
result = task_store.create_tasks_bulk(tasks)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
except SecurityError as e:
|
|
134
|
+
return {
|
|
135
|
+
"success": False,
|
|
136
|
+
"error": "Security validation failed",
|
|
137
|
+
"message": str(e)
|
|
138
|
+
}
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return {
|
|
141
|
+
"success": False,
|
|
142
|
+
"error": "Bulk task creation failed",
|
|
143
|
+
"message": str(e)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
def task_update(
|
|
148
|
+
task_id: int,
|
|
149
|
+
title: str | None = None,
|
|
150
|
+
content: str | None = None,
|
|
151
|
+
status: str | None = None,
|
|
152
|
+
parent_id: int | None = None,
|
|
153
|
+
comment: str | None = None,
|
|
154
|
+
start_at: str | None = None,
|
|
155
|
+
finish_at: str | None = None,
|
|
156
|
+
priority: str | None = None,
|
|
157
|
+
tags: list[str] | None = None
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Update task fields by ID.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
task_id: Task ID to update
|
|
164
|
+
title: Optional new title
|
|
165
|
+
content: Optional new content
|
|
166
|
+
status: Optional new status (pending, in_progress, completed, stopped)
|
|
167
|
+
parent_id: Optional new parent task ID
|
|
168
|
+
comment: Optional comment to add or replace
|
|
169
|
+
start_at: Optional start timestamp (ISO 8601 format)
|
|
170
|
+
finish_at: Optional finish timestamp (ISO 8601 format)
|
|
171
|
+
priority: Optional new priority (low, medium, high, critical)
|
|
172
|
+
tags: Optional list of tags to replace existing tags
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
176
|
+
return {
|
|
177
|
+
"success": False,
|
|
178
|
+
"error": "Invalid parameter",
|
|
179
|
+
"message": "task_id must be a positive integer"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Build kwargs from provided parameters
|
|
183
|
+
kwargs = {}
|
|
184
|
+
if title is not None:
|
|
185
|
+
kwargs['title'] = title
|
|
186
|
+
if content is not None:
|
|
187
|
+
kwargs['content'] = content
|
|
188
|
+
if status is not None:
|
|
189
|
+
kwargs['status'] = status
|
|
190
|
+
if parent_id is not None:
|
|
191
|
+
kwargs['parent_id'] = parent_id
|
|
192
|
+
if comment is not None:
|
|
193
|
+
kwargs['comment'] = comment
|
|
194
|
+
if start_at is not None:
|
|
195
|
+
kwargs['start_at'] = start_at
|
|
196
|
+
if finish_at is not None:
|
|
197
|
+
kwargs['finish_at'] = finish_at
|
|
198
|
+
if priority is not None:
|
|
199
|
+
kwargs['priority'] = priority
|
|
200
|
+
if tags is not None:
|
|
201
|
+
kwargs['tags'] = tags
|
|
202
|
+
|
|
203
|
+
result = task_store.update_task(task_id, **kwargs)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
except SecurityError as e:
|
|
207
|
+
return {
|
|
208
|
+
"success": False,
|
|
209
|
+
"error": "Security validation failed",
|
|
210
|
+
"message": str(e)
|
|
211
|
+
}
|
|
212
|
+
except Exception as e:
|
|
213
|
+
return {
|
|
214
|
+
"success": False,
|
|
215
|
+
"error": "Task update failed",
|
|
216
|
+
"message": str(e)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@mcp.tool()
|
|
220
|
+
def task_delete(task_id: int) -> dict[str, Any]:
|
|
221
|
+
"""
|
|
222
|
+
Delete task by ID (permanent, cannot be undone).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
task_id: Task ID to delete
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
229
|
+
return {
|
|
230
|
+
"success": False,
|
|
231
|
+
"error": "Invalid parameter",
|
|
232
|
+
"message": "task_id must be a positive integer"
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
deleted = task_store.delete_task(task_id)
|
|
236
|
+
|
|
237
|
+
if not deleted:
|
|
238
|
+
return {
|
|
239
|
+
"success": False,
|
|
240
|
+
"error": "Not found",
|
|
241
|
+
"message": f"Task with ID {task_id} not found"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"success": True,
|
|
246
|
+
"task_id": task_id,
|
|
247
|
+
"message": "Task deleted successfully"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
return {
|
|
252
|
+
"success": False,
|
|
253
|
+
"error": "Deletion failed",
|
|
254
|
+
"message": str(e)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@mcp.tool()
|
|
258
|
+
def task_delete_bulk(task_ids: list[int]) -> dict[str, Any]:
|
|
259
|
+
"""
|
|
260
|
+
Delete multiple tasks by IDs (permanent, cannot be undone).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
task_ids: List of task IDs to delete
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
result = task_store.delete_tasks_bulk(task_ids)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
except SecurityError as e:
|
|
270
|
+
return {
|
|
271
|
+
"success": False,
|
|
272
|
+
"error": "Security validation failed",
|
|
273
|
+
"message": str(e)
|
|
274
|
+
}
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return {
|
|
277
|
+
"success": False,
|
|
278
|
+
"error": "Bulk deletion failed",
|
|
279
|
+
"message": str(e)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
@mcp.tool()
|
|
283
|
+
def task_last() -> dict[str, Any]:
|
|
284
|
+
"""Get last created task."""
|
|
285
|
+
try:
|
|
286
|
+
task = task_store.get_last_task()
|
|
287
|
+
|
|
288
|
+
if task is None:
|
|
289
|
+
return {
|
|
290
|
+
"success": False,
|
|
291
|
+
"error": "Not found",
|
|
292
|
+
"message": "No tasks found in database"
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"success": True,
|
|
297
|
+
"task": task.to_dict(),
|
|
298
|
+
"message": "Last task retrieved successfully"
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
except Exception as e:
|
|
302
|
+
return {
|
|
303
|
+
"success": False,
|
|
304
|
+
"error": "Retrieval failed",
|
|
305
|
+
"message": str(e)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
def task_start(task_id: int) -> dict[str, Any]:
|
|
310
|
+
"""
|
|
311
|
+
Start task (set status to in_progress, record start time).
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
task_id: Task ID to start
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
318
|
+
return {
|
|
319
|
+
"success": False,
|
|
320
|
+
"error": "Invalid parameter",
|
|
321
|
+
"message": "task_id must be a positive integer"
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Fetch current task to validate status transition
|
|
325
|
+
current_task = task_store.get_task_by_id(task_id)
|
|
326
|
+
if current_task is None:
|
|
327
|
+
return {
|
|
328
|
+
"success": False,
|
|
329
|
+
"error": "Not found",
|
|
330
|
+
"message": f"Task {task_id} not found"
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
# Validate status transition
|
|
334
|
+
if current_task.status == "completed":
|
|
335
|
+
raise SecurityError("Cannot start completed task. Task already finished.")
|
|
336
|
+
|
|
337
|
+
if current_task.status == "in_progress":
|
|
338
|
+
raise SecurityError("Task already in progress")
|
|
339
|
+
|
|
340
|
+
# Only pending and stopped tasks can be started
|
|
341
|
+
result = task_store.update_task(
|
|
342
|
+
task_id,
|
|
343
|
+
status="in_progress",
|
|
344
|
+
start_at=datetime.now(timezone.utc).isoformat(),
|
|
345
|
+
finish_at=None
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
except SecurityError as e:
|
|
351
|
+
return {
|
|
352
|
+
"success": False,
|
|
353
|
+
"error": "Security validation failed",
|
|
354
|
+
"message": str(e)
|
|
355
|
+
}
|
|
356
|
+
except Exception as e:
|
|
357
|
+
return {
|
|
358
|
+
"success": False,
|
|
359
|
+
"error": "Failed to start task",
|
|
360
|
+
"message": str(e)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
def task_stop(task_id: int) -> dict[str, Any]:
|
|
365
|
+
"""
|
|
366
|
+
Stop task (set status to stopped).
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
task_id: Task ID to stop
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
373
|
+
return {
|
|
374
|
+
"success": False,
|
|
375
|
+
"error": "Invalid parameter",
|
|
376
|
+
"message": "task_id must be a positive integer"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
result = task_store.update_task(task_id, status="stopped")
|
|
380
|
+
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
except SecurityError as e:
|
|
384
|
+
return {
|
|
385
|
+
"success": False,
|
|
386
|
+
"error": "Security validation failed",
|
|
387
|
+
"message": str(e)
|
|
388
|
+
}
|
|
389
|
+
except Exception as e:
|
|
390
|
+
return {
|
|
391
|
+
"success": False,
|
|
392
|
+
"error": "Failed to stop task",
|
|
393
|
+
"message": str(e)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
@mcp.tool()
|
|
397
|
+
def task_finish(task_id: int) -> dict[str, Any]:
|
|
398
|
+
"""
|
|
399
|
+
Finish task (set status to completed, record finish time).
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
task_id: Task ID to finish
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
406
|
+
return {
|
|
407
|
+
"success": False,
|
|
408
|
+
"error": "Invalid parameter",
|
|
409
|
+
"message": "task_id must be a positive integer"
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
result = task_store.update_task(
|
|
413
|
+
task_id,
|
|
414
|
+
status="completed",
|
|
415
|
+
finish_at=datetime.now(timezone.utc).isoformat()
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
except SecurityError as e:
|
|
421
|
+
return {
|
|
422
|
+
"success": False,
|
|
423
|
+
"error": "Security validation failed",
|
|
424
|
+
"message": str(e)
|
|
425
|
+
}
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return {
|
|
428
|
+
"success": False,
|
|
429
|
+
"error": "Failed to finish task",
|
|
430
|
+
"message": str(e)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
@mcp.tool()
|
|
434
|
+
def task_resume(task_id: int) -> dict[str, Any]:
|
|
435
|
+
"""
|
|
436
|
+
Resume stopped task (set status back to in_progress).
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
task_id: Task ID to resume
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
443
|
+
return {
|
|
444
|
+
"success": False,
|
|
445
|
+
"error": "Invalid parameter",
|
|
446
|
+
"message": "task_id must be a positive integer"
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
result = task_store.update_task(task_id, status="in_progress", finish_at=None)
|
|
450
|
+
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
except SecurityError as e:
|
|
454
|
+
return {
|
|
455
|
+
"success": False,
|
|
456
|
+
"error": "Security validation failed",
|
|
457
|
+
"message": str(e)
|
|
458
|
+
}
|
|
459
|
+
except Exception as e:
|
|
460
|
+
return {
|
|
461
|
+
"success": False,
|
|
462
|
+
"error": "Failed to resume task",
|
|
463
|
+
"message": str(e)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@mcp.tool()
|
|
467
|
+
def task_list(
|
|
468
|
+
query: str = None,
|
|
469
|
+
limit: int = 10,
|
|
470
|
+
offset: int = 0,
|
|
471
|
+
status: str = None,
|
|
472
|
+
parent_id: int = None,
|
|
473
|
+
tags: list[str] = None
|
|
474
|
+
) -> dict[str, Any]:
|
|
475
|
+
"""
|
|
476
|
+
List tasks with optional filters and vector semantic search.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
query: Optional semantic search query for title/content
|
|
480
|
+
limit: Max results (1-50, default 10)
|
|
481
|
+
offset: Starting position for pagination (default 0)
|
|
482
|
+
status: Optional status filter (pending, in_progress, completed, stopped)
|
|
483
|
+
parent_id: Optional parent task ID filter (for subtasks)
|
|
484
|
+
tags: Optional list of tags to filter by (matches tasks containing ANY of the specified tags)
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
# Validate parameters
|
|
488
|
+
limit, offset, status, parent_id, validated_tags = validate_task_list_params(
|
|
489
|
+
limit=limit,
|
|
490
|
+
offset=offset,
|
|
491
|
+
status=status,
|
|
492
|
+
parent_id=parent_id,
|
|
493
|
+
tags=tags
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Search tasks
|
|
497
|
+
tasks, total = task_store.search_tasks(
|
|
498
|
+
query=query,
|
|
499
|
+
limit=limit,
|
|
500
|
+
offset=offset,
|
|
501
|
+
status=status,
|
|
502
|
+
parent_id=parent_id,
|
|
503
|
+
tags=validated_tags
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if not tasks:
|
|
507
|
+
return {
|
|
508
|
+
"success": True,
|
|
509
|
+
"tasks": [],
|
|
510
|
+
"total": total,
|
|
511
|
+
"count": 0,
|
|
512
|
+
"message": "No tasks found matching filters"
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Convert Task objects to dictionaries
|
|
516
|
+
task_dicts = [task.to_dict() for task in tasks]
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
"success": True,
|
|
520
|
+
"query": query,
|
|
521
|
+
"tasks": task_dicts,
|
|
522
|
+
"total": total,
|
|
523
|
+
"count": len(task_dicts),
|
|
524
|
+
"message": f"Retrieved {len(task_dicts)} of {total} tasks"
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
except SecurityError as e:
|
|
528
|
+
return {
|
|
529
|
+
"success": False,
|
|
530
|
+
"error": "Security validation failed",
|
|
531
|
+
"message": str(e)
|
|
532
|
+
}
|
|
533
|
+
except Exception as e:
|
|
534
|
+
return {
|
|
535
|
+
"success": False,
|
|
536
|
+
"error": "Task list failed",
|
|
537
|
+
"message": str(e)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
@mcp.tool()
|
|
541
|
+
def task_next() -> dict[str, Any]:
|
|
542
|
+
"""
|
|
543
|
+
Get next task to work on (smart selection).
|
|
544
|
+
|
|
545
|
+
Returns in_progress task if any exists, otherwise returns
|
|
546
|
+
next pending task after last completed task.
|
|
547
|
+
"""
|
|
548
|
+
try:
|
|
549
|
+
task = task_store.get_next_task()
|
|
550
|
+
|
|
551
|
+
if task is None:
|
|
552
|
+
return {
|
|
553
|
+
"success": False,
|
|
554
|
+
"error": "Not found",
|
|
555
|
+
"message": "No pending or in-progress tasks found"
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"success": True,
|
|
560
|
+
"task": task.to_dict(),
|
|
561
|
+
"message": f"Next task: {task.status}"
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
except Exception as e:
|
|
565
|
+
return {
|
|
566
|
+
"success": False,
|
|
567
|
+
"error": "Failed to get next task",
|
|
568
|
+
"message": str(e)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
@mcp.tool()
|
|
572
|
+
def task_get(task_id: int) -> dict[str, Any]:
|
|
573
|
+
"""
|
|
574
|
+
Get task by ID.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
task_id: Task ID to retrieve
|
|
578
|
+
"""
|
|
579
|
+
try:
|
|
580
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
581
|
+
return {
|
|
582
|
+
"success": False,
|
|
583
|
+
"error": "Invalid parameter",
|
|
584
|
+
"message": "task_id must be a positive integer"
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
task = task_store.get_task_by_id(task_id)
|
|
588
|
+
|
|
589
|
+
if task is None:
|
|
590
|
+
return {
|
|
591
|
+
"success": False,
|
|
592
|
+
"error": "Not found",
|
|
593
|
+
"message": f"Task with ID {task_id} not found"
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
"success": True,
|
|
598
|
+
"task": task.to_dict(),
|
|
599
|
+
"message": "Task retrieved successfully"
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
except Exception as e:
|
|
603
|
+
return {
|
|
604
|
+
"success": False,
|
|
605
|
+
"error": "Retrieval failed",
|
|
606
|
+
"message": str(e)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
@mcp.tool()
|
|
610
|
+
def task_stats() -> dict[str, Any]:
|
|
611
|
+
"""
|
|
612
|
+
Get task statistics (total, completed, pending, in_progress, stopped, next_task_id, etc.).
|
|
613
|
+
|
|
614
|
+
Returns comprehensive task statistics including:
|
|
615
|
+
- Total tasks count
|
|
616
|
+
- Count by status (pending, in_progress, completed, stopped)
|
|
617
|
+
- Tasks with subtasks count
|
|
618
|
+
- Next task ID (from smart selection logic)
|
|
619
|
+
"""
|
|
620
|
+
try:
|
|
621
|
+
# Get stats from TaskStore
|
|
622
|
+
stats = task_store.get_stats()
|
|
623
|
+
|
|
624
|
+
# Get next task ID
|
|
625
|
+
next_task = task_store.get_next_task()
|
|
626
|
+
next_task_id = next_task.id if next_task else None
|
|
627
|
+
|
|
628
|
+
# Build response with stats
|
|
629
|
+
result = stats.to_dict()
|
|
630
|
+
result["success"] = True
|
|
631
|
+
result["next_task_id"] = next_task_id
|
|
632
|
+
result["message"] = f"Statistics for {result['total_tasks']} tasks"
|
|
633
|
+
|
|
634
|
+
return result
|
|
635
|
+
|
|
636
|
+
except Exception as e:
|
|
637
|
+
return {
|
|
638
|
+
"success": False,
|
|
639
|
+
"error": "Failed to get statistics",
|
|
640
|
+
"message": str(e)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
@mcp.tool()
|
|
644
|
+
def task_comment(task_id: int, comment: str, append: bool = True) -> dict[str, Any]:
|
|
645
|
+
"""
|
|
646
|
+
Add or replace task comment.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
task_id: Task ID to update
|
|
650
|
+
comment: Comment text to add or set
|
|
651
|
+
append: If True, append to existing comment with \\n\\n separator. If False, replace entirely.
|
|
652
|
+
"""
|
|
653
|
+
try:
|
|
654
|
+
# Parameter validation
|
|
655
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
656
|
+
return {
|
|
657
|
+
"success": False,
|
|
658
|
+
"error": "Invalid parameter",
|
|
659
|
+
"message": "task_id must be a positive integer"
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if not isinstance(comment, str) or not comment.strip():
|
|
663
|
+
return {
|
|
664
|
+
"success": False,
|
|
665
|
+
"error": "Invalid parameter",
|
|
666
|
+
"message": "comment cannot be empty"
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
# Fetch existing task
|
|
670
|
+
existing_task = task_store.get_task_by_id(task_id)
|
|
671
|
+
|
|
672
|
+
if existing_task is None:
|
|
673
|
+
return {
|
|
674
|
+
"success": False,
|
|
675
|
+
"error": "Not found",
|
|
676
|
+
"message": f"Task with ID {task_id} not found"
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
# Build new comment
|
|
680
|
+
if append and existing_task.comment:
|
|
681
|
+
new_comment = existing_task.comment + "\n\n" + comment
|
|
682
|
+
else:
|
|
683
|
+
new_comment = comment
|
|
684
|
+
|
|
685
|
+
# Update task
|
|
686
|
+
result = task_store.update_task(task_id, comment=new_comment)
|
|
687
|
+
return result
|
|
688
|
+
|
|
689
|
+
except SecurityError as e:
|
|
690
|
+
return {
|
|
691
|
+
"success": False,
|
|
692
|
+
"error": "Security validation failed",
|
|
693
|
+
"message": str(e)
|
|
694
|
+
}
|
|
695
|
+
except Exception as e:
|
|
696
|
+
return {
|
|
697
|
+
"success": False,
|
|
698
|
+
"error": "Comment update failed",
|
|
699
|
+
"message": str(e)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
@mcp.tool()
|
|
703
|
+
def task_add_tag(task_id: int, tag: str) -> dict[str, Any]:
|
|
704
|
+
"""
|
|
705
|
+
Add a single tag to a task (appends to existing tags).
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
task_id: Task ID to update
|
|
709
|
+
tag: Tag to add (will be sanitized and lowercased)
|
|
710
|
+
"""
|
|
711
|
+
try:
|
|
712
|
+
# Validate task_id
|
|
713
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
714
|
+
return {
|
|
715
|
+
"success": False,
|
|
716
|
+
"error": "Invalid parameter",
|
|
717
|
+
"message": "task_id must be a positive integer"
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
# Validate tag
|
|
721
|
+
if not isinstance(tag, str) or not tag.strip():
|
|
722
|
+
return {
|
|
723
|
+
"success": False,
|
|
724
|
+
"error": "Invalid parameter",
|
|
725
|
+
"message": "tag cannot be empty"
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Get existing task
|
|
729
|
+
task = task_store.get_task_by_id(task_id)
|
|
730
|
+
|
|
731
|
+
if task is None:
|
|
732
|
+
return {
|
|
733
|
+
"success": False,
|
|
734
|
+
"error": "Not found",
|
|
735
|
+
"message": f"Task with ID {task_id} not found"
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
# Get current tags and add new tag
|
|
739
|
+
current_tags = task.tags if task.tags else []
|
|
740
|
+
|
|
741
|
+
# Sanitize and normalize new tag
|
|
742
|
+
from src.security import validate_tags
|
|
743
|
+
validated_tags = validate_tags([tag])
|
|
744
|
+
|
|
745
|
+
if not validated_tags:
|
|
746
|
+
return {
|
|
747
|
+
"success": False,
|
|
748
|
+
"error": "Validation failed",
|
|
749
|
+
"message": "Tag validation failed (must be alphanumeric + hyphens/underscores)"
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
new_tag = validated_tags[0]
|
|
753
|
+
|
|
754
|
+
# Check if tag already exists
|
|
755
|
+
if new_tag in current_tags:
|
|
756
|
+
return {
|
|
757
|
+
"success": False,
|
|
758
|
+
"error": "Already exists",
|
|
759
|
+
"message": f"Tag '{new_tag}' already exists on task {task_id}"
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
# Check max tags limit
|
|
763
|
+
if len(current_tags) >= 10:
|
|
764
|
+
return {
|
|
765
|
+
"success": False,
|
|
766
|
+
"error": "Limit exceeded",
|
|
767
|
+
"message": "Task already has maximum of 10 tags"
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
# Add tag
|
|
771
|
+
updated_tags = current_tags + [new_tag]
|
|
772
|
+
result = task_store.update_task(task_id, tags=updated_tags)
|
|
773
|
+
|
|
774
|
+
return result
|
|
775
|
+
|
|
776
|
+
except SecurityError as e:
|
|
777
|
+
return {
|
|
778
|
+
"success": False,
|
|
779
|
+
"error": "Security validation failed",
|
|
780
|
+
"message": str(e)
|
|
781
|
+
}
|
|
782
|
+
except Exception as e:
|
|
783
|
+
return {
|
|
784
|
+
"success": False,
|
|
785
|
+
"error": "Failed to add tag",
|
|
786
|
+
"message": str(e)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
@mcp.tool()
|
|
790
|
+
def task_remove_tag(task_id: int, tag: str) -> dict[str, Any]:
|
|
791
|
+
"""
|
|
792
|
+
Remove a single tag from a task.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
task_id: Task ID to update
|
|
796
|
+
tag: Tag to remove (case-insensitive match)
|
|
797
|
+
"""
|
|
798
|
+
try:
|
|
799
|
+
# Validate task_id
|
|
800
|
+
if not isinstance(task_id, int) or task_id < 1:
|
|
801
|
+
return {
|
|
802
|
+
"success": False,
|
|
803
|
+
"error": "Invalid parameter",
|
|
804
|
+
"message": "task_id must be a positive integer"
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
# Validate tag
|
|
808
|
+
if not isinstance(tag, str) or not tag.strip():
|
|
809
|
+
return {
|
|
810
|
+
"success": False,
|
|
811
|
+
"error": "Invalid parameter",
|
|
812
|
+
"message": "tag cannot be empty"
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
# Get existing task
|
|
816
|
+
task = task_store.get_task_by_id(task_id)
|
|
817
|
+
|
|
818
|
+
if task is None:
|
|
819
|
+
return {
|
|
820
|
+
"success": False,
|
|
821
|
+
"error": "Not found",
|
|
822
|
+
"message": f"Task with ID {task_id} not found"
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
# Get current tags
|
|
826
|
+
current_tags = task.tags if task.tags else []
|
|
827
|
+
|
|
828
|
+
# Normalize tag for comparison (lowercase)
|
|
829
|
+
tag_normalized = tag.lower().strip()
|
|
830
|
+
|
|
831
|
+
# Check if tag exists
|
|
832
|
+
if tag_normalized not in current_tags:
|
|
833
|
+
return {
|
|
834
|
+
"success": False,
|
|
835
|
+
"error": "Not found",
|
|
836
|
+
"message": f"Tag '{tag_normalized}' not found on task {task_id}"
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
# Remove tag
|
|
840
|
+
updated_tags = [t for t in current_tags if t != tag_normalized]
|
|
841
|
+
result = task_store.update_task(task_id, tags=updated_tags)
|
|
842
|
+
|
|
843
|
+
return result
|
|
844
|
+
|
|
845
|
+
except SecurityError as e:
|
|
846
|
+
return {
|
|
847
|
+
"success": False,
|
|
848
|
+
"error": "Security validation failed",
|
|
849
|
+
"message": str(e)
|
|
850
|
+
}
|
|
851
|
+
except Exception as e:
|
|
852
|
+
return {
|
|
853
|
+
"success": False,
|
|
854
|
+
"error": "Failed to remove tag",
|
|
855
|
+
"message": str(e)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
@mcp.tool()
|
|
859
|
+
def task_get_all_tags() -> dict[str, Any]:
|
|
860
|
+
"""
|
|
861
|
+
Get all unique tags across all tasks.
|
|
862
|
+
|
|
863
|
+
Returns sorted list of unique tags from the task database.
|
|
864
|
+
"""
|
|
865
|
+
try:
|
|
866
|
+
tags = task_store.get_all_tags()
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
"success": True,
|
|
870
|
+
"tags": tags,
|
|
871
|
+
"count": len(tags),
|
|
872
|
+
"message": f"Retrieved {len(tags)} unique tags"
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
except Exception as e:
|
|
876
|
+
return {
|
|
877
|
+
"success": False,
|
|
878
|
+
"error": "Failed to retrieve tags",
|
|
879
|
+
"message": str(e)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return mcp
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def main():
|
|
886
|
+
"""Main entry point"""
|
|
887
|
+
print(f"Starting {Config.SERVER_NAME} v{Config.SERVER_VERSION}", file=sys.stderr)
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
# Get working directory and config
|
|
891
|
+
working_dir = get_working_dir()
|
|
892
|
+
memory_dir = working_dir / "memory"
|
|
893
|
+
task_db_path = memory_dir / "tasks.db"
|
|
894
|
+
|
|
895
|
+
print(f"Working directory: {working_dir}", file=sys.stderr)
|
|
896
|
+
print(f"Task database: {task_db_path}", file=sys.stderr)
|
|
897
|
+
print(f"Embedding model: {Config.EMBEDDING_MODEL}", file=sys.stderr)
|
|
898
|
+
print("=" * 50, file=sys.stderr)
|
|
899
|
+
|
|
900
|
+
# Create and run server
|
|
901
|
+
server = create_server()
|
|
902
|
+
print("Server ready for connections...", file=sys.stderr)
|
|
903
|
+
server.run()
|
|
904
|
+
|
|
905
|
+
except KeyboardInterrupt:
|
|
906
|
+
print("\nServer stopped by user", file=sys.stderr)
|
|
907
|
+
except Exception as e:
|
|
908
|
+
print(f"Server failed to start: {e}", file=sys.stderr)
|
|
909
|
+
sys.exit(1)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
if __name__ == "__main__":
|
|
913
|
+
main()
|