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.
- vikunja_python/__init__.py +0 -0
- vikunja_python/cli/__init__.py +0 -0
- vikunja_python/cli/main.py +264 -0
- vikunja_python/core/__init__.py +0 -0
- vikunja_python/core/client.py +106 -0
- vikunja_python/core/models/__init__.py +377 -0
- vikunja_python/core/models/api_token.py +131 -0
- vikunja_python/core/models/auth.py +34 -0
- vikunja_python/core/models/base.py +193 -0
- vikunja_python/core/models/bulk_assignees.py +98 -0
- vikunja_python/core/models/filter.py +134 -0
- vikunja_python/core/models/label.py +230 -0
- vikunja_python/core/models/link_sharing.py +138 -0
- vikunja_python/core/models/migration.py +404 -0
- vikunja_python/core/models/phase6_medium.py +74 -0
- vikunja_python/core/models/project.py +217 -0
- vikunja_python/core/models/relation.py +199 -0
- vikunja_python/core/models/task.py +261 -0
- vikunja_python/core/models/task_expansion.py +252 -0
- vikunja_python/core/models/user.py +838 -0
- vikunja_python/core/models/webhook.py +270 -0
- vikunja_python/mcp/__init__.py +0 -0
- vikunja_python/mcp/server.py +678 -0
- vikunja_python-0.1.0.dist-info/METADATA +16 -0
- vikunja_python-0.1.0.dist-info/RECORD +28 -0
- vikunja_python-0.1.0.dist-info/WHEEL +5 -0
- vikunja_python-0.1.0.dist-info/entry_points.txt +3 -0
- vikunja_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|