mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
This squashes all local history (768 commits) onto the scrubbed PR #90 baseline. Individual commits were lost during filter-repo corruption; the working tree content is preserved intact. Build: N/A (working tree state only)
278 lines
9.7 KiB
Python
278 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MCP server for Vikunja kanban board on neon.
|
|
Exposes tools for agents/LLMs to manage projects, tasks, and lists.
|
|
|
|
Environment:
|
|
VIKUNJA_URL — default http://100.92.88.64:3456
|
|
VIKUNJA_TOKEN — API token from /api/v1/login
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
from urllib.request import Request, urlopen
|
|
from urllib.error import HTTPError
|
|
|
|
SERVER_NAME = "vikunja-mcp"
|
|
SERVER_VERSION = "0.1.0"
|
|
VIKUNJA_URL = os.environ.get("VIKUNJA_URL", "http://100.92.88.64:3456")
|
|
_token_file = os.environ.get("VIKUNJA_TOKEN_FILE", "")
|
|
VIKUNJA_TOKEN = os.environ.get("VIKUNJA_TOKEN", "")
|
|
if not VIKUNJA_TOKEN and _token_file:
|
|
try:
|
|
with open(_token_file) as f:
|
|
VIKUNJA_TOKEN = f.read().strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
_VIEW_ID = 13 # Kanban view ID for project 3 (Research Stack)
|
|
|
|
|
|
def _api(method: str, path: str, data: dict | None = None) -> Any:
|
|
url = f"{VIKUNJA_URL}/api/v1{path}"
|
|
headers = {"Content-Type": "application/json"}
|
|
if VIKUNJA_TOKEN:
|
|
headers["Authorization"] = f"Bearer {VIKUNJA_TOKEN}"
|
|
body = json.dumps(data).encode() if data else None
|
|
req = Request(url, data=body, headers=headers, method=method)
|
|
try:
|
|
with urlopen(req) as resp:
|
|
return json.loads(resp.read())
|
|
except HTTPError as e:
|
|
return {"error": f"{e.code}: {e.reason}", "body": e.read().decode(errors="replace")[:500]}
|
|
|
|
|
|
def json_text(data: Any) -> list[dict[str, str]]:
|
|
return [{"type": "text", "text": json.dumps(data, indent=2, sort_keys=True)}]
|
|
|
|
|
|
def _view_id(pid: int) -> int:
|
|
"""Return the kanban view ID for a project."""
|
|
if pid == 3:
|
|
return 13
|
|
views = _api("GET", f"/projects/{pid}/views")
|
|
if isinstance(views, list):
|
|
for v in views:
|
|
if v.get("view_kind") == "kanban":
|
|
return v["id"]
|
|
return 0
|
|
|
|
|
|
def tool_list_projects(_: dict[str, Any]) -> dict[str, Any]:
|
|
projects = _api("GET", "/projects")
|
|
return {"ok": True, "projects": projects}
|
|
|
|
|
|
def tool_list_lists(args: dict[str, Any]) -> dict[str, Any]:
|
|
pid = args.get("project_id", 3)
|
|
vid = _view_id(pid)
|
|
if not vid:
|
|
return {"ok": False, "error": "no kanban view found"}
|
|
buckets = _api("GET", f"/projects/{pid}/views/{vid}/buckets")
|
|
return {"ok": True, "lists": buckets}
|
|
|
|
|
|
def tool_list_tasks(args: dict[str, Any]) -> dict[str, Any]:
|
|
pid = args.get("project_id", 3)
|
|
lid = args.get("list_id", 0)
|
|
vid = _view_id(pid)
|
|
if not vid:
|
|
tasks = _api("GET", f"/projects/{pid}/tasks")
|
|
return {"ok": True, "tasks": tasks}
|
|
buckets = _api("GET", f"/projects/{pid}/views/{vid}/tasks")
|
|
if isinstance(buckets, list):
|
|
if lid:
|
|
for b in buckets:
|
|
if b.get("id") == lid:
|
|
return {"ok": True, "tasks": b.get("tasks", [])}
|
|
return {"ok": True, "tasks": []}
|
|
all_tasks = []
|
|
for b in buckets:
|
|
all_tasks.extend(b.get("tasks", []))
|
|
return {"ok": True, "tasks": all_tasks}
|
|
return {"ok": True, "tasks": []}
|
|
|
|
|
|
def tool_create_task(args: dict[str, Any]) -> dict[str, Any]:
|
|
pid = args.get("project_id", 3)
|
|
title = args.get("title", "")
|
|
desc = args.get("description", "")
|
|
body = {"title": title, "description": desc}
|
|
result = _api("PUT", f"/projects/{pid}/tasks", body)
|
|
task_id = result.get("id")
|
|
if task_id and "list_id" in args:
|
|
lid = args["list_id"]
|
|
_api("POST", f"/tasks/{task_id}", {"bucket_id": lid})
|
|
return {"ok": bool(task_id), "task": result,
|
|
"note": "Use web UI at {VIKUNJA_URL} to assign tasks to kanban buckets"}
|
|
|
|
|
|
def tool_move_task(args: dict[str, Any]) -> dict[str, Any]:
|
|
task_id = args.get("task_id", 0)
|
|
lid = args.get("list_id", 0)
|
|
_api("POST", f"/tasks/{task_id}", {"bucket_id": lid})
|
|
return {"ok": True,
|
|
"note": "Open the web UI to confirm and adjust bucket assignment: "
|
|
f"{VIKUNJA_URL}/projects/3?view=13"}
|
|
|
|
|
|
def tool_update_task(args: dict[str, Any]) -> dict[str, Any]:
|
|
task_id = args.get("task_id", 0)
|
|
body = {k: v for k, v in args.items() if k in ("title", "description", "done", "priority") and v is not None}
|
|
result = _api("POST", f"/tasks/{task_id}", body)
|
|
return {"ok": True, "task": result}
|
|
|
|
|
|
def tool_status(_: dict[str, Any]) -> dict[str, Any]:
|
|
try:
|
|
info = _api("GET", "/info")
|
|
ok = "version" in info
|
|
except Exception as exc:
|
|
info = {"error": str(exc)}
|
|
ok = False
|
|
return {"ok": ok, "server": SERVER_NAME, "version": SERVER_VERSION,
|
|
"web_ui": f"{VIKUNJA_URL}/projects/3?view=13",
|
|
**info}
|
|
|
|
|
|
TOOLS = {
|
|
"kanban_list_projects": {
|
|
"description": "List all kanban projects/boards.",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
"handler": tool_list_projects,
|
|
},
|
|
"kanban_list_lists": {
|
|
"description": "List buckets/columns in a project's kanban view.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"project_id": {"type": "integer", "default": 3},
|
|
},
|
|
},
|
|
"handler": tool_list_lists,
|
|
},
|
|
"kanban_list_tasks": {
|
|
"description": "List all tasks, or tasks in a specific bucket. "
|
|
"Shows bucket membership from the kanban view.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"project_id": {"type": "integer", "default": 3},
|
|
"list_id": {"type": "integer", "default": 0},
|
|
},
|
|
},
|
|
"handler": tool_list_tasks,
|
|
},
|
|
"kanban_create_task": {
|
|
"description": "Create a new task. Tag with list_id to assign to a bucket. "
|
|
"NOTE: Vikunja v2 does not persist bucket_id on the task model; "
|
|
"use the web UI to move tasks between kanban columns.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["title"],
|
|
"properties": {
|
|
"project_id": {"type": "integer", "default": 3},
|
|
"title": {"type": "string"},
|
|
"description": {"type": "string", "default": ""},
|
|
"list_id": {"type": "integer", "description": "Bucket ID from kanban_list_lists"},
|
|
},
|
|
},
|
|
"handler": tool_create_task,
|
|
},
|
|
"kanban_move_task": {
|
|
"description": "Move a task to a different bucket. "
|
|
"NOTE: Staged via task update; open the web UI to confirm "
|
|
"drag-and-drop placement in the kanban view.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["task_id", "list_id"],
|
|
"properties": {
|
|
"task_id": {"type": "integer"},
|
|
"list_id": {"type": "integer"},
|
|
},
|
|
},
|
|
"handler": tool_move_task,
|
|
},
|
|
"kanban_update_task": {
|
|
"description": "Update a task's title, description, done status, or priority. "
|
|
"Works with standard Vikunja task API (POST /tasks/{id}).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["task_id"],
|
|
"properties": {
|
|
"task_id": {"type": "integer"},
|
|
"title": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
"done": {"type": "boolean"},
|
|
"priority": {"type": "integer"},
|
|
},
|
|
},
|
|
"handler": tool_update_task,
|
|
},
|
|
"kanban_status": {
|
|
"description": "Return kanban server health, version, and web UI URL.",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
"handler": tool_status,
|
|
},
|
|
}
|
|
|
|
|
|
def handle(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
method = message.get("method")
|
|
msg_id = message.get("id")
|
|
if method == "initialize":
|
|
return {
|
|
"jsonrpc": "2.0", "id": msg_id,
|
|
"result": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
},
|
|
}
|
|
if method == "tools/list":
|
|
return {
|
|
"jsonrpc": "2.0", "id": msg_id,
|
|
"result": {
|
|
"tools": [
|
|
{"name": name, "description": data["description"], "inputSchema": data["inputSchema"]}
|
|
for name, data in TOOLS.items()
|
|
]
|
|
},
|
|
}
|
|
if method == "tools/call":
|
|
params = message.get("params") or {}
|
|
name = params.get("name")
|
|
args = params.get("arguments") or {}
|
|
if name not in TOOLS:
|
|
result = {"ok": False, "error": f"unknown tool: {name}"}
|
|
else:
|
|
result = TOOLS[name]["handler"](args)
|
|
return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": json_text(result)}}
|
|
if method and method.startswith("notifications/"):
|
|
return None
|
|
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
|
|
|
|
def main() -> None:
|
|
if not VIKUNJA_TOKEN:
|
|
err = {"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": "VIKUNJA_TOKEN not set"}}
|
|
print(json.dumps(err, separators=(",", ":")), flush=True)
|
|
sys.exit(1)
|
|
for line in sys.stdin:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
response = handle(json.loads(line))
|
|
except Exception as exc:
|
|
response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": f"{type(exc).__name__}: {exc}"}}
|
|
if response is not None:
|
|
print(json.dumps(response, separators=(",", ":")), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|