Vikunja tooling for agent harnesses

Standard-library Python only, so any device can run it without a virtualenv:

  vk               CLI  -- for harnesses that can shell out
  vikunja_mcp.py   MCP server (stdio) -- for harnesses that speak MCP
  vkclient.py      shared client
  vk-watch@.service  systemd template for the per-device task watcher

AGENT-ONBOARDING.md is a prompt you can hand to an agent so it configures its
own access and verifies it.
This commit is contained in:
oc
2026-08-12 13:09:22 -07:00
commit 2a73f2ef5e
7 changed files with 1382 additions and 0 deletions
Executable
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
Vikunja MCP server -- stdio transport, JSON-RPC 2.0, standard library only.
Deliberately dependency-free: this gets registered in several different agent
harnesses, and requiring each of them to provision a virtualenv for one SDK is
how a "works everywhere" integration stops working anywhere.
Register it (Claude Code):
claude mcp add vikunja -- /srv/pods/vikunja-agents/vikunja_mcp.py
Auth comes from the same place the CLI gets it: VIKUNJA_TOKEN / VIKUNJA_PROFILE
in the server's environment, or ~/.config/vikunja-agents/config.json.
Tool descriptions below state *when* to call each tool, not just what it does --
that trigger condition is what actually drives correct tool selection.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from vkclient import Vikunja, VikunjaError # noqa: E402
PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"}
FALLBACK_PROTOCOL = "2024-11-05"
SERVER_INFO = {"name": "vikunja", "version": "1.0.0"}
TOOLS = [
{
"name": "vikunja_list_tasks",
"description": (
"List tasks from Vikunja, newest-relevant first. Call this at the start of a "
"work session to find out what has been assigned to you, and whenever you need "
"to check whether something is already tracked before creating a duplicate. "
"Defaults to undone tasks assigned to the calling bot account."
),
"inputSchema": {
"type": "object",
"properties": {
"assigned_to_me": {
"type": "boolean",
"description": "Only tasks assigned to this bot account. Defaults to true.",
},
"project_id": {"type": "integer", "description": "Restrict to one project."},
"include_done": {
"type": "boolean",
"description": "Include completed tasks. Defaults to false.",
},
"filter": {
"type": "string",
"description": "Raw Vikunja filter expression, e.g. \"priority >= 3\". ANDed with the other options.",
},
"limit": {"type": "integer", "description": "Maximum tasks to return."},
},
},
},
{
"name": "vikunja_get_task",
"description": (
"Fetch one task with its full description and comment thread. Call this before "
"starting work on a task -- the comment thread carries the conversation and "
"prior agents' findings, which the task list does not show."
),
"inputSchema": {
"type": "object",
"properties": {"task_id": {"type": "integer"}},
"required": ["task_id"],
},
},
{
"name": "vikunja_comment_task",
"description": (
"Post a comment on a task. This is the primary way to report progress, findings, "
"and blockers so other agents and the human can follow along. Call it when you "
"finish a unit of work, when you discover something that changes the task's "
"scope, and when you are blocked -- not for routine narration."
),
"inputSchema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"comment": {"type": "string", "description": "Markdown is rendered."},
},
"required": ["task_id", "comment"],
},
},
{
"name": "vikunja_create_task",
"description": (
"Create a task. Call this when work is identified that will not be done in the "
"current session -- follow-up items, discovered bugs, work for another agent. "
"Check vikunja_list_tasks first so you do not file a duplicate."
),
"inputSchema": {
"type": "object",
"properties": {
"project_id": {"type": "integer"},
"title": {"type": "string"},
"description": {"type": "string", "description": "Markdown is rendered."},
"priority": {
"type": "integer",
"description": "0 unset, 1 low, 2 medium, 3 high, 4 urgent, 5 DO NOW.",
},
"due_date": {"type": "string", "description": "RFC3339 timestamp."},
"assign_to": {"type": "string", "description": "Username to assign it to."},
},
"required": ["project_id", "title"],
},
},
{
"name": "vikunja_update_task",
"description": (
"Update a task's fields, including marking it done. Call this when the work is "
"actually finished and verified -- marking a task done is the signal other "
"agents and the human rely on. Fields you omit are left unchanged."
),
"inputSchema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"title": {"type": "string"},
"description": {"type": "string"},
"priority": {"type": "integer"},
"done": {"type": "boolean"},
},
"required": ["task_id"],
},
},
{
"name": "vikunja_assign_task",
"description": (
"Assign a task to a user or bot account. Call this to hand work to a specific "
"agent, or to claim a task before starting on it so two agents do not "
"duplicate each other."
),
"inputSchema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"username": {"type": "string"},
},
"required": ["task_id", "username"],
},
},
{
"name": "vikunja_list_projects",
"description": (
"List projects with their IDs. Call this when you need a project_id to file a "
"task and do not already know it."
),
"inputSchema": {"type": "object", "properties": {}},
},
]
def _slim(task):
"""Trim a task to what an agent needs. Full payloads are mostly nulls."""
return {
"id": task.get("id"),
"title": task.get("title"),
"done": task.get("done"),
"priority": task.get("priority"),
"due_date": task.get("due_date"),
"project_id": task.get("project_id"),
"assignees": [a.get("username") for a in (task.get("assignees") or [])],
"labels": [l.get("title") for l in (task.get("labels") or [])],
}
def call_tool(vk, name, args):
if name == "vikunja_list_tasks":
filters = []
if args.get("assigned_to_me", True):
filters.append(f"assignees in '{vk.whoami()}'")
if not args.get("include_done", False):
filters.append("done = false")
if args.get("filter"):
filters.append(args["filter"])
tasks = vk.tasks(
project_id=args.get("project_id"),
filter=" && ".join(filters) or None,
limit=args.get("limit"),
)
return [_slim(t) for t in tasks]
if name == "vikunja_get_task":
task = vk.task(args["task_id"])
detail = _slim(task)
detail["description"] = task.get("description")
detail["comments"] = [
{
"author": (c.get("author") or {}).get("username"),
"created": c.get("created"),
"comment": c.get("comment"),
}
for c in vk.comments(args["task_id"])
]
return detail
if name == "vikunja_comment_task":
vk.comment(args["task_id"], args["comment"])
return {"ok": True, "task_id": args["task_id"]}
if name == "vikunja_create_task":
task = vk.create_task(
args["project_id"], args["title"], args.get("description"),
args.get("priority"), args.get("due_date"),
)
if args.get("assign_to"):
user = vk.find_user(args["assign_to"], args["project_id"])
if not user:
raise VikunjaError(
f"No user '{args['assign_to']}' with access to project {args['project_id']}")
vk.assign(task["id"], user["id"])
task = vk.task(task["id"])
return _slim(task)
if name == "vikunja_update_task":
fields = {k: args[k] for k in ("title", "description", "priority", "done") if k in args}
if not fields:
raise VikunjaError("Nothing to update -- supply at least one field besides task_id.")
return _slim(vk.update_task(args["task_id"], **fields))
if name == "vikunja_assign_task":
task = vk.task(args["task_id"])
user = vk.find_user(args["username"], task["project_id"])
if not user:
raise VikunjaError(
f"No user '{args['username']}' with access to project {task['project_id']}")
vk.assign(args["task_id"], user["id"])
return _slim(vk.task(args["task_id"]))
if name == "vikunja_list_projects":
return [{"id": p["id"], "title": p.get("title")} for p in vk.projects()]
raise VikunjaError(f"Unknown tool: {name}")
def handle(message, state):
"""Return a JSON-RPC response dict, or None for notifications."""
method = message.get("method")
msg_id = message.get("id")
params = message.get("params") or {}
is_notification = msg_id is None
def ok(result):
return None if is_notification else {"jsonrpc": "2.0", "id": msg_id, "result": result}
def err(code, msg):
return None if is_notification else {
"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": msg}
}
if method == "initialize":
asked = params.get("protocolVersion")
return ok({
# Echo the client's version when we recognise it; otherwise name the
# one we know it can speak.
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else FALLBACK_PROTOCOL,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
})
if method in ("notifications/initialized", "initialized", "notifications/cancelled"):
return None
if method == "ping":
return ok({})
if method == "tools/list":
return ok({"tools": TOOLS})
if method == "tools/call":
name = params.get("name")
args = params.get("arguments") or {}
try:
result = call_tool(state["vk"], name, args)
except VikunjaError as e:
# Tool failures are results with isError, not protocol errors -- the
# model needs to see the message so it can adapt.
return ok({"content": [{"type": "text", "text": f"Error: {e}"}], "isError": True})
except KeyError as e:
return ok({
"content": [{"type": "text", "text": f"Error: missing required argument {e}"}],
"isError": True,
})
return ok({"content": [{"type": "text", "text": json.dumps(result, indent=2, default=str)}]})
if method in ("shutdown", "exit"):
state["running"] = False
return ok({})
return err(-32601, f"Method not found: {method}")
def main():
state = {"vk": Vikunja(), "running": True}
stdout = sys.stdout
for line in sys.stdin:
if not state["running"]:
break
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except ValueError:
stdout.write(json.dumps({
"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "Parse error"},
}) + "\n")
stdout.flush()
continue
try:
response = handle(message, state)
except Exception as e: # never let one bad call kill the server
response = {
"jsonrpc": "2.0", "id": message.get("id"),
"error": {"code": -32603, "message": f"Internal error: {e}"},
}
if message.get("id") is None:
response = None
if response is not None:
stdout.write(json.dumps(response) + "\n")
stdout.flush()
if __name__ == "__main__":
main()