Initial commit
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# PyVikunja - 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
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
281
README.md
Normal file
281
README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# PyVikunja
|
||||
|
||||
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.
|
||||
|
||||
## Table of Contents
|
||||
- [What Problem This Solves](#what-problem-this-solves)
|
||||
- [How It Works](#how-it-works)
|
||||
- [How Information Is Stored](#how-information-is-stored)
|
||||
- [Projects are containers](#projects-are-containers)
|
||||
- [Tasks are memory entries](#tasks-are-memory-entries)
|
||||
- [Labels are namespaced tags](#labels-are-namespaced-tags)
|
||||
- [Buckets are state machines](#buckets-are-state-machines)
|
||||
- [The Memory project](#the-memory-project)
|
||||
- [Per repository projects](#per-repository-projects)
|
||||
- [How Information Is Recalled](#how-information-is-recalled)
|
||||
- [The Instructions Payload](#the-instructions-payload)
|
||||
- [Setup](#setup)
|
||||
- [Client Configuration](#client-configuration)
|
||||
- [Usage Patterns](#usage-patterns)
|
||||
- [Token Savings](#token-savings)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## What Problem This Solves
|
||||
|
||||
AI agents forget. Every new session starts from zero, every long session drags its own transcript into every follow up request, and anything the agent "learned" last week evaporates unless you paste it back in. Two things follow from this:
|
||||
|
||||
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).
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
+-------------------+
|
||||
| Vikunja |
|
||||
| (self hosted) |
|
||||
+---------+---------+
|
||||
|
|
||||
| HTTP
|
||||
|
|
||||
+---------+---------+
|
||||
| PyVikunja MCP |
|
||||
+---------+---------+
|
||||
|
|
||||
| stdio
|
||||
|
|
||||
+---------------+---------------+
|
||||
| |
|
||||
+--------+---------+ +---------+--------+
|
||||
| Claude Code | | Local LLM |
|
||||
| (remote) | | (Ollama, |
|
||||
| | | LM Studio) |
|
||||
+------------------+ +------------------+
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## How Information Is Stored
|
||||
|
||||
Vikunja already has the right primitives for an agent brain. The MCP just teaches the agent how to use them consistently.
|
||||
|
||||
### Projects are containers
|
||||
|
||||
A project is a long lived bucket for related memories. Projects do not expire, they do not reset between sessions, and the agent never deletes them. The agent treats projects as the top level of its mental filing cabinet:
|
||||
|
||||
| Example project | What lives inside |
|
||||
| ------------------- | --------------------------------------------------------------- |
|
||||
| `Memory` | General long term facts, preferences, decisions, notes |
|
||||
| `my-app-backend` | Per repository project tracking for a specific codebase |
|
||||
| `infrastructure` | Persistent notes about servers, DNS, certificates, deployments |
|
||||
| `people` | Memories scoped to individuals you work with |
|
||||
|
||||
You can create any project structure you want. The agent is told to create projects on first use and reuse them forever after.
|
||||
|
||||
### Tasks are memory entries
|
||||
|
||||
A task in Vikunja has exactly the fields a good memory entry needs:
|
||||
|
||||
| Field | How the agent uses it |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `title` | Short headline, acts like a filename for the memory |
|
||||
| `description` | Full markdown body of the memory, can be arbitrarily long |
|
||||
| `labels` | Tags for recall, see the namespace convention below |
|
||||
| `priority` | Importance, 1 to 5 |
|
||||
| `due_date` | Optional reminder, used for actual todos |
|
||||
| `done` | Marks a task as resolved or a memory as superseded |
|
||||
| `created` | Automatic timestamp, queryable by date |
|
||||
| `updated` | Automatic timestamp, queryable by date |
|
||||
| `comments` | Threaded notes added over time without editing the original body |
|
||||
| `attachments` | Binary blobs, screenshots, logs, whatever |
|
||||
| `relations` | Links to other tasks *(subtask, blocks, related, duplicates)* |
|
||||
|
||||
A "fact the agent remembered" and a "todo the agent is tracking" are both just tasks. The schema is uniform. You never have two kinds of memory to worry about.
|
||||
|
||||
### Labels are namespaced tags
|
||||
|
||||
Labels are how the agent finds things later. The convention is strict for a reason: if labels are freeform, the agent invents a new variant every session *(`postgres`, `postgresql`, `pg`, `Postgres`)* and nothing is findable. The instructions tell the agent to use a `namespace:value` format so labels cluster into a small vocabulary:
|
||||
|
||||
| Namespace | Example labels | Meaning |
|
||||
| ---------- | ---------------------------------- | ---------------------------------- |
|
||||
| `person` | `person:alice`, `person:bob` | Facts tied to a specific human |
|
||||
| `topic` | `topic:postgres`, `topic:docker` | Technical subject matter |
|
||||
| `source` | `source:slack`, `source:meeting` | Where the memory came from |
|
||||
| `kind` | `kind:decision`, `kind:fact` | Shape of the memory |
|
||||
| `project` | `project:mcp`, `project:backend` | Scope to a specific project |
|
||||
|
||||
You can add your own namespaces. The only rule is that the agent always namespaces labels, never adds bare tags.
|
||||
|
||||
### Buckets are state machines
|
||||
|
||||
Views and buckets are optional, but for project management they are how the agent tracks work state. A kanban view on a project gets buckets like:
|
||||
|
||||
```
|
||||
Todo -> In Progress -> Review -> Done
|
||||
|
|
||||
Blocked
|
||||
```
|
||||
|
||||
The agent moves tasks between buckets as work progresses. Anything in `Done` is historical, anything in `Blocked` is awaiting an external dependency. When you start a new session and ask "what was I working on" the agent filters for tasks not in `Done` and you immediately see the real state of the repo.
|
||||
|
||||
### The Memory project
|
||||
|
||||
One project called **Memory** holds long term facts that do not belong to any specific repo or initiative. This is the default home for "remember this" style interactions. The agent creates it the first time it is asked to remember anything, then reuses it forever. Memories here are tagged aggressively so recall is precise.
|
||||
|
||||
### Per repository projects
|
||||
|
||||
For code work, the agent creates one project per code repository, project title = repo name. On first touch it sets up a standard kanban layout and a standard label set so every repo looks the same. Once that is in place you can open any editor, tell the agent "keep going on this repo", and it reads the real todo list out of Vikunja instead of asking you what it should be doing.
|
||||
|
||||
## How Information Is Recalled
|
||||
|
||||
Storing things is only half the point. The payoff is precise recall that does not bloat the prompt.
|
||||
|
||||
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 |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| 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"` |
|
||||
| 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.
|
||||
|
||||
## The Instructions Payload
|
||||
|
||||
The MCP server ships a usage guide as part of the protocol handshake. It is a plain text document sent once when an agent connects, and it becomes part of that agent's system context for the rest of the session. Without this payload the agent would see the tool list and have no idea what conventions to follow. It would invent a new "Memory" project name every session, tag things inconsistently, create duplicate labels, and generally make the store unusable over time.
|
||||
|
||||
The instructions teach the agent:
|
||||
|
||||
| Section | What the agent learns |
|
||||
| ----------------------- | --------------------------------------------------------------------------- |
|
||||
| **Vocabulary** | What a project, task, label, view, bucket, and filter mean in this context |
|
||||
| **Memory conventions** | The Memory project, tasks as memory entries, namespaced label format |
|
||||
| **Per repo conventions**| Project per repo, standard kanban buckets, standard label vocabulary |
|
||||
| **Filter syntax** | Operators, field names, quoted strings, real query examples |
|
||||
| **Lookup flows** | How to resolve a username to a user id, how to assign a task |
|
||||
| **Defaults** | Priority scale, done semantics, sensible `per_page` values |
|
||||
| **Safety rules** | Confirm before destructive calls, verify ids before acting on ambiguous ones |
|
||||
|
||||
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.
|
||||
|
||||
## Setup
|
||||
|
||||
Clone the repository and install dependencies:
|
||||
|
||||
```
|
||||
git clone https://github.com/acidvegas/pyvikunja
|
||||
cd pyvikunja/mcp
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --------------- | -------- | ------------------------------------------- |
|
||||
| `VIKUNJA_URL` | yes | Base URL of your Vikunja server |
|
||||
| `VIKUNJA_TOKEN` | yes | Personal API token *(`tk_...`)* |
|
||||
|
||||
Generate the token in the Vikunja web UI under Settings, API Tokens. Verify connectivity with the stdlib test from the repo root:
|
||||
|
||||
```
|
||||
python3 test.py
|
||||
```
|
||||
|
||||
You should see `OK` for `/info`, `/user`, `/projects`, and `/tasks`.
|
||||
|
||||
## Client Configuration
|
||||
|
||||
The MCP server is launched by the client, not as a standalone daemon. Each client has its own config where you register MCP servers by command line.
|
||||
|
||||
**Claude Code** *(`~/.config/claude-code/mcp.json`)*:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vikunja": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/to/pyvikunja/mcp/server.py"],
|
||||
"env": {
|
||||
"VIKUNJA_URL": "http://localhost:3456",
|
||||
"VIKUNJA_TOKEN": "tk_your_token_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** *(`~/.config/Claude/claude_desktop_config.json` on Linux, equivalent under `~/Library/Application Support/Claude/` on macOS)*:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vikunja": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/to/pyvikunja/mcp/server.py"],
|
||||
"env": {
|
||||
"VIKUNJA_URL": "http://localhost:3456",
|
||||
"VIKUNJA_TOKEN": "tk_your_token_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Local LLM via [Continue](https://continue.dev)** *(VS Code or JetBrains extension with Ollama, LM Studio, llama.cpp, or any OpenAI compatible backend)*. Add to Continue's `config.yaml`:
|
||||
```yaml
|
||||
experimental:
|
||||
mcpServers:
|
||||
- name: vikunja
|
||||
command: python3
|
||||
args:
|
||||
- /absolute/path/to/pyvikunja/mcp/server.py
|
||||
env:
|
||||
VIKUNJA_URL: http://localhost:3456
|
||||
VIKUNJA_TOKEN: tk_your_token_here
|
||||
```
|
||||
|
||||
Any stdio capable MCP client *(Zed, Cursor, LM Studio, generic MCP runners)* uses the same `command` + `args` + `env` shape. Restart the client after editing its config.
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
All three patterns use the exact same MCP server. The difference is which agent is on the other end of the connection.
|
||||
|
||||
**Claude Code direct.** Register the MCP in Claude Code. When you chat, Claude has the Vikunja tools and calls them whenever the conversation needs memory or project state. Good for deep reasoning, writing code, architecture, anything where you want frontier model quality and also want the agent to have access to persistent memory.
|
||||
|
||||
**Local LLM direct.** Register the same MCP in a local LLM client. Ask your local model to do routine memory work *(save this note, list today's tasks, move a card, tag a decision)*. Zero paid tokens, everything runs on your own hardware.
|
||||
|
||||
**Hybrid.** Register it in both. Use Claude Code for work that needs a frontier model, use the local LLM for mechanical memory ops. The two agents see the same memories because they both write to the same Vikunja. Facts saved by the local LLM on Monday are findable by Claude Code on Friday. This is where the serious token savings come from: anything routine runs for free, and when Claude Code does run it only touches the memory it actually needs.
|
||||
|
||||
## Token Savings
|
||||
|
||||
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.
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
###### Mirrors: [SuperNETs](https://git.supernets.org/acidvegas/) • [GitHub](https://github.com/acidvegas/) • [GitLab](https://gitlab.com/acidvegas/) • [Codeberg](https://codeberg.org/acidvegas/)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
aiohttp>=3.9
|
||||
mcp>=1.0
|
||||
python-dotenv>=1.0
|
||||
511
server.py
Normal file
511
server.py
Normal file
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
|
||||
# vikunja/mcp/server.py
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
raise ImportError('missing aiohttp library (pip install aiohttp)')
|
||||
|
||||
try:
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import TextContent, Tool
|
||||
except ImportError:
|
||||
raise ImportError('missing mcp library (pip install mcp)')
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
raise ImportError('missing python-dotenv library (pip install python-dotenv)')
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_URL = os.getenv('VIKUNJA_URL', '').rstrip('/') + '/api/v1'
|
||||
TOKEN = os.getenv('VIKUNJA_TOKEN', '')
|
||||
SPEC_URL = BASE_URL + '/docs.json'
|
||||
HEADERS = {'Authorization': f'Bearer {TOKEN}', 'Accept': 'application/json'}
|
||||
|
||||
|
||||
# Curated allowlist of endpoints the MCP exposes. Anything not in this set is
|
||||
# dropped at build time. Keeps the tool surface small, predictable, and safe.
|
||||
ALLOWLIST = frozenset({
|
||||
# server / user
|
||||
('GET', '/info'),
|
||||
('GET', '/user'),
|
||||
('GET', '/users'),
|
||||
# projects
|
||||
('GET', '/projects'),
|
||||
('PUT', '/projects'),
|
||||
('GET', '/projects/{id}'),
|
||||
('POST', '/projects/{id}'),
|
||||
('DELETE', '/projects/{id}'),
|
||||
('PUT', '/projects/{projectID}/duplicate'),
|
||||
('GET', '/projects/{id}/projectusers'),
|
||||
# views
|
||||
('GET', '/projects/{project}/views'),
|
||||
('PUT', '/projects/{project}/views'),
|
||||
('GET', '/projects/{project}/views/{id}'),
|
||||
('POST', '/projects/{project}/views/{id}'),
|
||||
('DELETE', '/projects/{project}/views/{id}'),
|
||||
# buckets
|
||||
('GET', '/projects/{id}/views/{view}/buckets'),
|
||||
('PUT', '/projects/{id}/views/{view}/buckets'),
|
||||
('POST', '/projects/{projectID}/views/{view}/buckets/{bucketID}'),
|
||||
('DELETE', '/projects/{projectID}/views/{view}/buckets/{bucketID}'),
|
||||
# tasks
|
||||
('GET', '/tasks'),
|
||||
('GET', '/tasks/{id}'),
|
||||
('POST', '/tasks/{id}'),
|
||||
('DELETE', '/tasks/{id}'),
|
||||
('PUT', '/projects/{id}/tasks'),
|
||||
('POST', '/tasks/bulk'),
|
||||
('POST', '/tasks/{id}/position'),
|
||||
('POST', '/tasks/{projecttask}/read'),
|
||||
('GET', '/projects/{id}/views/{view}/tasks'),
|
||||
('POST', '/projects/{project}/views/{view}/buckets/{bucket}/tasks'),
|
||||
# task relations
|
||||
('PUT', '/tasks/{taskID}/relations'),
|
||||
('DELETE', '/tasks/{taskID}/relations/{relationKind}/{otherTaskID}'),
|
||||
# assignees
|
||||
('GET', '/tasks/{taskID}/assignees'),
|
||||
('PUT', '/tasks/{taskID}/assignees'),
|
||||
('POST', '/tasks/{taskID}/assignees/bulk'),
|
||||
('DELETE', '/tasks/{taskID}/assignees/{userID}'),
|
||||
# labels
|
||||
('GET', '/labels'),
|
||||
('PUT', '/labels'),
|
||||
('GET', '/labels/{id}'),
|
||||
('POST', '/labels/{id}'),
|
||||
('DELETE', '/labels/{id}'),
|
||||
('GET', '/tasks/{task}/labels'),
|
||||
('PUT', '/tasks/{task}/labels'),
|
||||
('DELETE', '/tasks/{task}/labels/{label}'),
|
||||
('POST', '/tasks/{taskID}/labels/bulk'),
|
||||
# comments
|
||||
('GET', '/tasks/{taskID}/comments'),
|
||||
('GET', '/tasks/{taskID}/comments/{commentID}'),
|
||||
('PUT', '/tasks/{taskID}/comments'),
|
||||
('POST', '/tasks/{taskID}/comments/{commentID}'),
|
||||
('DELETE', '/tasks/{taskID}/comments/{commentID}'),
|
||||
# attachments
|
||||
('GET', '/tasks/{id}/attachments'),
|
||||
('GET', '/tasks/{id}/attachments/{attachmentID}'),
|
||||
('PUT', '/tasks/{id}/attachments'),
|
||||
('DELETE', '/tasks/{id}/attachments/{attachmentID}'),
|
||||
# reactions
|
||||
('GET', '/{kind}/{id}/reactions'),
|
||||
('PUT', '/{kind}/{id}/reactions'),
|
||||
('POST', '/{kind}/{id}/reactions/delete'),
|
||||
# filters
|
||||
('PUT', '/filters'),
|
||||
('GET', '/filters/{id}'),
|
||||
('POST', '/filters/{id}'),
|
||||
('DELETE', '/filters/{id}'),
|
||||
# teams
|
||||
('GET', '/teams'),
|
||||
('PUT', '/teams'),
|
||||
('GET', '/teams/{id}'),
|
||||
('POST', '/teams/{id}'),
|
||||
('DELETE', '/teams/{id}'),
|
||||
('PUT', '/teams/{id}/members'),
|
||||
('POST', '/teams/{id}/members/{userID}/admin'),
|
||||
('DELETE', '/teams/{id}/members/{username}'),
|
||||
# sharing
|
||||
('GET', '/projects/{id}/users'),
|
||||
('PUT', '/projects/{id}/users'),
|
||||
('POST', '/projects/{projectID}/users/{userID}'),
|
||||
('DELETE', '/projects/{projectID}/users/{userID}'),
|
||||
('GET', '/projects/{id}/teams'),
|
||||
('PUT', '/projects/{id}/teams'),
|
||||
('POST', '/projects/{projectID}/teams/{teamID}'),
|
||||
('DELETE', '/projects/{projectID}/teams/{teamID}'),
|
||||
# link shares
|
||||
('GET', '/projects/{project}/shares'),
|
||||
('GET', '/projects/{project}/shares/{share}'),
|
||||
('PUT', '/projects/{project}/shares'),
|
||||
('DELETE', '/projects/{project}/shares/{share}'),
|
||||
# subscriptions / notifications
|
||||
('PUT', '/subscriptions/{entity}/{entityID}'),
|
||||
('DELETE', '/subscriptions/{entity}/{entityID}'),
|
||||
('GET', '/notifications'),
|
||||
('POST', '/notifications'),
|
||||
('POST', '/notifications/{id}'),
|
||||
# webhooks
|
||||
('GET', '/projects/{id}/webhooks'),
|
||||
('PUT', '/projects/{id}/webhooks'),
|
||||
('POST', '/projects/{id}/webhooks/{webhookID}'),
|
||||
('DELETE', '/projects/{id}/webhooks/{webhookID}'),
|
||||
('GET', '/webhooks/events'),
|
||||
})
|
||||
|
||||
|
||||
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.
|
||||
'''
|
||||
|
||||
|
||||
async def build_tools(spec: dict) -> tuple:
|
||||
'''Walk an OpenAPI 2 spec and build an MCP tool list and lookup table.
|
||||
|
||||
Applies the ALLOWLIST filter and patches known Vikunja spec bugs before
|
||||
generating tools.
|
||||
|
||||
:param spec: Parsed OpenAPI document
|
||||
'''
|
||||
|
||||
patch_spec(spec)
|
||||
|
||||
tools = []
|
||||
index = {}
|
||||
|
||||
for path, methods in spec.get('paths', {}).items():
|
||||
for method, op in methods.items():
|
||||
if method not in ('get', 'post', 'put', 'delete', 'patch'):
|
||||
continue
|
||||
|
||||
if (method.upper(), path) not in ALLOWLIST:
|
||||
continue
|
||||
|
||||
name = sanitize_name(op.get('operationId') or f'{method}_{path}')
|
||||
desc = (op.get('summary') or op.get('description') or f'{method.upper()} {path}').strip()[:1024]
|
||||
schema, required = {}, []
|
||||
|
||||
for param in op.get('parameters', []) or []:
|
||||
pname = param['name']
|
||||
loc = param.get('in')
|
||||
|
||||
if loc == 'body':
|
||||
schema[pname] = {'type': 'object', 'description': f'Request body for {method.upper()} {path}'}
|
||||
else:
|
||||
schema[pname] = {'type': openapi_to_json(param.get('type', 'string')), 'description': (param.get('description') or f'{loc} parameter').strip()}
|
||||
|
||||
if param.get('required'):
|
||||
required.append(pname)
|
||||
|
||||
tools.append(Tool(name=name, description=desc, inputSchema={'type': 'object', 'properties': schema, 'required': required}))
|
||||
index[name] = {'method': method.upper(), 'path': path, 'params': op.get('parameters', []) or []}
|
||||
|
||||
return tools, index
|
||||
|
||||
|
||||
async def call_endpoint(session, spec_op: dict, args: dict) -> str:
|
||||
'''Execute a Vikunja API call for a single MCP tool invocation.
|
||||
|
||||
:param session: Shared aiohttp client session
|
||||
:param spec_op: Operation descriptor from the index returned by build_tools
|
||||
:param args: Arguments supplied by the MCP client
|
||||
'''
|
||||
|
||||
path = spec_op['path']
|
||||
query = {}
|
||||
body = None
|
||||
|
||||
for param in spec_op['params']:
|
||||
pname = param['name']
|
||||
if pname not in args or args[pname] is None:
|
||||
continue
|
||||
|
||||
loc = param.get('in')
|
||||
|
||||
if loc == 'path':
|
||||
path = path.replace('{' + pname + '}', str(args[pname]))
|
||||
elif loc == 'query':
|
||||
query[pname] = args[pname]
|
||||
elif loc == 'body':
|
||||
body = args[pname]
|
||||
|
||||
async with session.request(spec_op['method'], BASE_URL + path, params=query or None, json=body, headers=HEADERS) as resp:
|
||||
text = await resp.text()
|
||||
|
||||
return f'HTTP {resp.status}\n{text}'
|
||||
|
||||
|
||||
async def load_spec() -> dict:
|
||||
'''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)
|
||||
|
||||
|
||||
async def main():
|
||||
'''Start the Vikunja MCP server over stdio.'''
|
||||
|
||||
spec = await load_spec()
|
||||
tools, index = await build_tools(spec)
|
||||
server = Server('vikunja', instructions=INSTRUCTIONS)
|
||||
session = aiohttp.ClientSession()
|
||||
|
||||
@server.list_tools()
|
||||
async def _list_tools():
|
||||
return tools
|
||||
|
||||
@server.call_tool()
|
||||
async def _call_tool(name: str, arguments: dict):
|
||||
op = index.get(name)
|
||||
|
||||
if op is None:
|
||||
return [TextContent(type='text', text=f'unknown tool: {name}')]
|
||||
|
||||
return [TextContent(type='text', text=await call_endpoint(session, op, arguments or {}))]
|
||||
|
||||
try:
|
||||
async with stdio_server() as (read, write):
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def openapi_to_json(t: str) -> str:
|
||||
'''Map an OpenAPI 2 primitive type to a JSON Schema primitive type.
|
||||
|
||||
:param t: OpenAPI type name *(string, integer, number, boolean, array, file)*
|
||||
'''
|
||||
|
||||
return {'integer': 'integer', 'number': 'number', 'boolean': 'boolean', 'array': 'array', 'file': 'string'}.get(t, 'string')
|
||||
|
||||
|
||||
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.
|
||||
|
||||
: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')
|
||||
|
||||
|
||||
def sanitize_name(raw: str) -> str:
|
||||
'''Sanitize an OpenAPI operationId or generated name into an MCP tool name.
|
||||
|
||||
:param raw: Candidate tool name
|
||||
'''
|
||||
|
||||
name = re.sub(r'[^a-zA-Z0-9_-]+', '_', raw).strip('_')[:64]
|
||||
|
||||
return name or 'op'
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user