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.
- altiplano-0.1.0/.env.example +2 -0
- altiplano-0.1.0/.gitignore +8 -0
- altiplano-0.1.0/PKG-INFO +82 -0
- altiplano-0.1.0/README.md +62 -0
- altiplano-0.1.0/banner.png +0 -0
- altiplano-0.1.0/pyproject.toml +37 -0
- altiplano-0.1.0/src/altiplano/__init__.py +1 -0
- altiplano-0.1.0/src/altiplano/server.py +182 -0
- altiplano-0.1.0/uv.lock +898 -0
altiplano-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|