vikunja-python 0.1.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.
@@ -0,0 +1,678 @@
1
+ import os
2
+ import logging
3
+ from typing import Optional, List, Annotated
4
+ from pydantic import Field
5
+ from mcp.server.fastmcp import FastMCP
6
+ from dotenv import load_dotenv
7
+ import dateparser
8
+
9
+ from vikunja_python.core.client import VikunjaClient, setup_logging
10
+ from vikunja_python.core.models.task import Task
11
+ from vikunja_python.core.models.project import Project
12
+ from vikunja_python.core.models.label import Label
13
+
14
+ # Load .env if present
15
+ load_dotenv()
16
+
17
+ # Setup logging correctly for MCP
18
+ setup_logging(is_mcp=True)
19
+ debug_mode = os.getenv("VIKUNJA_DEBUG", "").lower() in ("1", "true", "yes", "on")
20
+
21
+ # Initialize FastMCP server
22
+ # Flat naming for small models: create_task, list_projects, etc.
23
+ mcp = FastMCP("Vikunja", debug=debug_mode, log_level="DEBUG" if debug_mode else "INFO")
24
+
25
+ def get_client() -> VikunjaClient:
26
+ """Helper to initialize client from environment."""
27
+ base_url = os.getenv("VIKUNJA_URL")
28
+ # MCP prioritized for API Key usage
29
+ token = os.getenv("VIKUNJA_API_TOKEN")
30
+
31
+ if not base_url or not token:
32
+ logging.error("VIKUNJA_URL and VIKUNJA_API_TOKEN must be set.")
33
+ raise RuntimeError("VIKUNJA_URL and VIKUNJA_API_TOKEN must be set for MCP server.")
34
+
35
+ logging.debug(f"Initializing client for {base_url}")
36
+ return VikunjaClient(base_url, token)
37
+
38
+ @mcp.tool()
39
+ async def list_tasks(
40
+ project_id: Annotated[Optional[int], Field(description="Optional ID of the project to list tasks from")] = None,
41
+ include_descriptions: Annotated[bool, Field(description="Include full task descriptions. Set to False for bulk operations to save tokens.")] = True,
42
+ page: Annotated[int, Field(description="Page number for pagination (default: 1)")] = 1,
43
+ per_page: Annotated[int, Field(description="Number of tasks per page (default: 20)")] = 20,
44
+ filter: Annotated[Optional[str], Field(description="Vikunja filter string (e.g., 'done = false')")] = None,
45
+ expand: Annotated[Optional[List[str]], Field(description=(
46
+ "Fields to expand in the response. "
47
+ "Valid: subtasks, comments, reactions, buckets, comment_count, is_unread. "
48
+ "Note: attachments and reminders are often better fetched via get_task() or project view listing."
49
+ ))] = None,
50
+ sort_by: Annotated[Optional[List[str]], Field(description="List of fields to sort by (e.g., ['due_date', 'priority'])")] = None,
51
+ order_by: Annotated[Optional[str], Field(description="Sort order, 'asc' or 'desc' (default: 'asc')")] = None,
52
+ ) -> str:
53
+ """List tasks from Vikunja with pagination."""
54
+ async with get_client() as client:
55
+ params = {
56
+ "page": page,
57
+ "per_page": per_page,
58
+ }
59
+ if filter:
60
+ # Strip extra quotes if provided by the model/MCP (Fixes 400 Bad Request)
61
+ params["filter"] = filter.strip('"\'')
62
+ if expand:
63
+ # Filter out invalid expand fields for this endpoint to avoid 412 errors
64
+ valid_expands = {"subtasks", "buckets", "reactions", "comments", "comment_count", "is_unread"}
65
+ valid_requested = [e for e in expand if e in valid_expands]
66
+ if valid_requested:
67
+ params["expand"] = valid_requested
68
+ if sort_by:
69
+ params["sort_by"] = sort_by
70
+ if order_by:
71
+ params["order_by"] = order_by
72
+
73
+ # Negative project_id = saved filter (e.g. -2 = "Due in 3 Days")
74
+ if project_id is None:
75
+ path = "/tasks"
76
+ elif project_id < 0:
77
+ path = "/tasks"
78
+ params["project_id"] = project_id
79
+ else:
80
+ path = f"/projects/{project_id}/tasks"
81
+ data = await client.request("GET", path, params=params)
82
+
83
+ if isinstance(data, dict) and "error" in data:
84
+ return f"Error fetching tasks: {data['error']}"
85
+
86
+ if not data:
87
+ return "No tasks found."
88
+
89
+ tasks = [Task(**item) for item in data]
90
+ result = []
91
+
92
+ def format_task(t: Task, indent: int = 0) -> str:
93
+ prefix = " " * indent
94
+ status = "[DONE]" if t.done else "[TODO]"
95
+ due = f" (Due: {t.due_date.strftime('%Y-%m-%d %H:%M')})" if t.due_date else ""
96
+
97
+ labels_list = t.labels or []
98
+ labels_str = f" [Labels: {', '.join(l.title for l in labels_list)}]" if labels_list else ""
99
+
100
+ assignees_str = f" [Assignees: {', '.join(u.username for u in t.assignees)}]" if t.assignees else ""
101
+
102
+ priority = f" [P{t.priority}]" if t.priority > 0 else ""
103
+
104
+ # Expanded info
105
+ extra = []
106
+ if t.comment_count is not None: extra.append(f"{t.comment_count} comments")
107
+ if t.reactions:
108
+ extra.append(f"{len(t.reactions)} reactions")
109
+ if t.buckets:
110
+ extra.append(f"Buckets: {', '.join(b.get('title', 'Unknown') if isinstance(b, dict) else str(b) for b in t.buckets)}")
111
+
112
+ extra_str = f" ({', '.join(extra)})" if extra else ""
113
+
114
+ line = f"{prefix}ID: {t.id} {status} {t.title}{priority}{due}{labels_str}{assignees_str}{extra_str}"
115
+ if include_descriptions and t.description:
116
+ # Include full description with indentation
117
+ desc_indented = t.description.replace('\n', f'\n{prefix} ')
118
+ line += f"\n{prefix} Desc: {desc_indented}"
119
+
120
+ lines = [line]
121
+
122
+ subtasks_list = t.subtasks or []
123
+ if subtasks_list:
124
+ for st in subtasks_list:
125
+ lines.append(format_task(st, indent + 1))
126
+
127
+ # Links/Relations
128
+ related = t.related_tasks or {}
129
+ if related:
130
+ for rel_type, rel_tasks in related.items():
131
+ if rel_type == "subtask": continue # Already handled by expand=subtasks
132
+ if not rel_tasks: continue
133
+ for rt_data in rel_tasks:
134
+ # rt_data is likely a dict or ID depending on expansion
135
+ rt_id = rt_data.get("id") if isinstance(rt_data, dict) else rt_data
136
+ lines.append(f"{prefix} -> {rel_type.capitalize()}: {rt_id}")
137
+
138
+ return "\n".join(lines)
139
+
140
+ for t in tasks:
141
+ result.append(format_task(t))
142
+
143
+ return "\n".join(result)
144
+
145
+ @mcp.tool()
146
+ async def list_saved_filter(
147
+ filter_id: Annotated[int, Field(description="Daily briefing saved filter ID (negative)")],
148
+ include_descriptions: Annotated[bool, Field(description="Include descriptions")] = True,
149
+ page: Annotated[int, Field(description="Page number")] = 1,
150
+ per_page: Annotated[int, Field(description="Tasks per page")] = 50,
151
+ ) -> str:
152
+ """List tasks from a saved filter in Vikunja (e.g., filter_id=-2 for Due in 3 Days)."""
153
+ async with get_client() as client:
154
+ params = {"page": page, "per_page": per_page, "project_id": filter_id}
155
+ data = await client.request("GET", "/tasks", params=params)
156
+ if isinstance(data, dict) and "error" in data:
157
+ return f"Error fetching saved filter: {data['error']}"
158
+ if not data:
159
+ return "No tasks found."
160
+ tasks = [Task(**item) for item in data]
161
+ lines = []
162
+ for t in tasks:
163
+ status = "[DONE]" if t.done else "[TODO]"
164
+ due = f" (Due: {t.due_date.strftime('%Y-%m-%d %H:%M')})" if t.due_date else ""
165
+ labels = f" [Labels: {', '.join(l.title for l in t.labels)}]" if t.labels else ""
166
+ line = f"ID: {t.id} {status} {t.title}{due}{labels}"
167
+ if include_descriptions and t.description:
168
+ line += f"\n Desc: {t.description.replace(chr(10), chr(10)+' ')}"
169
+ lines.append(line)
170
+ return "\n".join(lines)
171
+
172
+ @mcp.tool()
173
+ async def task_summary(
174
+ top_n: Annotated[int, Field(description="How many top tasks to show per filter (default: 5)")] = 5,
175
+ ) -> str:
176
+ """Quick dashboard summary — task counts and top tasks for overdue, due today, and due soon.
177
+ Uses parallel queries for minimal latency. Optimized for ePaper dashboard polling."""
178
+ async with get_client() as client:
179
+ summary = await client.get_dashboard_summary()
180
+ lines = [f"## Vikunja Task Summary ({summary['total']} urgent)"]
181
+ for section in ["overdue", "due_today", "due_soon"]:
182
+ s = summary["filters"].get(section, {})
183
+ label_map = {"overdue": "🔴 Overdue", "due_today": "🟡 Due Today", "due_soon": "🟢 Due in 3 Days"}
184
+ label = label_map.get(section, section)
185
+ lines.append(f"\n### {label} ({s.get('count', 0)})")
186
+ for t in s.get("tasks", [])[:top_n]:
187
+ due = t.get("due_date", "")
188
+ if due and due.startswith("0001"):
189
+ due = ""
190
+ elif due and len(due) > 10:
191
+ due = due[:10]
192
+ priority = f" P{t['priority']}" if t.get("priority", 0) > 0 else ""
193
+ lines.append(f"- #{t['id']} {t['title']}{priority}"
194
+ f"{' (' + due + ')' if due else ''}")
195
+ if not s.get("tasks"):
196
+ lines.append(" _None_ 🎉")
197
+ return "\n".join(lines)
198
+
199
+
200
+ @mcp.tool()
201
+ async def get_project(project_id: Annotated[int, Field(description="The ID of the project to fetch")]) -> str:
202
+ """Get detailed information about a project, including its views."""
203
+ async with get_client() as client:
204
+ data = await client.request("GET", f"/projects/{project_id}")
205
+ if isinstance(data, dict) and "error" in data:
206
+ return f"Error fetching project: {data['error']}"
207
+
208
+ p = Project(**data)
209
+ lines = [f"ID: {p.id} - Title: {p.title}"]
210
+ if p.description: lines.append(f"Description: {p.description}")
211
+
212
+ if p.views:
213
+ lines.append("\nViews:")
214
+ for v in p.views:
215
+ # v is a ProjectView model object
216
+ lines.append(f" - {v.title} (ID: {v.id}, Kind: {v.view_kind})")
217
+
218
+ return "\n".join(lines)
219
+
220
+ @mcp.tool()
221
+ async def list_project_view_tasks(
222
+ project_id: Annotated[int, Field(description="ID of the project")],
223
+ view_id: Annotated[int, Field(description="ID of the view")],
224
+ include_descriptions: Annotated[bool, Field(description="Include full task descriptions. Set to False for bulk operations to save tokens.")] = True,
225
+ page: Annotated[int, Field(description="Page number (default: 1)")] = 1,
226
+ per_page: Annotated[int, Field(description="Tasks per page (default: 50)")] = 50,
227
+ expand: Annotated[Optional[List[str]], Field(description=(
228
+ "Fields to expand. Valid: subtasks, comments, reactions, buckets, comment_count, is_unread. "
229
+ "This endpoint often returns more metadata (like descriptions) by default."
230
+ ))] = None
231
+ ) -> str:
232
+ """List tasks for a specific project view."""
233
+ async with get_client() as client:
234
+ params = {"page": page, "per_page": per_page}
235
+ if expand:
236
+ params["expand"] = expand
237
+
238
+ data = await client.request("GET", f"/projects/{project_id}/views/{view_id}/tasks", params=params)
239
+
240
+ if isinstance(data, dict) and "error" in data:
241
+ return f"Error fetching view tasks: {data['error']}"
242
+
243
+ if not data:
244
+ return "No tasks found in this view."
245
+
246
+ all_tasks = []
247
+ if isinstance(data, list) and len(data) > 0:
248
+ first_item = data[0]
249
+ if "tasks" in first_item and "id" in first_item and "title" in first_item:
250
+ # It's a list of buckets (Kanban view)
251
+ for bucket in data:
252
+ b_tasks = bucket.get("tasks") or []
253
+ for t_item in b_tasks:
254
+ # Inject bucket title for context
255
+ t_item["_bucket_title"] = bucket.get("title")
256
+ all_tasks.append(Task(**t_item))
257
+ else:
258
+ # It's a list of tasks
259
+ all_tasks = [Task(**item) for item in data]
260
+
261
+ if not all_tasks:
262
+ return "No tasks found in this view."
263
+
264
+ result = []
265
+ def format_task_rich(t: Task) -> str:
266
+ status = "[DONE]" if t.done else "[TODO]"
267
+ due = f" (Due: {t.due_date.strftime('%Y-%m-%d %H:%M')})" if t.due_date else ""
268
+ labels = f" [Labels: {', '.join(l.title for l in t.labels)}]" if t.labels else ""
269
+ assignees = f" [Assignees: {', '.join(u.username for u in t.assignees)}]" if t.assignees else ""
270
+
271
+ # Check for injected bucket title in model_extra
272
+ bucket_title = t.model_extra.get("_bucket_title") if t.model_extra else None
273
+ bucket = f" [Bucket: {bucket_title}]" if bucket_title else ""
274
+
275
+ line = f"ID: {t.id} {status} {t.title}{due}{labels}{assignees}{bucket}"
276
+ if include_descriptions and t.description:
277
+ desc_indented = t.description.replace('\n', '\n ')
278
+ line += f"\n Desc: {desc_indented}"
279
+ return line
280
+
281
+ for t in all_tasks:
282
+ result.append(format_task_rich(t))
283
+
284
+ return "\n\n".join(result)
285
+
286
+ @mcp.tool()
287
+ async def get_task(
288
+ task_id: Annotated[int, Field(description="The ID of the task to fetch")],
289
+ expand: Annotated[Optional[List[str]], Field(description=(
290
+ "Fields to expand for full details. Valid: subtasks, comments, attachments, reminders, assignees, reactions, buckets. "
291
+ "Use this for a deep-dive into a single task."
292
+ ))] = None
293
+ ) -> str:
294
+ """Get the full details of a specific task, including its description, recurrence, and all metadata."""
295
+ async with get_client() as client:
296
+ # 1. Try with expansion
297
+ params = {}
298
+ if expand:
299
+ params["expand"] = expand
300
+
301
+ data = await client.request("GET", f"/tasks/{task_id}", params=params)
302
+
303
+ # 2. Fallback if 412 (Precondition Failed) - Some servers don't support certain expansions
304
+ if isinstance(data, dict) and "error" in data and "412" in str(data["error"]):
305
+ logging.warning(f"Task expansion failed with 412 for task {task_id}, falling back to manual fetch.")
306
+ data = await client.request("GET", f"/tasks/{task_id}")
307
+ if isinstance(data, dict) and "error" in data:
308
+ return f"Error fetching task: {data['error']}"
309
+
310
+ # If expansion was requested, try manual fetch for known sub-resources
311
+ if expand:
312
+ if "assignees" in expand:
313
+ assignees = await client.request("GET", f"/tasks/{task_id}/assignees")
314
+ if isinstance(assignees, list): data["assignees"] = assignees
315
+ if "attachments" in expand:
316
+ attachments = await client.request("GET", f"/tasks/{task_id}/attachments")
317
+ if isinstance(attachments, list): data["attachments"] = attachments
318
+ if "comments" in expand:
319
+ comments = await client.request("GET", f"/tasks/{task_id}/comments")
320
+ if isinstance(comments, list): data["comments"] = comments
321
+
322
+ if isinstance(data, dict) and "error" in data:
323
+ return f"Error fetching task: {data['error']}"
324
+
325
+ t = Task(**data)
326
+
327
+ lines = []
328
+ status = "[DONE]" if t.done else "[TODO]"
329
+ lines.append(f"ID: {t.id} {status} {t.title}")
330
+ lines.append(f"Project ID: {t.project_id}")
331
+ lines.append(f"Identifier: {t.identifier}")
332
+ if t.due_date: lines.append(f"Due: {t.due_date.isoformat()}")
333
+ if t.priority > 0: lines.append(f"Priority: {t.priority}")
334
+
335
+ if t.labels:
336
+ lines.append(f"Labels: {', '.join(l.title for l in t.labels)}")
337
+
338
+ if t.assignees:
339
+ lines.append(f"Assignees: {', '.join(u.username for u in t.assignees)}")
340
+
341
+ if t.reactions:
342
+ lines.append(f"Reactions: {len(t.reactions)} total")
343
+
344
+ # Recurrence
345
+ if t.repeat_after > 0:
346
+ lines.append(f"Recurrence: repeats after {t.repeat_after} seconds (Mode: {t.repeat_mode})")
347
+
348
+ if t.description:
349
+ lines.append("\n--- Description ---")
350
+ lines.append(t.description)
351
+ lines.append("-------------------")
352
+
353
+ # Attachments/Reminders summaries
354
+ if t.attachments:
355
+ lines.append(f"\nAttachments ({len(t.attachments)}):")
356
+ for att in t.attachments:
357
+ name = "Unknown"
358
+ if isinstance(att, dict):
359
+ name = att.get("file", {}).get("name") or att.get("name") or "Unnamed"
360
+ lines.append(f" - {name} (ID: {att.get('id') if isinstance(att, dict) else '?'})")
361
+
362
+ if t.reminders:
363
+ lines.append(f"\nReminders ({len(t.reminders)}):")
364
+ for rem in t.reminders:
365
+ if isinstance(rem, dict):
366
+ rem_time = rem.get("reminder") or f"Relative to {rem.get('relative_to')} ({rem.get('relative_period')}s)"
367
+ lines.append(f" - {rem_time}")
368
+
369
+ return "\n".join(lines)
370
+
371
+ @mcp.tool()
372
+ async def create_task(
373
+ title: Annotated[str, Field(description="Task title")],
374
+ project_id: Annotated[int, Field(description="ID of the project")],
375
+ description: Annotated[Optional[str], Field(description="Optional markdown description")] = None,
376
+ due_date: Annotated[Optional[str], Field(description="Natural language or ISO date string (e.g., 'tomorrow')")] = None,
377
+ priority: Annotated[Optional[int], Field(description="Integer 1-5 (5 is highest)")] = None,
378
+ labels: Annotated[Optional[List[int]], Field(description="List of label IDs to attach")] = None,
379
+ recurrence: Annotated[Optional[dict], Field(description='Optional dict with {"frequency": "daily|weekly|monthly|yearly", "interval": int}')] = None
380
+ ) -> str:
381
+ """Create a new task in a specific project."""
382
+ async with get_client() as client:
383
+ payload = {"title": title}
384
+ if description is not None: payload["description"] = description
385
+ if priority is not None: payload["priority"] = priority
386
+ if labels is not None: payload["label_ids"] = labels
387
+ if due_date is not None:
388
+ dt = dateparser.parse(due_date)
389
+ if dt:
390
+ payload["due_date"] = dt.isoformat()
391
+
392
+ if recurrence:
393
+ freq = recurrence.get("frequency", "").lower()
394
+ interval = recurrence.get("interval", 1)
395
+ # Rough mapping to seconds for Vikunja repeat_after
396
+ multiplier = 0
397
+ if freq == "daily": multiplier = 86400
398
+ elif freq == "weekly": multiplier = 604800
399
+ elif freq == "monthly": multiplier = 2592000 # 30 days
400
+ elif freq == "yearly": multiplier = 31536000 # 365 days
401
+
402
+ if multiplier > 0:
403
+ payload["repeat_after"] = multiplier * interval
404
+ payload["repeat_mode"] = 0 # 0/1 usually means repeat after due date
405
+
406
+ data = await client.request("PUT", f"/projects/{project_id}/tasks", json=payload)
407
+
408
+ if isinstance(data, dict) and "error" in data:
409
+ return f"Error creating task: {data['error']}"
410
+
411
+ task = Task(**data)
412
+ return f"Successfully created task '{task.title}' with ID {task.id} in project {project_id}."
413
+
414
+ @mcp.tool()
415
+ async def list_projects() -> str:
416
+ """List all available projects."""
417
+ async with get_client() as client:
418
+ data = await client.request("GET", "/projects")
419
+
420
+ if isinstance(data, dict) and "error" in data:
421
+ return f"Error fetching projects: {data['error']}"
422
+
423
+ projects = [Project(**item) for item in data]
424
+ result = []
425
+ for p in projects:
426
+ result.append(f"ID: {p.id} - Title: {p.title}")
427
+
428
+ return "\n".join(result) if result else "No projects found."
429
+
430
+ @mcp.tool()
431
+ async def list_labels() -> str:
432
+ """List all available labels."""
433
+ async with get_client() as client:
434
+ data = await client.request("GET", "/labels")
435
+
436
+ if isinstance(data, dict) and "error" in data:
437
+ return f"Error fetching labels: {data['error']}"
438
+
439
+ labels = [Label(**item) for item in data]
440
+ result = []
441
+ for l in labels:
442
+ result.append(f"ID: {l.id} - Title: {l.title} (Color: {l.hex_color})")
443
+
444
+ return "\n".join(result) if result else "No labels found."
445
+
446
+ @mcp.tool()
447
+ async def create_label(
448
+ title: Annotated[str, Field(description="The name of the label")],
449
+ hex_color: Annotated[Optional[str], Field(description="The color of the label in #RRGGBB format")] = None,
450
+ description: Annotated[Optional[str], Field(description="A description for the label")] = None
451
+ ) -> str:
452
+ """Create a new label."""
453
+ async with get_client() as client:
454
+ payload = {"title": title}
455
+ if hex_color:
456
+ payload["hex_color"] = hex_color
457
+ if description:
458
+ payload["description"] = description
459
+
460
+ data = await client.request("PUT", "/labels", json=payload)
461
+
462
+ if isinstance(data, dict) and "error" in data:
463
+ return f"Error creating label: {data['error']}"
464
+
465
+ label = Label(**data)
466
+ return f"Successfully created label '{label.title}' with ID {label.id}."
467
+
468
+ @mcp.tool()
469
+ async def update_task(
470
+ task_id: Annotated[int, Field(description="The ID of the task to update")],
471
+ title: Annotated[Optional[str], Field(description="New title for the task")] = None,
472
+ description: Annotated[Optional[str], Field(description="New markdown description for the task")] = None,
473
+ due_date: Annotated[Optional[str], Field(description="Natural language or ISO date string (e.g., 'tomorrow')")] = None,
474
+ priority: Annotated[Optional[int], Field(description="Integer 1-5 (5 is highest)")] = None,
475
+ labels: Annotated[Optional[List[int]], Field(description="List of label IDs to set (overwrites existing)")] = None,
476
+ recurrence: Annotated[Optional[dict], Field(description='Optional dict with {"frequency": "daily|weekly|monthly|yearly", "interval": int}')] = None
477
+ ) -> str:
478
+ """Update a task's fields. To mark as done/incomplete, use complete_task or mark_task_incomplete."""
479
+ async with get_client() as client:
480
+ payload = {}
481
+ if title is not None: payload["title"] = title
482
+ if description is not None: payload["description"] = description
483
+ if priority is not None: payload["priority"] = priority
484
+ if labels is not None: payload["label_ids"] = labels
485
+ if due_date is not None:
486
+ dt = dateparser.parse(due_date)
487
+ if dt:
488
+ payload["due_date"] = dt.isoformat()
489
+
490
+ if recurrence:
491
+ freq = recurrence.get("frequency", "").lower()
492
+ interval = recurrence.get("interval", 1)
493
+ # Rough mapping to seconds for Vikunja repeat_after
494
+ multiplier = 0
495
+ if freq == "daily": multiplier = 86400
496
+ elif freq == "weekly": multiplier = 604800
497
+ elif freq == "monthly": multiplier = 2592000 # 30 days
498
+ elif freq == "yearly": multiplier = 31536000 # 365 days
499
+
500
+ if multiplier > 0:
501
+ payload["repeat_after"] = multiplier * interval
502
+ payload["repeat_mode"] = 0 # 0/1 usually means repeat after due date
503
+
504
+ if not payload:
505
+ return "No changes provided for update_task."
506
+
507
+ data = await client.request("POST", f"/tasks/{task_id}", json=payload)
508
+ if isinstance(data, dict) and "error" in data:
509
+ return f"Error updating task: {data['error']}"
510
+ return f"Successfully updated task {task_id}."
511
+
512
+ @mcp.tool()
513
+ async def complete_task(task_id: Annotated[int, Field(description="The ID of the task to mark as completed")]) -> str:
514
+ """Mark a task as completed (done = true)."""
515
+ async with get_client() as client:
516
+ payload = {"done": True}
517
+ data = await client.request("POST", f"/tasks/{task_id}", json=payload)
518
+ if isinstance(data, dict) and "error" in data:
519
+ return f"Error completing task: {data['error']}"
520
+ return f"Task {task_id} marked as completed."
521
+
522
+ @mcp.tool()
523
+ async def mark_task_incomplete(task_id: Annotated[int, Field(description="The ID of the task to mark as incomplete")]) -> str:
524
+ """Mark a task as incomplete (done = false)."""
525
+ async with get_client() as client:
526
+ payload = {"done": False}
527
+ data = await client.request("POST", f"/tasks/{task_id}", json=payload)
528
+ if isinstance(data, dict) and "error" in data:
529
+ return f"Error marking task as incomplete: {data['error']}"
530
+ return f"Task {task_id} marked as incomplete."
531
+
532
+ @mcp.tool()
533
+ async def delete_task(task_id: Annotated[int, Field(description="The ID of the task to delete")]) -> str:
534
+ """Delete a task."""
535
+ async with get_client() as client:
536
+ data = await client.request("DELETE", f"/tasks/{task_id}")
537
+ if isinstance(data, dict) and "error" in data:
538
+ return f"Error deleting task: {data['error']}"
539
+ return f"Successfully deleted task {task_id}."
540
+
541
+ @mcp.tool()
542
+ async def add_subtask(
543
+ parent_task_id: Annotated[int, Field(description="The ID of the parent task")],
544
+ subtask_task_id: Annotated[int, Field(description="The ID of the task that will become a subtask")]
545
+ ) -> str:
546
+ """Explicitly make one task a subtask of another."""
547
+ async with get_client() as client:
548
+ payload = {"other_task_id": subtask_task_id, "relation_kind": "subtask"}
549
+ data = await client.request("PUT", f"/tasks/{parent_task_id}/relations", json=payload)
550
+ if isinstance(data, dict) and "error" in data:
551
+ return f"Error creating subtask: {data['error']}"
552
+ return f"Task {subtask_task_id} is now a subtask of {parent_task_id}."
553
+
554
+ @mcp.tool()
555
+ async def add_task_link(
556
+ task_id: Annotated[int, Field(description="The source task ID")],
557
+ other_task_id: Annotated[int, Field(description="The target task ID")],
558
+ link_type: Annotated[str, Field(description="Link type (related, duplicate, blocked, blocking, predecessor, successor)")] = "related"
559
+ ) -> str:
560
+ """Link two tasks together with a specific relationship type."""
561
+ async with get_client() as client:
562
+ payload = {"other_task_id": other_task_id, "relation_kind": link_type}
563
+ data = await client.request("PUT", f"/tasks/{task_id}/relations", json=payload)
564
+ if isinstance(data, dict) and "error" in data:
565
+ return f"Error creating relationship: {data['error']}"
566
+ return f"Successfully created {link_type} link between {task_id} and {other_task_id}."
567
+
568
+ @mcp.tool()
569
+ async def list_filters() -> str:
570
+ """List all saved filters."""
571
+ async with get_client() as client:
572
+ data = await client.request("GET", "/filters")
573
+ if isinstance(data, dict) and "error" in data:
574
+ return f"Error fetching filters: {data['error']}"
575
+
576
+ result = [f"ID: {f['id']} - Title: {f['title']}" for f in data]
577
+ return "\n".join(result) if result else "No filters found."
578
+
579
+ @mcp.tool()
580
+ async def search_tasks(query: Annotated[str, Field(description="Search string to match task titles")]) -> str:
581
+ """Search for tasks globally across all projects."""
582
+ async with get_client() as client:
583
+ # Strip extra quotes if provided (Consistency fix)
584
+ clean_query = query.strip('"\'')
585
+ data = await client.request("GET", "/tasks", params={"s": clean_query})
586
+
587
+ if isinstance(data, dict) and "error" in data:
588
+ return f"Error searching tasks: {data['error']}"
589
+
590
+ tasks = [Task(**item) for item in data]
591
+ result = []
592
+ for t in tasks:
593
+ status = "[DONE]" if t.done else "[TODO]"
594
+ result.append(f"ID: {t.id} {status} {t.title} (Project ID: {t.project_id})")
595
+
596
+ return "\n".join(result) if result else f"No tasks found matching '{query}'."
597
+
598
+ @mcp.tool()
599
+ async def add_task_comment(
600
+ task_id: Annotated[int, Field(description="The ID of the task to comment on")],
601
+ comment: Annotated[str, Field(description="The markdown comment text")]
602
+ ) -> str:
603
+ """Add a comment to a task."""
604
+ async with get_client() as client:
605
+ data = await client.request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
606
+ if isinstance(data, dict) and "error" in data:
607
+ return f"Error adding comment: {data['error']}"
608
+ return f"Successfully added comment to task {task_id}."
609
+
610
+ @mcp.tool()
611
+ async def list_task_comments(task_id: Annotated[int, Field(description="The ID of the task")]) -> str:
612
+ """List all comments on a task."""
613
+ async with get_client() as client:
614
+ data = await client.request("GET", f"/tasks/{task_id}/comments")
615
+ if isinstance(data, dict) and "error" in data:
616
+ return f"Error fetching comments: {data['error']}"
617
+
618
+ if not data:
619
+ return "No comments found on this task."
620
+
621
+ result = []
622
+ for c in data:
623
+ author = c.get("author", {}).get("username", "Unknown")
624
+ result.append(f"[{c['created']}] {author}: {c['comment']}")
625
+
626
+ return "\n".join(result)
627
+
628
+ @mcp.tool()
629
+ async def add_label_to_task(
630
+ task_id: Annotated[int, Field(description="The ID of the task")],
631
+ label_id: Annotated[int, Field(description="The ID of the label to add")]
632
+ ) -> str:
633
+ """Link an existing label to a task."""
634
+ async with get_client() as client:
635
+ data = await client.request("PUT", f"/tasks/{task_id}/labels", json={"label_id": label_id})
636
+ if isinstance(data, dict) and "error" in data:
637
+ return f"Error adding label: {data['error']}"
638
+ return f"Successfully added label {label_id} to task {task_id}."
639
+
640
+ @mcp.tool()
641
+ async def setup_new_project(
642
+ title: Annotated[str, Field(description="The title of the new project")],
643
+ tasks: Annotated[List[str], Field(description="List of task titles to create in the project")]
644
+ ) -> str:
645
+ """Create a new project and multiple tasks in one operation."""
646
+ async with get_client() as client:
647
+ # 1. Create Project
648
+ proj_data = await client.request("PUT", "/projects", json={"title": title})
649
+ if isinstance(proj_data, dict) and "error" in proj_data:
650
+ return f"Error creating project: {proj_data['error']}"
651
+
652
+ project = Project(**proj_data)
653
+ results = [f"Project '{project.title}' created with ID {project.id}."]
654
+
655
+ # 2. Create Tasks
656
+ for task_title in tasks:
657
+ t_data = await client.request("PUT", f"/projects/{project.id}/tasks", json={"title": task_title})
658
+ if isinstance(t_data, dict) and "error" in t_data:
659
+ results.append(f" - Error creating task '{task_title}': {t_data['error']}")
660
+ else:
661
+ results.append(f" - Task '{task_title}' created with ID {t_data['id']}.")
662
+
663
+ return "\n".join(results)
664
+
665
+ @mcp.tool()
666
+ async def parse_date(date_string: Annotated[str, Field(description="Natural language date (e.g., 'next Friday at 2pm')")]) -> str:
667
+ """Convert natural language dates into ISO 8601 format."""
668
+ dt = dateparser.parse(date_string)
669
+ if not dt:
670
+ return f"Could not parse date: '{date_string}'"
671
+ return dt.isoformat()
672
+
673
+ def main():
674
+ logging.info("Starting Vikunja MCP Server...")
675
+ mcp.run()
676
+
677
+ if __name__ == "__main__":
678
+ main()