Refactor project naming and update documentation for Vikunja MCP. Changed references from PyVikunja to Vikunja MCP in .env.example, README.md, requirements.txt, server.py, test.py, and removed obsolete wipe.sh script. Enhanced error handling for environment variables in server.py.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/mcp/.env.example
|
||||
# Vikunja MCP - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja-mcp/.env.example
|
||||
|
||||
VIKUNJA_URL=http://localhost:3456
|
||||
VIKUNJA_TOKEN=tk_your_api_token_here
|
||||
|
||||
61
README.md
61
README.md
@@ -1,8 +1,8 @@
|
||||
# PyVikunja
|
||||
# Vikunja MCP
|
||||
|
||||
A persistent brain for AI agents.
|
||||
|
||||
PyVikunja turns a self hosted [Vikunja](https://vikunja.io) instance into long term memory and project management that any AI agent can read and write. It ships an MCP server that gives the agent a small set of tools for storing facts, tracking work, and recalling exactly what it needs when it needs it. Memories live in a real database instead of your prompt, sessions keep their continuity across days and weeks, and your token bill drops because the agent stops dragging yesterday's context into today's conversation.
|
||||
Vikunja MCP turns a self hosted [Vikunja](https://vikunja.io) instance into long term memory and project management that any AI agent can read and write. It ships an MCP server that gives the agent a small set of tools for storing facts, tracking work, and recalling exactly what it needs when it needs it. Memories live in a real database instead of your prompt, sessions keep their continuity across days and weeks, and your token bill drops because the agent stops dragging yesterday's context into today's conversation.
|
||||
|
||||
## Table of Contents
|
||||
- [What Problem This Solves](#what-problem-this-solves)
|
||||
@@ -30,7 +30,7 @@ AI agents forget. Every new session starts from zero, every long session drags i
|
||||
1. **Session amnesia.** The agent cannot continue yesterday's work. You re explain the project, the constraints, the decisions, the people, the open bugs. Every morning.
|
||||
2. **Token cost.** Long running context lives inside the prompt. You pay for history you already read, every single turn, forever.
|
||||
|
||||
PyVikunja fixes both by moving the memory out of the model and into a structured store the model can *query*. Instead of pasting everything you ever told the agent into the system prompt, the agent stores facts once and later asks for the two or three items that are actually relevant to the current question. The storage is Vikunja, the access path is an MCP server, and the shape of what gets stored is not left up to the agent's imagination. See [How Information Is Stored](#how-information-is-stored).
|
||||
Vikunja MCP fixes both by moving the memory out of the model and into a structured store the model can *query*. Instead of pasting everything you ever told the agent into the system prompt, the agent stores facts once and later asks for the two or three items that are actually relevant to the current question. The storage is Vikunja, the access path is an MCP server, and the shape of what gets stored is not left up to the agent's imagination. See [How Information Is Stored](#how-information-is-stored).
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -43,7 +43,7 @@ PyVikunja fixes both by moving the memory out of the model and into a structured
|
||||
| HTTP
|
||||
|
|
||||
+---------+---------+
|
||||
| PyVikunja MCP |
|
||||
| Vikunja MCP |
|
||||
+---------+---------+
|
||||
|
|
||||
+-----------------+-----------------+
|
||||
@@ -60,7 +60,7 @@ PyVikunja fixes both by moving the memory out of the model and into a structured
|
||||
|
||||
The agent speaks MCP. The MCP server speaks the Vikunja REST API. Vikunja stores everything on disk in its own database. Any number of agents *(remote or local, paid or free)* can point at the same MCP server and share the same memory.
|
||||
|
||||
PyVikunja supports all three MCP transports — stdio for local subprocess use, SSE for legacy HTTP clients, and Streamable HTTP for modern HTTP clients. Pick whichever your editor or agent supports. See [Transports](#transports) for the details.
|
||||
Vikunja MCP supports all three MCP transports — stdio for local subprocess use, SSE for legacy HTTP clients, and Streamable HTTP for modern HTTP clients. Pick whichever your editor or agent supports. See [Transports](#transports) for the details.
|
||||
|
||||
## How Information Is Stored
|
||||
|
||||
@@ -139,18 +139,18 @@ Storing things is only half the point. The payoff is precise recall that does no
|
||||
|
||||
The agent reads memories by calling a single tool with a filter expression. The filter language supports the usual operators and runs against every task field:
|
||||
|
||||
| Query intent | Filter expression |
|
||||
| Query intent | Filter expression *(label IDs are examples)* |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| All open todos in this repo | `project = 42 && done = false` |
|
||||
| High priority bugs across everything | `labels in ["bug", "p0"] && done = false` |
|
||||
| Everything you know about Alice | `labels in ["person:alice"]` |
|
||||
| Decisions made in the last month | `labels in ["kind:decision"] && created > "2026-03-12"` |
|
||||
| High priority bugs across everything | `labels in 3,7 && done = false` |
|
||||
| Everything you know about Alice | `labels = 5` |
|
||||
| Decisions made in the last month | `labels = 8 && created > "2026-03-12"` |
|
||||
| Anything mentioning postgres | `title like "postgres" \|\| description like "postgres"` |
|
||||
| Overdue work | `due_date < "now" && done = false` |
|
||||
|
||||
The agent asks for the narrowest filter it can, pulls the matching 2-5 tasks, and puts only those into its working context. Five tasks of markdown is dozens of tokens, not thousands.
|
||||
|
||||
Recall composes with projects and labels naturally. "What do I know about Bob that came up in meetings" is `labels in ["person:bob", "source:meeting"]`. "Open p0 bugs in the backend repo" is `project = <id> && labels in ["bug", "p0"] && done = false`. The agent does not need a search engine because Vikunja's filters are already one.
|
||||
Recall composes with projects and labels naturally. "What do I know about Bob that came up in meetings" resolves those two label names to their numeric ids first, then filters with `labels in 4,9`. "Open p0 bugs in the backend repo" is `project = <id> && labels in 3,7 && done = false`. Label names must always be resolved to numeric ids before use in filter expressions. The agent does not need a search engine because Vikunja's filters are already one.
|
||||
|
||||
## The Instructions Payload
|
||||
|
||||
@@ -170,15 +170,15 @@ The instructions teach the agent:
|
||||
|
||||
Think of it as a constitution for the agent's interaction with the store. Every agent that connects reads the same rules and therefore produces the same structure, which means memories written by your local LLM on Monday are perfectly readable by Claude Code on Friday. Shared conventions are what makes multi agent memory work.
|
||||
|
||||
The payload lives in `mcp/server.py` as the `INSTRUCTIONS` constant. Edit it to customise conventions for your own workflows *(add new label namespaces, change the Memory project name, enforce stricter safety rules)*. Changes take effect on the next MCP client restart.
|
||||
The payload lives in `instructions.txt` at the repo root. Edit it to customise conventions for your own workflows *(add new label namespaces, change the Memory project name, enforce stricter safety rules)*. Changes take effect on the next MCP client restart.
|
||||
|
||||
## Setup
|
||||
|
||||
Clone the repository and install dependencies:
|
||||
|
||||
```
|
||||
git clone https://github.com/acidvegas/pyvikunja
|
||||
cd pyvikunja/mcp
|
||||
git clone https://github.com/acidvegas/vikunja-mcp
|
||||
cd vikunja-mcp
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
@@ -200,7 +200,7 @@ You should see `OK` for `/info`, `/user`, `/projects`, and `/tasks`.
|
||||
|
||||
## Transports
|
||||
|
||||
MCP defines three transports. PyVikunja speaks all three — pick the one your client supports. stdio is the default, SSE and Streamable HTTP are selected with a CLI flag.
|
||||
MCP defines three transports. Vikunja MCP speaks all three — pick the one your client supports. stdio is the default, SSE and Streamable HTTP are selected with a CLI flag.
|
||||
|
||||
| Transport | Use when | Launched by | Endpoint |
|
||||
| --------------- | --------------------------------------------------------------------------------------- | -------------------- | ------------------- |
|
||||
@@ -233,15 +233,15 @@ Each process is independent and talks to the same Vikunja backend, so memories w
|
||||
If you want the network transports to be persistent, run them as a systemd unit. Minimal example *(`/etc/systemd/system/vikunja-mcp.service`)*:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=PyVikunja MCP server
|
||||
Description=Vikunja MCP server
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vikunja-mcp
|
||||
WorkingDirectory=/opt/pyvikunja
|
||||
EnvironmentFile=/opt/pyvikunja/.env
|
||||
ExecStart=/usr/bin/python3 /opt/pyvikunja/server.py --transport http --host 127.0.0.1 --port 8000
|
||||
WorkingDirectory=/opt/vikunja-mcp
|
||||
EnvironmentFile=/opt/vikunja-mcp/.env
|
||||
ExecStart=/usr/bin/python3 /opt/vikunja-mcp/server.py --transport http --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -264,7 +264,7 @@ The client launches `server.py` as a subprocess and talks to it over the pipe. S
|
||||
"mcpServers": {
|
||||
"vikunja": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/to/pyvikunja/server.py"],
|
||||
"args": ["/absolute/path/to/vikunja-mcp/server.py"],
|
||||
"env": {
|
||||
"VIKUNJA_URL": "http://localhost:3456",
|
||||
"VIKUNJA_TOKEN": "tk_your_token_here"
|
||||
@@ -280,7 +280,7 @@ The client launches `server.py` as a subprocess and talks to it over the pipe. S
|
||||
"mcpServers": {
|
||||
"vikunja": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/to/pyvikunja/server.py"],
|
||||
"args": ["/absolute/path/to/vikunja-mcp/server.py"],
|
||||
"env": {
|
||||
"VIKUNJA_URL": "http://localhost:3456",
|
||||
"VIKUNJA_TOKEN": "tk_your_token_here"
|
||||
@@ -296,7 +296,7 @@ The client launches `server.py` as a subprocess and talks to it over the pipe. S
|
||||
"mcpServers": {
|
||||
"vikunja": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/to/pyvikunja/server.py"],
|
||||
"args": ["/absolute/path/to/vikunja-mcp/server.py"],
|
||||
"env": {
|
||||
"VIKUNJA_URL": "http://localhost:3456",
|
||||
"VIKUNJA_TOKEN": "tk_your_token_here"
|
||||
@@ -313,7 +313,7 @@ experimental:
|
||||
- name: vikunja
|
||||
command: python3
|
||||
args:
|
||||
- /absolute/path/to/pyvikunja/server.py
|
||||
- /absolute/path/to/vikunja-mcp/server.py
|
||||
env:
|
||||
VIKUNJA_URL: http://localhost:3456
|
||||
VIKUNJA_TOKEN: tk_your_token_here
|
||||
@@ -433,27 +433,12 @@ All three patterns use the exact same MCP server. The difference is which agent
|
||||
|
||||
Three compounding effects:
|
||||
|
||||
1. **Structured recall instead of context stuffing.** Traditional agent memory pastes a wall of text into the system prompt every turn. With PyVikunja the agent filters for the two or three items it actually needs. A user with 500 stored memories can see the difference between 50k tokens per turn and a few hundred.
|
||||
1. **Structured recall instead of context stuffing.** Traditional agent memory pastes a wall of text into the system prompt every turn. With Vikunja MCP the agent filters for the two or three items it actually needs. A user with 500 stored memories can see the difference between 50k tokens per turn and a few hundred.
|
||||
2. **Session continuity.** Because memory survives across sessions, you stop re explaining yesterday's work every morning. The first message of every session can be "what was I doing" and the answer comes out of Vikunja.
|
||||
3. **Free labor.** Routine writes and lookups run on your local model. Frontier model sessions stay focused on reasoning, and only touch memory through tool calls instead of holding it in the prompt.
|
||||
|
||||
There is no universal savings number because it depends on how memory heavy your workflows already are. The heavier they are, the bigger the win.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| MCP client lists no tools | Check the client's MCP log, usually a missing `command` path or a bad `env` block |
|
||||
| `HTTP 401 invalid token` | Regenerate the API token in Vikunja, update `.env`, restart the MCP client |
|
||||
| `HTTP 404` from a tool call | The target id does not exist, verify with a list or search tool first |
|
||||
| Agent creates duplicate Memory projects | Tell it to look up the Memory project id once per session and reuse it |
|
||||
| Agent invents inconsistent labels | Strengthen the label namespace rules inside `INSTRUCTIONS` |
|
||||
| Token usage does not drop | You are still pasting memory into prompts, stop doing that and let the agent fetch it |
|
||||
| SSE or HTTP client can't connect | Verify the daemon is running *(`curl http://host:port/sse` or `POST /mcp`)*, check `--host` is reachable, check firewall |
|
||||
| SSE client gets stuck "connecting" | The SSE transport needs `POST /messages/` to be reachable too, not just `GET /sse`. Don't block POSTs at your proxy |
|
||||
| Streamable HTTP returns 307 redirect | Client is posting to `/mcp/` with a trailing slash. Point it at `/mcp` exactly, or allow redirects on the client side |
|
||||
| `ModuleNotFoundError: starlette` / `uvicorn` | Reinstall deps: `pip install -r requirements.txt`. These are only needed for `sse` and `http` transports |
|
||||
|
||||
---
|
||||
|
||||
###### Mirrors: [SuperNETs](https://git.supernets.org/acidvegas/) • [GitHub](https://github.com/acidvegas/) • [GitLab](https://gitlab.com/acidvegas/) • [Codeberg](https://codeberg.org/acidvegas/)
|
||||
|
||||
197
instructions.txt
Normal file
197
instructions.txt
Normal file
@@ -0,0 +1,197 @@
|
||||
Vikunja MCP usage guide.
|
||||
|
||||
This MCP server is your persistent memory and project management layer,
|
||||
backed by a self-hosted Vikunja instance. You are the only user. Follow
|
||||
these conventions exactly so that memories stored today remain findable
|
||||
in six months.
|
||||
|
||||
VOCABULARY
|
||||
project container of tasks. One per repo, one for general memory,
|
||||
one per life area. Create once, reuse forever.
|
||||
task a single unit of work, note, or memory entry. Fields: title,
|
||||
description (markdown), done, priority (1-5), labels,
|
||||
assignees, bucket_id, created, updated.
|
||||
label a reusable tag. Cross-cutting across all projects. Labels
|
||||
have a numeric id that filters require.
|
||||
view a saved layout for a project (list, kanban, gantt, table).
|
||||
bucket a column inside a kanban view (Todo, In Progress, Done, ...).
|
||||
filter a saved query recallable by id.
|
||||
|
||||
TAGGING RULES (MANDATORY)
|
||||
|
||||
Labels are the primary recall mechanism. Without discipline here,
|
||||
memory becomes unsearchable noise.
|
||||
|
||||
1. Always namespace labels as "namespace:value". The only exceptions
|
||||
are the universal workflow tags listed below.
|
||||
Good: topic:postgres, kind:decision, lang:python
|
||||
Bad: postgres, decision, python
|
||||
|
||||
2. Always lowercase. "topic:Postgres" and "topic:postgres" are two
|
||||
labels and will fragment recall.
|
||||
|
||||
3. Before creating a label, search with get__labels s=<prefix>. Only
|
||||
create if no match exists. Reuse is the whole point.
|
||||
|
||||
4. Never invent synonyms. Once "topic:postgres" exists, never create
|
||||
"topic:postgresql" or "topic:pg". One canonical form per concept.
|
||||
|
||||
5. Tag aggressively. Every memory gets two to five labels. A decision
|
||||
about Postgres replication for the backend project should carry:
|
||||
topic:postgres, topic:replication, kind:decision, project:backend.
|
||||
More angles means more recall paths.
|
||||
|
||||
6. Cache label ids within a session. Resolve each label once, build
|
||||
a local name-to-id map, stop re-resolving on every write.
|
||||
|
||||
CANONICAL NAMESPACES
|
||||
|
||||
topic:<thing> Technical subjects, tools, concepts, systems.
|
||||
Examples: topic:postgres, topic:docker, topic:auth,
|
||||
topic:networking, topic:dns, topic:linux, topic:irc.
|
||||
|
||||
kind:<type> The shape of the memory entry.
|
||||
Examples: kind:fact, kind:decision, kind:preference,
|
||||
kind:reference, kind:snippet, kind:question, kind:todo,
|
||||
kind:lesson, kind:idea, kind:config, kind:howto.
|
||||
|
||||
lang:<language> Programming language when relevant.
|
||||
Examples: lang:python, lang:go, lang:bash, lang:c.
|
||||
|
||||
project:<name> Scope to a specific codebase or initiative.
|
||||
Examples: project:backend, project:mcp, project:dotfiles.
|
||||
|
||||
area:<domain> Broader life or work area.
|
||||
Examples: area:infra, area:homelab, area:learning,
|
||||
area:networking, area:security.
|
||||
|
||||
source:<where> Where the information originated, when it matters.
|
||||
Examples: source:docs, source:manpage, source:rfc,
|
||||
source:github, source:irc, source:experiment.
|
||||
|
||||
status:<state> Temporal relevance markers for things that expire.
|
||||
Examples: status:current, status:outdated, status:blocked.
|
||||
|
||||
Add new namespaces when you genuinely need one. Keep the format
|
||||
namespace:value. If you find yourself wanting a namespace that only has
|
||||
one or two entries, consider whether an existing namespace covers it.
|
||||
|
||||
The following tags are allowed un-namespaced because they are universal
|
||||
workflow labels in a repo context: bug, feature, refactor, docs, chore,
|
||||
breaking, p0, p1, p2. Do not use these outside code projects.
|
||||
|
||||
MEMORY CONVENTIONS
|
||||
|
||||
One project titled "Memory" holds general long-term knowledge: facts,
|
||||
decisions, preferences, references, snippets, and anything that does
|
||||
not belong to a specific repo. Create it on first use if missing.
|
||||
Cache its id for the rest of the session.
|
||||
|
||||
Every memory is a single task:
|
||||
title short headline, 3 to 10 words. Treat it like a filename.
|
||||
description full markdown body, arbitrarily long. This is the content.
|
||||
labels two to five tags following the rules above.
|
||||
priority 1-5, default 2. Reserve 4-5 for truly critical info.
|
||||
|
||||
Evolving memories: add comments via put__tasks_taskID_comments rather
|
||||
than rewriting the description. Comments preserve the timeline.
|
||||
|
||||
Superseded memories: set done=true. They stay searchable but are
|
||||
excluded from default "active" queries.
|
||||
|
||||
NEVER store secrets, credentials, tokens, API keys, or private keys.
|
||||
|
||||
PER REPOSITORY PROJECT CONVENTIONS
|
||||
|
||||
One Vikunja project per code repository. Title must match the repo name.
|
||||
|
||||
On first touch:
|
||||
1. get__projects s=<repo_name> to check for an existing project
|
||||
2. If missing, put__projects with {"title": "<repo_name>",
|
||||
"description": "<repo path or url>"}
|
||||
3. Ensure workflow labels exist: bug, feature, refactor, docs,
|
||||
chore, breaking, p0, p1, p2
|
||||
|
||||
Task workflow:
|
||||
- Create tasks via put__projects_id_tasks
|
||||
- Every task gets a type label (bug | feature | refactor | docs | chore)
|
||||
and a priority label (p0 | p1 | p2)
|
||||
- Mark done=true when complete. Never delete. History matters.
|
||||
- Record implementation notes via put__tasks_taskID_comments
|
||||
|
||||
Session startup: call get__tasks with
|
||||
filter="project = <id> && done = false" to see what is in flight.
|
||||
|
||||
FILTER SYNTAX (CRITICAL)
|
||||
|
||||
Operators: = != > >= < <= like in
|
||||
Combinators: && (and), || (or). Parentheses for grouping.
|
||||
Strings are double-quoted. Numbers and booleans are bare.
|
||||
|
||||
Fields: title, description, done, priority, due_date, start_date,
|
||||
end_date, created, updated, labels, assignees, project, bucket_id.
|
||||
|
||||
LABEL FILTERS USE NUMERIC IDS, NOT NAMES.
|
||||
1. Resolve each tag name to its id via get__labels s=<tag>
|
||||
2. Filter: labels = 3
|
||||
3. Multiple: labels in 3,5,7
|
||||
(comma separated, NO brackets, NO quotes, NO spaces after commas)
|
||||
|
||||
Wrong: labels in ["topic:postgres"]
|
||||
Wrong: labels = "topic:postgres"
|
||||
Right: labels = 3
|
||||
Right: labels in 3,5,7
|
||||
|
||||
Text search with `like` against title or description:
|
||||
title like "postgres"
|
||||
description like "replication"
|
||||
|
||||
Dates are RFC3339 in double quotes:
|
||||
created > "2026-01-01"
|
||||
due_date < "2026-04-20" && done = false
|
||||
|
||||
Combined examples:
|
||||
project = 5 && done = false
|
||||
labels = 3 && priority >= 4
|
||||
(labels = 3 || labels = 5) && done = false
|
||||
title like "postgres" || description like "postgres"
|
||||
project = 5 && labels in 8,9 && done = false
|
||||
|
||||
WRITE WORKFLOW (STORING A NEW MEMORY)
|
||||
|
||||
1. Decide the tag set. Aim for 2 to 5 labels.
|
||||
2. For each tag, call get__labels s=<tag>. If missing, create with
|
||||
put__labels using a hex_color by namespace:
|
||||
topic=3b82f6 kind=8b5cf6 lang=06b6d4 project=10b981
|
||||
area=f59e0b source=6b7280 status=ef4444
|
||||
Cache ids for the session.
|
||||
3. Resolve the Memory project id once via get__projects s=Memory.
|
||||
4. Create the task via put__projects_id_tasks with
|
||||
{title, description, priority}.
|
||||
5. Attach each label via put__tasks_task_labels with {"label_id": <id>}.
|
||||
6. Confirm by echoing the stored title and tag set.
|
||||
|
||||
READ WORKFLOW (RECALLING MEMORIES)
|
||||
|
||||
1. Translate the question into candidate tags.
|
||||
2. Resolve each tag to its id (cached when possible).
|
||||
3. Build a filter: labels in 3,5,7. Add && done = false unless
|
||||
historical entries are explicitly wanted.
|
||||
4. Call get__tasks with the filter, per_page 5-15.
|
||||
5. Zero hits? Fall back to text search:
|
||||
title like "<keyword>" || description like "<keyword>"
|
||||
6. Surface 1-3 matches concisely. Full descriptions only on request.
|
||||
|
||||
DEFAULTS
|
||||
priority 1..5, 5 is highest
|
||||
hex_color 6-char hex, no leading hash
|
||||
done false by default
|
||||
per_page keep <= 20 unless a full dump is requested
|
||||
timezone UTC unless specified otherwise
|
||||
|
||||
SAFETY
|
||||
- Always check-or-create for projects and labels. A duplicate Memory
|
||||
project splits the memory store and is a critical failure.
|
||||
- Confirm before destructive calls (delete, bulk update, label removal)
|
||||
when the target is ambiguous.
|
||||
- Verify ids by reading first when intent is vague.
|
||||
@@ -1,3 +1,6 @@
|
||||
# Vikunja MCP - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/mcp/requirements.txt
|
||||
|
||||
aiohttp>=3.9
|
||||
mcp>=1.0
|
||||
python-dotenv>=1.0
|
||||
|
||||
373
server.py
373
server.py
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/mcp/server.py
|
||||
# Vikunja MCP - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja-mcp/server.py
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -28,8 +29,17 @@ except ImportError:
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_URL = os.getenv('VIKUNJA_URL', '').rstrip('/') + '/api/v1'
|
||||
TOKEN = os.getenv('VIKUNJA_TOKEN', '')
|
||||
log = logging.getLogger('vikunja-mcp')
|
||||
|
||||
_vikunja_url = os.getenv('VIKUNJA_URL', '').rstrip('/')
|
||||
if not _vikunja_url:
|
||||
raise SystemExit('VIKUNJA_URL is not set. Export it or add it to .env')
|
||||
|
||||
TOKEN = os.getenv('VIKUNJA_TOKEN', '')
|
||||
if not TOKEN:
|
||||
raise SystemExit('VIKUNJA_TOKEN is not set. Export it or add it to .env')
|
||||
|
||||
BASE_URL = _vikunja_url + '/api/v1'
|
||||
SPEC_URL = BASE_URL + '/docs.json'
|
||||
HEADERS = {'Authorization': f'Bearer {TOKEN}', 'Accept': 'application/json'}
|
||||
|
||||
@@ -152,216 +162,12 @@ ALLOWLIST = frozenset({
|
||||
})
|
||||
|
||||
|
||||
INSTRUCTIONS = '''Vikunja MCP usage guide.
|
||||
|
||||
This MCP server is persistent memory and project management for AI agents,
|
||||
backed by a self hosted Vikunja instance. Follow these conventions exactly so
|
||||
intents in natural language map cleanly onto API calls and so memories stored
|
||||
today remain findable tomorrow.
|
||||
|
||||
VOCABULARY
|
||||
project container of tasks, for long lived areas (memory, a repo, a life
|
||||
area). Create once, reuse forever.
|
||||
task a single unit of work, bug, note, or memory entry. Fields: title,
|
||||
description (markdown), done, priority (1-5), due_date, labels,
|
||||
assignees, bucket_id, created, updated.
|
||||
label a tag. Cross cutting, reusable across all projects. Labels have
|
||||
a numeric id that filters require.
|
||||
view a saved layout for a project (list, kanban, gantt, table).
|
||||
bucket a column inside a kanban view (Todo, In Progress, Done, ...).
|
||||
filter a saved query the user can recall by id.
|
||||
|
||||
TAGGING RULES (MANDATORY - BAD TAGS MAKE MEMORY UNREADABLE)
|
||||
|
||||
Labels are the primary recall mechanism. Discipline here is non negotiable.
|
||||
|
||||
1. Always namespace labels as "namespace:value". Never use bare tags.
|
||||
Good: topic:postgres, person:alice, kind:decision
|
||||
Bad: postgres, alice, decision
|
||||
|
||||
2. Always lowercase the value. "topic:Postgres" and "topic:postgres" are
|
||||
two separate labels and will fragment memory.
|
||||
|
||||
3. Before creating a label, search for it first with get__labels s=<prefix>.
|
||||
Only create a new label if no match exists. Reuse is the whole point.
|
||||
|
||||
4. Never invent synonyms. Once "topic:postgres" exists, do not create
|
||||
"topic:postgresql" or "topic:pg" or "topic:psql". One canonical form
|
||||
per concept, forever.
|
||||
|
||||
5. Tag aggressively. Every memory gets two to five labels. A fact about
|
||||
Alice using Postgres in a Slack discussion about a deploy decision
|
||||
should carry: person:alice, topic:postgres, topic:deployment,
|
||||
source:slack, kind:decision. More angles, more recall precision.
|
||||
|
||||
6. Cache label ids within a session. Do not re resolve the same label id
|
||||
for every write. Build a local name to id map on first use.
|
||||
|
||||
CANONICAL NAMESPACES
|
||||
|
||||
person:<name> A specific human. Lowercase first name or handle.
|
||||
Examples: person:alice, person:bob, person:acidvegas.
|
||||
|
||||
topic:<thing> Technical subject matter, tools, concepts, systems.
|
||||
Examples: topic:postgres, topic:docker, topic:auth,
|
||||
topic:deployment, topic:infra, topic:testing.
|
||||
|
||||
source:<where> Where the memory originated.
|
||||
Examples: source:slack, source:meeting, source:email,
|
||||
source:docs, source:ops, source:user.
|
||||
|
||||
kind:<type> The shape of the memory entry itself.
|
||||
Examples: kind:fact, kind:decision, kind:preference,
|
||||
kind:reference, kind:question, kind:todo.
|
||||
|
||||
project:<name> Scope to a specific codebase or initiative.
|
||||
Examples: project:backend, project:mcp, project:website.
|
||||
|
||||
area:<domain> Broader life or work area.
|
||||
Examples: area:home, area:work, area:learning.
|
||||
|
||||
Add new namespaces when you genuinely need one. Keep the format
|
||||
namespace:value for everything except the universal per repo workflow tags
|
||||
below.
|
||||
|
||||
The following tags are allowed un-namespaced because they are universal and
|
||||
self explanatory in a repo context: bug, feature, refactor, docs, chore,
|
||||
breaking, p0, p1, p2. Do not repurpose these names for non code projects.
|
||||
|
||||
MEMORY CONVENTIONS (LONG TERM AGENT MEMORY)
|
||||
|
||||
One project titled "Memory" holds every long term fact, decision,
|
||||
preference, and reference. Create it on first use if missing. Cache its
|
||||
id within the session; do not look it up repeatedly.
|
||||
|
||||
Every memory is a single task:
|
||||
title short headline, like a filename. 3 to 10 words.
|
||||
description full markdown body, arbitrarily long. This is the content.
|
||||
labels two to five tags, following the TAGGING RULES above.
|
||||
priority 1-5, default 2. Use 4 or 5 only for truly load bearing info.
|
||||
|
||||
Updates over time: add a comment via put__tasks_taskID_comments rather
|
||||
than editing the description. Comments preserve how the memory evolved.
|
||||
|
||||
Superseded memories: set done=true. They stay searchable but are filtered
|
||||
out of default "active" queries.
|
||||
|
||||
NEVER store secrets, credentials, tokens, API keys, or private data in
|
||||
memory tasks. The store is not encrypted and any agent or human with
|
||||
Vikunja access can read it.
|
||||
|
||||
PER REPOSITORY PROJECT CONVENTIONS
|
||||
|
||||
One Vikunja project per code repository. Project title must exactly match
|
||||
the repo name.
|
||||
|
||||
On first touch for a repo:
|
||||
1. get__projects s=<repo_name> to check for an existing project
|
||||
2. If missing, put__projects with {"title": "<repo_name>",
|
||||
"description": "<repo path or url>"}
|
||||
3. Ensure universal workflow labels exist: bug, feature, refactor, docs,
|
||||
chore, breaking, p0, p1, p2
|
||||
|
||||
Task workflow:
|
||||
- Create tasks via put__projects_id_tasks
|
||||
- Every task gets a type label (bug | feature | refactor | docs | chore)
|
||||
and a priority label (p0 | p1 | p2)
|
||||
- Mark tasks done=true when complete. Never delete them. History matters
|
||||
- Record implementation notes via put__tasks_taskID_comments
|
||||
|
||||
Session startup for a repo: call get__tasks with
|
||||
filter="project = <id> && done = false" before doing anything else so you
|
||||
know what is already in flight.
|
||||
|
||||
FILTER SYNTAX (CRITICAL - WRONG SYNTAX RETURNS ERRORS OR NOTHING)
|
||||
|
||||
Operators: = != > >= < <= like in
|
||||
Combinators: && (and), || (or). Group with parentheses when mixing.
|
||||
Strings are double quoted. Numbers and booleans are bare.
|
||||
|
||||
Valid fields: title, description, done, priority, due_date, start_date,
|
||||
end_date, created, updated, labels, assignees, project, bucket_id.
|
||||
|
||||
LABEL FILTERS REQUIRE NUMERIC LABEL IDS.
|
||||
You cannot filter by label title directly. Correct flow:
|
||||
1. Resolve each tag to its numeric id via get__labels s=<tag>
|
||||
2. Filter by id: `labels = 3`
|
||||
3. For OR across multiple labels: `labels in 3,5,7`
|
||||
(comma separated, NO brackets, NO quotes, NO spaces after commas)
|
||||
|
||||
Wrong: labels in ["topic:postgres"]
|
||||
Wrong: labels = "topic:postgres"
|
||||
Right: labels = 3
|
||||
Right: labels in 3,5,7
|
||||
|
||||
TEXT SEARCH via `like` against title or description. No % wildcards.
|
||||
title like "postgres"
|
||||
description like "deployment"
|
||||
|
||||
DATES are RFC3339 strings in double quotes.
|
||||
due_date < "2026-04-20" && done = false
|
||||
created > "2026-01-01"
|
||||
Use filter_timezone=<IANA zone> when a query spans a day boundary.
|
||||
|
||||
COMBINED EXAMPLES
|
||||
project = 5 && done = false
|
||||
labels = 3 && priority >= 4
|
||||
(labels = 3 || labels = 5) && done = false
|
||||
title like "postgres" || description like "postgres"
|
||||
project = 5 && labels in 8,9 && done = false
|
||||
|
||||
WRITE WORKFLOW (STORING A NEW MEMORY)
|
||||
|
||||
1. Decide the tag set using the TAGGING RULES. Aim for 2 to 5 tags.
|
||||
2. For each tag, call get__labels s=<tag>. If missing, create it with
|
||||
put__labels and a sensible hex_color (topic=3b82f6 blue, person=f59e0b
|
||||
amber, kind=8b5cf6 purple, source=6b7280 gray, area=10b981 green).
|
||||
Cache id results for the remainder of the session.
|
||||
3. Resolve the Memory project id once via get__projects s=Memory.
|
||||
4. Call put__projects_id_tasks with id=<memory_project_id> and a task
|
||||
body of {title, description, priority}.
|
||||
5. For each tag, call put__tasks_task_labels with task=<task_id> and
|
||||
body {"label_id": <id>}.
|
||||
6. Confirm by echoing the stored title and tag set back to the user.
|
||||
|
||||
READ WORKFLOW (RECALLING MEMORIES)
|
||||
|
||||
1. Translate the user question into a candidate tag set.
|
||||
2. Resolve each tag to its label id via get__labels (cached when possible).
|
||||
3. Build a label id filter: `labels in 3,5,7`. Add `&& done = false`
|
||||
unless the user explicitly wants historical entries.
|
||||
4. Call get__tasks with that filter and per_page between 5 and 15.
|
||||
5. If zero hits, fall back to text search:
|
||||
`title like "<keyword>" || description like "<keyword>"`
|
||||
6. Surface the top 1 to 3 matches concisely. Do not dump full descriptions
|
||||
unless the user asks for detail.
|
||||
|
||||
USER AND ASSIGNEE LOOKUP
|
||||
Resolve a username to a user id with get__users s=<partial>. Assign via
|
||||
put__tasks_taskID_assignees with {"user_id": <id>}.
|
||||
|
||||
REACTIONS
|
||||
The kind path parameter is "tasks" or "comments". The reaction body is a
|
||||
short emoji or keyword string.
|
||||
|
||||
DEFAULTS
|
||||
priority 1..5, 5 is highest
|
||||
hex_color 6 char hex, no leading hash
|
||||
done false by default
|
||||
per_page keep <= 20 unless the user asks for a full dump
|
||||
timezone UTC unless the user specifies otherwise
|
||||
|
||||
SAFETY AND IDEMPOTENCY
|
||||
- Always check-or-create for projects and labels. A duplicate Memory
|
||||
project is a critical failure that splits the agent memory store.
|
||||
- Confirm with the user before any destructive call (delete, bulk update,
|
||||
removing a label from a task) when the target is ambiguous.
|
||||
- Verify ids by reading first when the user intent is vague.
|
||||
- Never store secrets, credentials, tokens, or private user data.
|
||||
- Endpoints that manage account security, passwords, tokens, deletion,
|
||||
migrations, and test fixtures are not exposed by this MCP. Do not try
|
||||
to use them.
|
||||
'''
|
||||
_instructions_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'instructions.txt')
|
||||
try:
|
||||
with open(_instructions_path, 'r') as f:
|
||||
INSTRUCTIONS = f.read()
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f'instructions file not found: {_instructions_path}')
|
||||
|
||||
|
||||
async def build_tools(spec: dict) -> tuple:
|
||||
@@ -392,10 +198,12 @@ async def build_tools(spec: dict) -> tuple:
|
||||
|
||||
for param in op.get('parameters', []) or []:
|
||||
pname = param['name']
|
||||
loc = param.get('in')
|
||||
loc = param.get('in')
|
||||
|
||||
if loc == 'body':
|
||||
schema[pname] = {'type': 'object', 'description': f'Request body for {method.upper()} {path}'}
|
||||
body_schema = resolve_body_schema(param, spec)
|
||||
body_schema.setdefault('description', f'Request body for {method.upper()} {path}')
|
||||
schema[pname] = body_schema
|
||||
else:
|
||||
schema[pname] = {'type': openapi_to_json(param.get('type', 'string')), 'description': (param.get('description') or f'{loc} parameter').strip()}
|
||||
|
||||
@@ -434,19 +242,40 @@ async def call_endpoint(session, spec_op: dict, args: dict) -> str:
|
||||
elif loc == 'body':
|
||||
body = args[pname]
|
||||
|
||||
unresolved = re.findall(r'\{(\w+)\}', path)
|
||||
if unresolved:
|
||||
msg = f'error: missing required path parameters: {", ".join(unresolved)}'
|
||||
log.warning(msg)
|
||||
return msg
|
||||
|
||||
async with session.request(spec_op['method'], BASE_URL + path, params=query or None, json=body, headers=HEADERS) as resp:
|
||||
text = await resp.text()
|
||||
|
||||
if resp.status >= 400:
|
||||
log.warning('%s %s -> HTTP %d', spec_op['method'], path, resp.status)
|
||||
|
||||
return f'HTTP {resp.status}\n{text}'
|
||||
|
||||
|
||||
async def load_spec() -> dict:
|
||||
'''Fetch the Vikunja OpenAPI document from the running server.'''
|
||||
'''Fetch the Vikunja OpenAPI document from the running server.
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(SPEC_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json(content_type=None)
|
||||
Retries up to three times with exponential backoff when the server is
|
||||
temporarily unreachable.
|
||||
'''
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(SPEC_URL, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json(content_type=None)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
if attempt == 2:
|
||||
raise
|
||||
delay = 2 ** (attempt + 1)
|
||||
log.warning('spec load attempt %d failed (%s), retrying in %ds', attempt + 1, exc, delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
async def build_server() -> tuple:
|
||||
@@ -458,8 +287,9 @@ async def build_server() -> tuple:
|
||||
|
||||
spec = await load_spec()
|
||||
tools, index = await build_tools(spec)
|
||||
log.info('loaded %d tools from vikunja spec', len(tools))
|
||||
server = Server('vikunja', instructions=INSTRUCTIONS)
|
||||
session = aiohttp.ClientSession()
|
||||
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
|
||||
|
||||
@server.list_tools()
|
||||
async def _list_tools():
|
||||
@@ -470,9 +300,12 @@ async def build_server() -> tuple:
|
||||
op = index.get(name)
|
||||
|
||||
if op is None:
|
||||
log.warning('unknown tool requested: %s', name)
|
||||
return [TextContent(type='text', text=f'unknown tool: {name}')]
|
||||
|
||||
return [TextContent(type='text', text=await call_endpoint(session, op, arguments or {}))]
|
||||
log.info('tool call: %s -> %s %s', name, op['method'], op['path'])
|
||||
result = await call_endpoint(session, op, arguments or {})
|
||||
return [TextContent(type='text', text=result)]
|
||||
|
||||
return server, session
|
||||
|
||||
@@ -512,11 +345,14 @@ def run_sse(host: str, port: int):
|
||||
sse = SseServerTransport('/messages/')
|
||||
state = {}
|
||||
|
||||
async def handle_sse(request):
|
||||
server = state['server']
|
||||
async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
return Response()
|
||||
class SSEApp:
|
||||
async def __call__(self, scope, receive, send):
|
||||
server = state['server']
|
||||
async with sse.connect_sse(scope, receive, send) as (read, write):
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
|
||||
async def handle_health(_request):
|
||||
return Response('ok', media_type='text/plain')
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
@@ -531,7 +367,8 @@ def run_sse(host: str, port: int):
|
||||
app = Starlette(
|
||||
debug=False,
|
||||
routes=[
|
||||
Route('/sse', endpoint=handle_sse, methods=['GET']),
|
||||
Route('/sse', endpoint=SSEApp()),
|
||||
Route('/health', endpoint=handle_health, methods=['GET']),
|
||||
Mount('/messages/', app=sse.handle_post_message),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
@@ -555,6 +392,7 @@ def run_http(host: str, port: int):
|
||||
import uvicorn
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
except ImportError:
|
||||
raise ImportError('missing starlette/uvicorn (pip install starlette uvicorn)')
|
||||
@@ -567,6 +405,9 @@ def run_http(host: str, port: int):
|
||||
async def __call__(self, scope, receive, send):
|
||||
await state['manager'].handle_request(scope, receive, send)
|
||||
|
||||
async def handle_health(_request):
|
||||
return Response('ok', media_type='text/plain')
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
server, aio_session = await build_server()
|
||||
@@ -581,7 +422,10 @@ def run_http(host: str, port: int):
|
||||
|
||||
app = Starlette(
|
||||
debug=False,
|
||||
routes=[Route('/mcp', endpoint=MCPApp())],
|
||||
routes=[
|
||||
Route('/mcp', endpoint=MCPApp()),
|
||||
Route('/health', endpoint=handle_health, methods=['GET']),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -591,7 +435,7 @@ def run_http(host: str, port: int):
|
||||
def parse_args():
|
||||
'''Parse CLI arguments for transport selection.'''
|
||||
|
||||
parser = argparse.ArgumentParser(description='PyVikunja MCP server', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
parser = argparse.ArgumentParser(description='Vikunja MCP server', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
parser.add_argument('--transport', choices=TRANSPORT_CHOICES, default=DEFAULT_TRANSPORT, help='Transport to expose the MCP server over')
|
||||
parser.add_argument('--host', default=DEFAULT_HOST, help='Host interface for sse and http transports')
|
||||
parser.add_argument('--port', default=DEFAULT_PORT, type=int, help='TCP port for sse and http transports')
|
||||
@@ -602,6 +446,7 @@ def parse_args():
|
||||
def main():
|
||||
'''Entry point. Dispatch to the selected transport.'''
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
|
||||
args = parse_args()
|
||||
|
||||
if args.transport == 'stdio':
|
||||
@@ -621,20 +466,72 @@ def openapi_to_json(t: str) -> str:
|
||||
return {'integer': 'integer', 'number': 'number', 'boolean': 'boolean', 'array': 'array', 'file': 'string'}.get(t, 'string')
|
||||
|
||||
|
||||
def resolve_ref(spec: dict, ref: str) -> dict:
|
||||
'''Follow a JSON Reference pointer inside an OpenAPI spec.
|
||||
|
||||
:param spec: Full OpenAPI document
|
||||
:param ref: Reference string, e.g. "#/definitions/models.Task"
|
||||
'''
|
||||
|
||||
node = spec
|
||||
for part in ref.lstrip('#/').split('/'):
|
||||
node = node.get(part, {})
|
||||
return node
|
||||
|
||||
|
||||
def resolve_body_schema(param: dict, spec: dict) -> dict:
|
||||
'''Build a JSON Schema dict for a body parameter, resolving $ref when present.
|
||||
|
||||
Extracts top-level property names and types from the referenced definition
|
||||
so the LLM knows what fields to include in the request body.
|
||||
|
||||
:param param: OpenAPI body parameter object
|
||||
:param spec: Full OpenAPI document for $ref resolution
|
||||
'''
|
||||
|
||||
schema = param.get('schema', {})
|
||||
if '$ref' in schema:
|
||||
schema = resolve_ref(spec, schema['$ref'])
|
||||
|
||||
if schema.get('type') != 'object' or 'properties' not in schema:
|
||||
return {'type': 'object'}
|
||||
|
||||
props = {}
|
||||
for name, prop in schema.get('properties', {}).items():
|
||||
entry = {'type': openapi_to_json(prop.get('type', 'string'))}
|
||||
if 'description' in prop:
|
||||
entry['description'] = prop['description'].strip()
|
||||
if prop.get('type') == 'array' and 'items' in prop:
|
||||
items = prop['items']
|
||||
if '$ref' in items:
|
||||
items = resolve_ref(spec, items['$ref'])
|
||||
entry['items'] = {'type': openapi_to_json(items.get('type', 'string'))}
|
||||
props[name] = entry
|
||||
|
||||
return {'type': 'object', 'properties': props}
|
||||
|
||||
|
||||
SPEC_PATCHES = [
|
||||
('/labels/{id}', 'put', 'post'),
|
||||
]
|
||||
|
||||
|
||||
def patch_spec(spec: dict):
|
||||
'''Apply known fixes to the upstream Vikunja OpenAPI spec in place.
|
||||
|
||||
Vikunja documents PUT /labels/{id} for label updates but the live server
|
||||
returns 405 and only accepts POST /labels/{id}. Rewrite the operation so
|
||||
the generated MCP tool matches the real server behaviour.
|
||||
Each entry in SPEC_PATCHES is a (path, wrong_method, correct_method) tuple.
|
||||
The operation is moved from the wrong method to the correct one only when
|
||||
the correct method is not already present.
|
||||
|
||||
:param spec: Parsed OpenAPI document to mutate
|
||||
'''
|
||||
|
||||
node = spec.get('paths', {}).get('/labels/{id}')
|
||||
|
||||
if node and 'put' in node and 'post' not in node:
|
||||
node['post'] = node.pop('put')
|
||||
paths = spec.get('paths', {})
|
||||
for path, wrong, correct in SPEC_PATCHES:
|
||||
node = paths.get(path)
|
||||
if node and wrong in node and correct not in node:
|
||||
node[correct] = node.pop(wrong)
|
||||
log.info('spec patch: %s %s -> %s', path, wrong.upper(), correct.upper())
|
||||
|
||||
|
||||
def sanitize_name(raw: str) -> str:
|
||||
|
||||
90
test.py
90
test.py
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/test.py
|
||||
# Vikunja MCP - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja-mcp/test.py
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
@@ -27,7 +28,7 @@ URL = os.getenv('VIKUNJA_URL', 'http://localhost:3456').rstrip('/')
|
||||
TOKEN = os.getenv('VIKUNJA_TOKEN', '')
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SERVER_PATH = os.path.join(SCRIPT_DIR, 'mcp', 'server.py')
|
||||
SERVER_PATH = os.path.join(SCRIPT_DIR, 'server.py')
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
|
||||
@@ -39,8 +40,8 @@ PROJECT_MEMORY = {
|
||||
}
|
||||
|
||||
PROJECT_REPO = {
|
||||
'title': 'pyvikunja',
|
||||
'description': 'Per repository project tracking for the PyVikunja codebase.',
|
||||
'title': 'vikunja-mcp',
|
||||
'description': 'Per repository project tracking for the Vikunja MCP codebase.',
|
||||
'hex_color': '10b981',
|
||||
}
|
||||
|
||||
@@ -384,7 +385,7 @@ async def main():
|
||||
repo = await ensure_project(session, PROJECT_REPO)
|
||||
home = await ensure_project(session, PROJECT_HOME)
|
||||
print(f' Memory id={memory["id"]}')
|
||||
print(f' pyvikunja id={repo["id"]}')
|
||||
print(f' vikunja-mcp id={repo["id"]}')
|
||||
print(f' home id={home["id"]}')
|
||||
print()
|
||||
|
||||
@@ -403,7 +404,7 @@ async def main():
|
||||
print(f' {task["id"]:>4} {spec["title"]}')
|
||||
print()
|
||||
|
||||
print('seeding pyvikunja:')
|
||||
print('seeding vikunja-mcp:')
|
||||
for spec in REPO_TODOS:
|
||||
task = await create_task(session, repo['id'], spec, label_map)
|
||||
status = 'done' if spec.get('done') else 'open'
|
||||
@@ -434,10 +435,10 @@ async def main():
|
||||
await validate_query(session, 'decisions recorded in memory',
|
||||
{'filter': f'labels = {decision_id}'})
|
||||
|
||||
await validate_query(session, 'open pyvikunja work',
|
||||
await validate_query(session, 'open vikunja-mcp work',
|
||||
{'filter': f'project = {repo["id"]} && done = false'})
|
||||
|
||||
await validate_query(session, 'completed pyvikunja work',
|
||||
await validate_query(session, 'completed vikunja-mcp work',
|
||||
{'filter': f'project = {repo["id"]} && done = true'})
|
||||
|
||||
await validate_query(session, 'open p0 or p1 tasks',
|
||||
@@ -500,6 +501,75 @@ async def wipe_project_tasks(session, project_id: int):
|
||||
pass
|
||||
|
||||
|
||||
async def delete_by_title(session, kind: str, title: str):
|
||||
'''Search for items by title and delete all exact matches via the MCP.
|
||||
|
||||
:param session: Open MCP ClientSession
|
||||
:param kind: Resource type ("projects" or "labels")
|
||||
:param title: Exact title to match
|
||||
'''
|
||||
|
||||
items = await call(session, f'get__{kind}', s=title, per_page=50) or []
|
||||
singular = kind.rstrip('s')
|
||||
found = False
|
||||
|
||||
for item in items:
|
||||
if item.get('title') != title:
|
||||
continue
|
||||
found = True
|
||||
try:
|
||||
await call(session, f'delete__{kind}_id', id=item['id'])
|
||||
print(f' deleted {singular} \'{title}\' (id {item["id"]})')
|
||||
except RuntimeError as e:
|
||||
print(f' failed {singular} \'{title}\' (id {item["id"]}): {e}')
|
||||
|
||||
if not found:
|
||||
print(f' {singular} \'{title}\' not found')
|
||||
|
||||
|
||||
async def wipe():
|
||||
'''Delete all seeded projects and labels through the MCP server.'''
|
||||
|
||||
if not TOKEN:
|
||||
print('error: VIKUNJA_TOKEN is not set in .env')
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(SERVER_PATH):
|
||||
print(f'error: mcp server not found at {SERVER_PATH}')
|
||||
sys.exit(1)
|
||||
|
||||
params = StdioServerParameters(
|
||||
command = 'python3',
|
||||
args = [SERVER_PATH],
|
||||
env = {**os.environ, 'VIKUNJA_URL': URL, 'VIKUNJA_TOKEN': TOKEN},
|
||||
)
|
||||
|
||||
project_titles = [PROJECT_MEMORY['title'], PROJECT_REPO['title'], PROJECT_HOME['title']]
|
||||
label_titles = list(LABELS.keys())
|
||||
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
print('>>> deleting seeded projects (cascades to their tasks)')
|
||||
for title in project_titles:
|
||||
await delete_by_title(session, 'projects', title)
|
||||
|
||||
print()
|
||||
print('>>> deleting seeded labels')
|
||||
for title in label_titles:
|
||||
await delete_by_title(session, 'labels', title)
|
||||
|
||||
print()
|
||||
print('>>> done')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
parser = argparse.ArgumentParser(description='Seed and validate a Vikunja instance through the MCP server')
|
||||
parser.add_argument('-w', '--wipe', action='store_true', help='Delete all seeded projects and labels instead of seeding')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.wipe:
|
||||
asyncio.run(wipe())
|
||||
else:
|
||||
asyncio.run(main())
|
||||
|
||||
89
wipe.sh
89
wipe.sh
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/wipe.sh
|
||||
|
||||
[ -f .env ] && source .env || { echo 'error: missing .env file'; exit 1; }
|
||||
|
||||
command -v jq >/dev/null || { echo 'error: jq is required (apt install jq)'; exit 1; }
|
||||
|
||||
API="${VIKUNJA_URL%/}/api/v1"
|
||||
AUTH="Authorization: Bearer ${VIKUNJA_TOKEN}"
|
||||
|
||||
PROJECTS=(
|
||||
'Memory'
|
||||
'pyvikunja'
|
||||
'home'
|
||||
)
|
||||
|
||||
LABELS=(
|
||||
'topic:postgres'
|
||||
'topic:docker'
|
||||
'topic:auth'
|
||||
'topic:deployment'
|
||||
'topic:infra'
|
||||
'person:alice'
|
||||
'person:bob'
|
||||
'source:slack'
|
||||
'source:ops'
|
||||
'source:meeting'
|
||||
'kind:fact'
|
||||
'kind:decision'
|
||||
'kind:preference'
|
||||
'kind:reference'
|
||||
'bug'
|
||||
'feature'
|
||||
'refactor'
|
||||
'docs'
|
||||
'chore'
|
||||
'p0'
|
||||
'p1'
|
||||
'p2'
|
||||
)
|
||||
|
||||
search_ids() {
|
||||
local kind="$1"
|
||||
local title="$2"
|
||||
|
||||
curl -fsSL -H "$AUTH" -G \
|
||||
--data-urlencode "s=$title" \
|
||||
--data-urlencode 'per_page=50' \
|
||||
"$API/$kind" \
|
||||
| jq -r --arg t "$title" '.[]? | select(.title == $t) | .id'
|
||||
}
|
||||
|
||||
api_delete() {
|
||||
curl -fsSL -X DELETE -H "$AUTH" "$API$1" >/dev/null
|
||||
}
|
||||
|
||||
wipe_by_title() {
|
||||
local kind="$1"
|
||||
local title="$2"
|
||||
local ids
|
||||
|
||||
ids=$(search_ids "$kind" "$title")
|
||||
|
||||
if [ -z "$ids" ]; then
|
||||
echo " $kind '$title' not found"
|
||||
return
|
||||
fi
|
||||
|
||||
for id in $ids; do
|
||||
if api_delete "/$kind/$id"; then
|
||||
echo " deleted $kind '$title' (id $id)"
|
||||
else
|
||||
echo " failed $kind '$title' (id $id)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo '>>> deleting seeded projects (cascades to their tasks)'
|
||||
for title in "${PROJECTS[@]}"; do
|
||||
wipe_by_title 'projects' "$title"
|
||||
done
|
||||
|
||||
echo '>>> deleting seeded labels'
|
||||
for title in "${LABELS[@]}"; do
|
||||
wipe_by_title 'labels' "$title"
|
||||
done
|
||||
|
||||
echo '>>> done'
|
||||
Reference in New Issue
Block a user