altiplano 0.1.0__tar.gz

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,2 @@
1
+ VIKUNJA_URL=https://todo.example.com/api/v1
2
+ VIKUNJA_API_TOKEN=tk_replace_me
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .env
8
+ .uv/
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: altiplano
3
+ Version: 0.1.0
4
+ Summary: Minimal MCP server for Vikunja (server-side filtering, no fluff)
5
+ Project-URL: Homepage, https://github.com/aichholzer/altiplano
6
+ Project-URL: Repository, https://github.com/aichholzer/altiplano
7
+ Project-URL: Issues, https://github.com/aichholzer/altiplano/issues
8
+ Author-email: Stefan Aichholzer <theaichholzer@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: mcp,model-context-protocol,tasks,todo,vikunja
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: mcp>=1.2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # altiplano
22
+
23
+ A small, dependable MCP server for [Vikunja](https://vikunja.io). Named after the Andean altiplano, the high plateau that is the Vicuña's native habitat.
24
+
25
+ Filtering and sorting are passed straight to the Vikunja API (server-side), so there is no client-side filtering engine and no paginate-then-filter pitfall.
26
+
27
+ ## Tools
28
+
29
+ - `list_projects`
30
+ - `list_tasks` (project_id, filter, sort_by, page, per_page)
31
+ - `get_task` (task_id)
32
+ - `create_task` (project_id, title, description?, priority?, due_date?)
33
+ - `update_task` (task_id, title?, description?, done?, priority?)
34
+ - `list_labels`
35
+ - `add_label` (task_id, label_id)
36
+ - `remove_label` (task_id, label_id)
37
+
38
+ ## Credentials (no secrets in mcp.json)
39
+
40
+ The server resolves two values, in order:
41
+
42
+ 1. Environment variables `VIKUNJA_URL` and `VIKUNJA_API_TOKEN`.
43
+ 2. A per-device file of `KEY=VALUE` lines, default `~/.config/altiplano/env`
44
+ (override the path with `ALTIPLANO_CONFIG`).
45
+
46
+ `VIKUNJA_URL` is the base API URL including `/api/v1` (e.g. `https://todo.example.com/api/v1`).
47
+
48
+ Recommended so the shared `mcp.json` carries no secret:
49
+
50
+ - Drop a per-device file and lock it down:
51
+ ```bash
52
+ mkdir -p ~/.config/altiplano
53
+ printf 'VIKUNJA_URL=https://todo.example.com/api/v1\nVIKUNJA_API_TOKEN=tk_xxx\n' > ~/.config/altiplano/env
54
+ chmod 600 ~/.config/altiplano/env
55
+ ```
56
+ - Or inject via the launcher's environment (e.g. a systemd unit `EnvironmentFile=` pointing at a `chmod 600` file), which the server inherits.
57
+ - For stronger setups, source the token from a secret manager/keychain at launch and export it into the environment.
58
+
59
+ Then `mcp.json` only needs the command, no `env` block with secrets:
60
+
61
+ ```json
62
+ {
63
+ "altiplano": {
64
+ "command": "uvx",
65
+ "args": ["altiplano"]
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## Run
71
+
72
+ ```bash
73
+ uv run altiplano # dev, from this directory
74
+ uvx --from /mnt/settings/MCP/altiplano altiplano # local path
75
+ uvx altiplano # from PyPI
76
+ ```
77
+
78
+ ## Notes
79
+
80
+ - Vikunja priority scale: 0 Unset, 1 Low, 2 Medium, 3 High, 4 Urgent, 5 DO NOW.
81
+ - The UI shows tasks by their project-local `identifier` (e.g. `#49`), which is not the global `id` the API uses.
82
+ - Endpoint shapes (create via `PUT /projects/{id}/tasks`, update via `POST /tasks/{id}`) follow current Vikunja; adjust if your instance differs.
@@ -0,0 +1,62 @@
1
+ # altiplano
2
+
3
+ A small, dependable MCP server for [Vikunja](https://vikunja.io). Named after the Andean altiplano, the high plateau that is the Vicuña's native habitat.
4
+
5
+ Filtering and sorting are passed straight to the Vikunja API (server-side), so there is no client-side filtering engine and no paginate-then-filter pitfall.
6
+
7
+ ## Tools
8
+
9
+ - `list_projects`
10
+ - `list_tasks` (project_id, filter, sort_by, page, per_page)
11
+ - `get_task` (task_id)
12
+ - `create_task` (project_id, title, description?, priority?, due_date?)
13
+ - `update_task` (task_id, title?, description?, done?, priority?)
14
+ - `list_labels`
15
+ - `add_label` (task_id, label_id)
16
+ - `remove_label` (task_id, label_id)
17
+
18
+ ## Credentials (no secrets in mcp.json)
19
+
20
+ The server resolves two values, in order:
21
+
22
+ 1. Environment variables `VIKUNJA_URL` and `VIKUNJA_API_TOKEN`.
23
+ 2. A per-device file of `KEY=VALUE` lines, default `~/.config/altiplano/env`
24
+ (override the path with `ALTIPLANO_CONFIG`).
25
+
26
+ `VIKUNJA_URL` is the base API URL including `/api/v1` (e.g. `https://todo.example.com/api/v1`).
27
+
28
+ Recommended so the shared `mcp.json` carries no secret:
29
+
30
+ - Drop a per-device file and lock it down:
31
+ ```bash
32
+ mkdir -p ~/.config/altiplano
33
+ printf 'VIKUNJA_URL=https://todo.example.com/api/v1\nVIKUNJA_API_TOKEN=tk_xxx\n' > ~/.config/altiplano/env
34
+ chmod 600 ~/.config/altiplano/env
35
+ ```
36
+ - Or inject via the launcher's environment (e.g. a systemd unit `EnvironmentFile=` pointing at a `chmod 600` file), which the server inherits.
37
+ - For stronger setups, source the token from a secret manager/keychain at launch and export it into the environment.
38
+
39
+ Then `mcp.json` only needs the command, no `env` block with secrets:
40
+
41
+ ```json
42
+ {
43
+ "altiplano": {
44
+ "command": "uvx",
45
+ "args": ["altiplano"]
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Run
51
+
52
+ ```bash
53
+ uv run altiplano # dev, from this directory
54
+ uvx --from /mnt/settings/MCP/altiplano altiplano # local path
55
+ uvx altiplano # from PyPI
56
+ ```
57
+
58
+ ## Notes
59
+
60
+ - Vikunja priority scale: 0 Unset, 1 Low, 2 Medium, 3 High, 4 Urgent, 5 DO NOW.
61
+ - The UI shows tasks by their project-local `identifier` (e.g. `#49`), which is not the global `id` the API uses.
62
+ - Endpoint shapes (create via `PUT /projects/{id}/tasks`, update via `POST /tasks/{id}`) follow current Vikunja; adjust if your instance differs.
Binary file
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "altiplano"
3
+ version = "0.1.0"
4
+ description = "Minimal MCP server for Vikunja (server-side filtering, no fluff)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Stefan Aichholzer", email = "theaichholzer@gmail.com" },
10
+ ]
11
+ keywords = ["mcp", "vikunja", "model-context-protocol", "todo", "tasks"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Topic :: Utilities",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.2.0",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/aichholzer/altiplano"
26
+ Repository = "https://github.com/aichholzer/altiplano"
27
+ Issues = "https://github.com/aichholzer/altiplano/issues"
28
+
29
+ [project.scripts]
30
+ altiplano = "altiplano.server:main"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/altiplano"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,182 @@
1
+ """Minimal Vikunja MCP server.
2
+
3
+ Filtering and sorting are passed straight to the Vikunja API (server-side),
4
+ so there is no client-side filtering engine to get wrong.
5
+
6
+ Credentials are resolved without storing secrets in a shared mcp.json:
7
+ 1. Environment variables VIKUNJA_URL / VIKUNJA_API_TOKEN (preferred).
8
+ 2. A per-device file of KEY=VALUE lines (default ~/.config/altiplano/env,
9
+ override with ALTIPLANO_CONFIG). Keep it chmod 600.
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ mcp = FastMCP("altiplano")
20
+
21
+ _CONFIG_FILE = Path(
22
+ os.environ.get("ALTIPLANO_CONFIG", Path.home() / ".config" / "altiplano" / "env")
23
+ )
24
+
25
+
26
+ def _from_file(key: str) -> str | None:
27
+ try:
28
+ for line in _CONFIG_FILE.read_text().splitlines():
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ k, v = line.split("=", 1)
33
+ if k.strip() == key:
34
+ return v.strip().strip('"').strip("'")
35
+ except FileNotFoundError:
36
+ return None
37
+ return None
38
+
39
+
40
+ def _conf(key: str) -> str | None:
41
+ return os.environ.get(key) or _from_file(key)
42
+
43
+
44
+ def _base() -> str:
45
+ url = _conf("VIKUNJA_URL")
46
+ if not url:
47
+ raise RuntimeError("VIKUNJA_URL is not set (env or ~/.config/altiplano/env)")
48
+ return url.rstrip("/")
49
+
50
+
51
+ def _headers() -> dict[str, str]:
52
+ token = _conf("VIKUNJA_API_TOKEN")
53
+ if not token:
54
+ raise RuntimeError("VIKUNJA_API_TOKEN is not set (env or ~/.config/altiplano/env)")
55
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
56
+
57
+
58
+ async def _request(method: str, path: str, **kwargs: Any) -> Any:
59
+ async with httpx.AsyncClient(base_url=_base(), headers=_headers(), timeout=30) as client:
60
+ r = await client.request(method, path, **kwargs)
61
+ r.raise_for_status()
62
+ if r.status_code == 204 or not r.content:
63
+ return {"ok": True}
64
+ return r.json()
65
+
66
+
67
+ def _task_summary(t: dict) -> dict:
68
+ return {
69
+ "id": t.get("id"),
70
+ "identifier": t.get("identifier"),
71
+ "title": t.get("title"),
72
+ "done": t.get("done"),
73
+ "priority": t.get("priority"),
74
+ }
75
+
76
+
77
+ @mcp.tool()
78
+ async def list_projects() -> list[dict]:
79
+ """List all projects (boards)."""
80
+ data = await _request("GET", "/projects")
81
+ return [
82
+ {"id": p["id"], "title": p["title"], "is_archived": p.get("is_archived", False)}
83
+ for p in (data or [])
84
+ ]
85
+
86
+
87
+ @mcp.tool()
88
+ async def list_tasks(
89
+ project_id: int,
90
+ filter: str | None = None,
91
+ sort_by: str | None = None,
92
+ page: int = 1,
93
+ per_page: int = 50,
94
+ ) -> list[dict]:
95
+ """List tasks in a project.
96
+
97
+ `filter` and `sort_by` are passed to Vikunja and applied server-side, e.g.
98
+ filter="done = false && priority >= 4", sort_by="priority". Vikunja filters
99
+ then paginates, so results are complete regardless of page size.
100
+ """
101
+ params: dict[str, Any] = {"page": page, "per_page": per_page}
102
+ if filter:
103
+ params["filter"] = filter
104
+ if sort_by:
105
+ params["sort_by"] = sort_by
106
+ data = await _request("GET", f"/projects/{project_id}/tasks", params=params)
107
+ return [_task_summary(t) for t in (data or [])]
108
+
109
+
110
+ @mcp.tool()
111
+ async def get_task(task_id: int) -> dict:
112
+ """Get a single task with full detail."""
113
+ return await _request("GET", f"/tasks/{task_id}")
114
+
115
+
116
+ @mcp.tool()
117
+ async def create_task(
118
+ project_id: int,
119
+ title: str,
120
+ description: str | None = None,
121
+ priority: int | None = None,
122
+ due_date: str | None = None,
123
+ ) -> dict:
124
+ """Create a task in a project."""
125
+ payload: dict[str, Any] = {"title": title}
126
+ if description is not None:
127
+ payload["description"] = description
128
+ if priority is not None:
129
+ payload["priority"] = priority
130
+ if due_date is not None:
131
+ payload["due_date"] = due_date
132
+ return await _request("PUT", f"/projects/{project_id}/tasks", json=payload)
133
+
134
+
135
+ @mcp.tool()
136
+ async def update_task(
137
+ task_id: int,
138
+ title: str | None = None,
139
+ description: str | None = None,
140
+ done: bool | None = None,
141
+ priority: int | None = None,
142
+ ) -> dict:
143
+ """Update a task. Only the fields you pass are changed. Use `done` to open/close it."""
144
+ payload: dict[str, Any] = {}
145
+ if title is not None:
146
+ payload["title"] = title
147
+ if description is not None:
148
+ payload["description"] = description
149
+ if done is not None:
150
+ payload["done"] = done
151
+ if priority is not None:
152
+ payload["priority"] = priority
153
+ if not payload:
154
+ raise ValueError("No fields to update")
155
+ return await _request("POST", f"/tasks/{task_id}", json=payload)
156
+
157
+
158
+ @mcp.tool()
159
+ async def list_labels() -> list[dict]:
160
+ """List all labels."""
161
+ data = await _request("GET", "/labels")
162
+ return [{"id": x["id"], "title": x["title"]} for x in (data or [])]
163
+
164
+
165
+ @mcp.tool()
166
+ async def add_label(task_id: int, label_id: int) -> dict:
167
+ """Attach a label to a task."""
168
+ return await _request("PUT", f"/tasks/{task_id}/labels", json={"label_id": label_id})
169
+
170
+
171
+ @mcp.tool()
172
+ async def remove_label(task_id: int, label_id: int) -> dict:
173
+ """Remove a label from a task."""
174
+ return await _request("DELETE", f"/tasks/{task_id}/labels/{label_id}")
175
+
176
+
177
+ def main() -> None:
178
+ mcp.run()
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()