vikunja-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@vikunja-mcplist my projects"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
vikunja-mcp
A FastMCP server that exposes the Vikunja REST API as MCP tools — projects, tasks, labels, comments, saved filters, and webhooks — designed for multi-agent use behind scoped-mcp.
Targets Vikunja's /api/v2, so it requires Vikunja 2.4.0 or newer. v1 is frozen
upstream at 2.4.0 (new routes land on v2 only) and removed at 4.0.
Why it's shaped this way — token passthrough
Run over HTTP, this server holds no Vikunja credentials. Vikunja issues a per-user API token, and each agent has its own account. Rather than teaching this server to fetch five tokens from Vault and pick one per call, it stays stateless: it reads the caller's bearer token off the incoming request and forwards it to Vikunja unchanged.
The token is injected upstream by each agent's own scoped-mcp instance (from its manifest, resolved out of Vault). The payoff:
Small blast radius — a compromise of this process exposes one in-flight request's token, never the whole set of agent credentials.
Real attribution — every call reaches Vikunja as the agent that made it, so task authorship, comments, and audit trails are per-agent for free.
flowchart LR
A[Agent] -->|MCP + own bearer token| S[scoped-mcp<br/>per-agent process]
S -->|mcp_proxy injects<br/>Authorization header| V[vikunja-mcp<br/>:8501 stateless]
V -->|forwards token verbatim| K[(Vikunja REST API<br/>/api/v2)]
W[Vault] -.->|per-agent token<br/>resolved into manifest| SBecause the token is the credential, a request with no Authorization header is rejected
fail-closed (AuthError) — there is no ambient fallback.
Single-user stdio
Passthrough needs a request to pass a token through, so it cannot work over stdio — there is no HTTP request and therefore no header. If you are running this the ordinary MCP way (one subprocess, one user, launched by your client), set both:
VIKUNJA_TRANSPORT=stdio
VIKUNJA_TOKEN=<your Vikunja API token>Neither half is optional. stdio without a token refuses to start, rather than starting
cleanly and failing every tool call the way it used to. And VIKUNJA_TOKEN with a network
transport also refuses to start: a shared static token on a port makes every caller
reach Vikunja as one identity, which silently destroys the per-agent attribution above. See
SECURITY.md for the full rule.
Related MCP server: vikunja-mcp
Tools
The server covers the full Vikunja resource surface, each tool pinned to the correct verb
by a wire test and by a sweep against the live router — 71 as of v0.2.0, plus
backlog_summary (v0.7.0) and task_link_commit (v0.8.0) for 73.
Group | Tools |
Identity |
|
Projects |
|
Project sharing |
|
Tasks |
|
Assignees |
|
Relations / reminders |
|
Backlinks |
|
Kanban buckets / views |
|
Labels |
|
Comments |
|
Filters |
|
Attachments |
|
Teams |
|
Webhooks |
|
Vikunja v2's REST idiom: POST creates, PUT replaces, PATCH merges. The tool names hide this, but it's why
*_createand*_updatehit the same path with different verbs. (v1 had these inverted — PUT created and POST updated. Nothing here targets v1.)
Notes:
No
filter_list— Vikunja has noGET /filters; saved filters are exposed as pseudo-projects, so list them viaproject_listand fetch withfilter_get.Project sharing permission ints:
0= read,1= write,2= admin.Attachments upload base64 (multipart on the wire);
attachment_uploadhandles the encoding.
Ticket numbers vs. task ids
Vikunja gives every task two numbers, and mixing them up silently edits the wrong
ticket. id is global and is what /tasks/{id} and every tool take. index is a counter
per project, and it is what the UI displays as #454.
They are not the same number and the difference is not a constant — across one real corpus the offset ran 8, then 11, then 19. That is the shape of gap that teaches you a rule which then quietly fails on an older ticket.
So, as of v0.5.0:
No tool returns a bare
index.identifier(the string"#454") is returned instead. Being a string, it cannot be passed where an int id is expected without an obvious type error.Every tool that takes a
task_idalso accepts a ticket reference. It is resolved server-side with one filtered lookup:You pass
Meaning
Lookup?
473or"473"global task id
no
"#454"ticket number
one call
"#456 (id 475)"ticket number with the id spelled out
no
Every projected read returns a
url, built fromid. Constructing/tasks/454from the ticket number lands on an unrelated task; this removes the opportunity.
A bare number is always a global id — "454" without the # is never read as a ticket
number. Guessing there is the whole bug. If a ticket reference is ambiguous (ticket numbers
are only unique within a project) the call raises and names every candidate rather than
picking one; set VIKUNJA_DEFAULT_PROJECT_ID to scope resolution and avoid it.
The third form is honoured only on a string that opens with a ticket reference and names
exactly one id N. Prose that merely mentions an id ("see id 999 somewhere") is refused,
and a string naming several ("#456 (id 475) blocks #331 (id 342)") raises rather than
taking the first — position is not evidence.
Resolution uses
filter=index = N, which works but is not documented by Vikunja — its published filter-field list does not includeindex. Verified against/api/v2on Vikunja v2.5.0 with a negative control (bogusfield = 1→ 400, so unknown fields are rejected rather than ignored). If an upgrade removes it, resolution fails loudly with a message naming this caveat; it never falls back to treating"#454"as id 454.tests/test_task_refs.pycarries an opt-in live canary for exactly this.Why not v2's documented route? v2 ships
GET /projects/{project}/tasks/by-index/ {index}, which is this lookup, documented — and it returns 401 for an API token that does not carry theprojects → tasks_by_indexpermission, which no token created before v2 does. Since this server forwards your token rather than holding one of its own, adopting the route would break#Nrefs for every existing deployment until each token was re-issued. If your token does carry that permission, the switch is a one-line change tracked upstream in the repo's issue for it.
Not resolved: tasks_bulk_update's task_ids, and other_task_id on the relation tools.
Both still take plain ints, so a "#454" there is refused at schema validation.
Response size — compact by default
task_get, task_list and task_search return projected bodies. Vikunja inlines the
full body of every related task, so a single well-linked ticket can return 155,000
characters and one 50-row task_list measured 182 KB — roughly 45k tokens for one
call.
The expensive field differs by tool, so the projection does too:
Tool | Kept | Dropped | Measured |
| id, identifier, title, done, project_id, priority, due_date, updated, url, labels, |
| 182 KB → ~13 KB |
| everything, including | related-task bodies (reduced to | 9.5 KB → ~4.6 KB |
Dropped collections become counts (attachment_count, reaction_count,
assignee_count), so their existence stays discoverable.
Pass verbose=true on any of the three to get the upstream body back untouched. Note the
index strip still applies in verbose mode — verbose restores the payload, not the
ambiguity — and the convenience url field is only added on the projected path.
pagination is never projected: a truncated list still reports
{"truncated": true, "total_pages": N, "total": M, "count": K} so one page is not mistaken
for a whole answer. total is the size of the whole result set and count is the rows in
this response — v1 could not report the former at all, and v2 does.
Markdown descriptions & comments
description on task_create/task_update/project_create/project_update, and the
comment body on comment_create, accept plain markdown. Vikunja itself stores these
fields as HTML (TipTap rich text), so this server converts markdown to HTML before writing,
then sanitizes the result with an allowlist HTML cleaner (nh3.clean()) — raw HTML embedded
in agent-authored markdown (e.g. a stray <script> tag) is stripped, not passed through. No
caller-side conversion is needed; just write normal markdown.
Vikunja v2 can do the conversion itself (?format=markdown), and this server deliberately
does not use it. Conversion and sanitization are not separable: nh3.clean() can only run
on HTML this process produced, so delegating the conversion would not move the sanitizer —
it would remove it from the path. Sending format=markdown on a write is also lossy by
upstream's own account (Markdown cannot express every HTML construct, so the field is
stored as its degraded conversion), which makes it a poor fit for a server that writes
fields humans later edit.
Extension hooks
Every tool is wrapped by server.instrument, which fires a pre/post hook chain around
each call — third parties can intercept or mutate calls without editing the server:
call → run_before_hooks(tool, kwargs) → [telemetry span] → tool(**kwargs)
→ run_after_hooks(tool, result) → returnRegister handlers with register_before(tool, handler) / register_after(tool, handler)
(hooks.py). Handlers run in registration order and propagate exceptions — they are not
fire-and-forget. contrib/audit_log.py is a worked example that records
actor/tool/args-hash without ever logging raw arguments or the bearer token. Full contract
and handler signatures: docs/extension-hooks.md.
Signals for agents
Three features exist because an agent decides what to do from whatever the tracker hands back at that moment. A rule in a prompt file erodes under context pressure; a field in the response does not.
Staleness
Every task read carries days_since_update and stale alongside the raw updated, on
task_get, task_list and task_search, in verbose mode too.
Read them honestly. updated moves on any change, a label edit included, so
stale: false means "recently touched", not "the text is still true". The useful
direction is the other one: stale: true means nobody has looked at this in months, so
treat its description as a claim about the past. Both fields are null — never false —
when the age is unknown, because "not known to be stale" and "known to be fresh" are
different claims.
The limitation at its sharpest: a bulk import rewrites updated on every task at once.
Forge's tracker was migrated on 2026-07-19, so a month later its oldest open ticket read as
33 days old, including tickets whose text predated the import by months. Nothing was fresh;
every timestamp was. Set VIKUNJA_STALE_AFTER_DAYS accordingly after an import.
backlog_summary
Counts, not rows — totals, done/open, and breakdowns by priority, label and staleness.
Each bucket is one request that reads the match count off the response envelope's total
and asks for a page holding no rows at all, so orienting in a backlog stops costing a
pagination sweep. The calls field reports what it cost, so the price is visible to
whoever pays it.
As of v0.10.0, max_label_buckets defaults to every label in scope, not a fixed 25.
Re-measured against forge's 514-task, 67-label tracker: 78 requests at the new default,
against 37 at the old fixed cap. A fixed default was already wrong at 67 labels — any fixed
default is a rot clock as a tracker's label vocabulary grows — so truncation is now something
you opt into with max_label_buckets, not something you have to know the current label count
to avoid. A hard ceiling of 200 buckets bounds the fan-out regardless, and reports itself in
notes when it bites. The label listing itself also now paginates to completion
(_LABEL_PAGE_SIZE = 100, up to _LABEL_PAGE_LIMIT = 20 pages) rather than reading page one
and stopping, so a tracker past 50 labels no longer undercounts before max_label_buckets is
even applied.
Two response fields distinguish why a bucket is missing — a caller checking a count needs to know which case it is:
labels_truncatedis measured against the labels that exist, not the ones that were fetched, andnotesnames which cause applies. They call for different fixes: raisingmax_label_bucketsfixes a cap that bit and does nothing for a listing that fell short.labels_not_counted(new in v0.10.0) names, by title, every bucket that was skipped. A skipped bucket is absent fromby_label— never reported as0. Reading an absent key as0via.get(name, 0)silently turns "not measured" into "nothing here"; checklabels_not_countedbefore trusting a zero.
by_label is keyed by label title, and titles are not unique — every label sharing a title
is one bucket, counted over all of their ids together. Forge's tracker carries source:github
as both id 1 and id 38; before v0.10.0 the reported count for that title silently depended on
where the cap happened to fall.
If your tracker has an "anchor" task carrying every label deliberately, name it in
VIKUNJA_SUMMARY_EXCLUDE_IDS — otherwise it lands in every label bucket and inflates each
count by exactly one, which is worse than an obviously broken number.
Breaking in v0.10.0:
max_label_buckets's default changed from a fixed25to every label in scope, andlabels_not_countedwas added to the response. Callers passing an explicitmax_label_bucketsare unaffected. The two consumer skills that call this tool (ticket-batch-select,ticket-triage-sweep) are already updated for it, inagent-platform-skills@2e6c122.
Idempotency keys
Pass idempotency_key to task_create and a retried create returns the ticket it already
made instead of filing a second one:
{ "id": 517, "title": "...", "idempotent_hit": true }The key is caller-supplied — never derived from the title, which would silently
collapse two legitimately-similar tickets into one. It is scoped to the project, and must
match [A-Za-z0-9][A-Za-z0-9._-]*: that charset is a security boundary, because the key is
interpolated into a Vikunja filter expression where a quote or a % would escape the scope
or silently over-match.
Known race, accepted: lookup-then-create is not atomic and Vikunja has no conditional
create, so two genuinely simultaneous creates with the same key can both file. The window
is small and the failure degrades to today's behaviour. If the lookup itself fails the task
is still created, with idempotency_degraded: true on the response — losing a filing to a
convenience feature is worse than failing to deduplicate, but silently claiming a guarantee
that did not hold is worse than both.
Commit and PR backlinks
task_link_commit(task_id, ref_type, ref_url) records what shipped for a ticket. Read them
back as linked_refs on task_get:
{ "id": 519, "linked_refs": [
{ "ref_type": "pr", "ref_url": "https://github.com/o/r/pull/16" },
{ "ref_type": "commit", "ref_url": "https://github.com/o/r/commit/138b02e8" } ] }Links accumulate — a second never displaces the first, and re-linking the same pair is a
no-op. ref_url must be https with a real hostname; javascript:, data: and plain
http are refused, because this is a link a human clicks years later. Nothing fetches it,
so this is stored-link injection rather than SSRF.
Deliberately not in scope: transitioning ticket state from VCS events. Closing a ticket because a PR merged guesses at intent, and guessing wrong closes work that is not done.
Both features store their metadata as a visible footer in the ticket description, under one shared convention — see docs/markers.md, which is worth reading before touching the parser.
Duplicate detection
On by default; set VIKUNJA_DUPLICATE_CHECK=0 to disable. On task_create, titles that
look like they already exist are attached to the response as possible_duplicates:
{ "id": 491, "title": "...", "possible_duplicates": [
{ "id": 490, "identifier": "#1", "title": "Containerize searxng-mcp deployment probe",
"done": false, "url": "https://vikunja.example/tasks/490", "matched_terms": 3 } ] }It reports, never refuses — a false positive that blocked a filing would lose the finding entirely — and it can never cost a filing: any failure of the search degrades to no warning, and the task is created regardless.
Default-on was decided by measurement, not preference. Run over all 470 titles in forge's tracker, 19 produced a warning (4.0%) and none errored; by inspection about twelve of those nineteen were genuine, including three exact-title pairs and one triplicate. So 96% of creates see nothing. Precision depends on how your tracker writes titles — if yours looks nothing like that, measure before trusting the number.
Matching is lexical and title-scoped: the most distinctive terms of the title must all
appear in a candidate's title. Descriptions are not searched, deliberately — on a corpus
where tickets quote each other, a description match usually means "discusses", not
"duplicates". This is narrower than task_search, which does match against descriptions —
a task_search hit on an existing ticket is not by itself evidence that ticket is a
duplicate.
Configuration
All configuration is environment variables. The only Vikunja credential that can be
configured here is the stdio-only VIKUNJA_TOKEN below; on any network transport the token
comes from the caller's Authorization header.
Var | Purpose | Default |
| Base URL of the Vikunja instance (no | required — no default; the server refuses to start if unset |
| Bind address |
|
| Bind port |
|
|
|
|
| Vikunja API token. Required with | unset |
| Upstream timeout (seconds) |
|
| Project a | unset |
| Log verbosity |
|
| Enable OTLP spans + metrics (needs | off |
| Enable the InfluxDB 3 metrics sink | off |
| InfluxDB 3 auth token |
|
| InfluxDB 3 target database |
|
| Enable the NATS metrics sink (e.g. | off |
| NATS subject for metric events |
|
| Wire | off |
| Directory audit lines are appended to, one | none |
| Age at which a task is reported |
|
| Comma-separated task ids excluded from every | none |
| Warn about probable duplicates on | on |
Run
pip install -e ".[dev]"
VIKUNJA_URL=https://vikunja.example.com vikunja-mcpThen, as a caller, present a Vikunja API token as a bearer:
curl -H "Authorization: Bearer <vikunja-token>" http://127.0.0.1:8501/mcp/...In production this header is set by scoped-mcp, not by hand — see
docs/forge.md for the manifest wiring.
Telemetry
Logging (structlog JSON) is on by default. Metrics and tracing are off by default
and enable per-backend when the relevant env var is set — install the extra with
pip install 'vikunja-mcp[telemetry]'. Every tool call records call count, error count, and
upstream latency, plus an OTLP span (tool.<name>). Sinks are best-effort and
fire-and-forget: a telemetry backend being down never breaks a tool call. Forge ships
influxdb:3-core, so the InfluxDB sink uses the v3 write API. See
docs/telemetry.md for the full backend matrix.
Enabling it — two steps, and the env var is not the one that matters
On forge this runs with OTLP on, exporting to the SigNoz collector. Enabling it takes both of the following; doing only the second is the common failure:
/opt/venvs/vikunja-mcp/bin/pip install 'vikunja-mcp[telemetry]' # 1. the extra
# 2. append to /opt/appdata/vikunja-mcp/env:
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
pm2 restart vikunja-mcpPort 4317 is gRPC — telemetry.py uses opentelemetry-exporter-otlp-proto-grpc, so it is
4317, not the 4318 HTTP port.
The acceptance check is the startup log line, never the presence of the env var:
pm2 logs vikunja-mcp --lines 50 --nostream | grep -E 'otlp_enabled|otlp_import_failed'otlp_enabled means it is working. otlp_import_failed means the endpoint is configured
but the extra was never installed — the process starts fine, logs one warning, then
silently emits nothing. Anyone reading /opt/appdata/*/env to see which services have
telemetry on gets the wrong answer in that state.
This is not hypothetical: nextcloud-mcp on forge has had the env var set and the extra
missing since at least 2026-07-26, emitting nothing the whole time (tracked as vikunja id
350 / #336). Confirm otlp_enabled, then confirm the service actually shows up in
SigNoz.
Development
pip install -e ".[dev]"
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/
pytest --cov=vikunja_mcp --cov-report=term-missing
# Optional: probe every implemented route against a live Vikunja router.
# Skips cleanly when the credentials are absent. Never run against a host you
# do not control — it sends one unroutable verb per endpoint.
VIKUNJA_URL=https://vikunja.example VIKUNJA_TOKEN=... python scripts/verify-routes.pyDeployment
Docker (recommended)
docker run -d --name vikunja-mcp \
-e VIKUNJA_URL=https://vikunja.example.com \
-p 127.0.0.1:8501:8501 \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp \
ghcr.io/tadmstr/vikunja-mcp:latest
curl -fsS http://127.0.0.1:8501/healthA reference docker-compose.yml sits at the repo root. Full env var
table, the security model, the audit-log mount and the webhook caveat are in
docs/docker.md.
There is no PyPI package. The name
vikunja-mcpis squatted on public PyPI by an unrelated project — do notpip install vikunja-mcp. Use the image, or install from a git checkout.
stdio
For a single-user setup launched by your MCP client, see
Single-user stdio above. You need VIKUNJA_TRANSPORT=stdio and
VIKUNJA_TOKEN.
forge
Runs as a PM2 service on forge, fronted by scoped-mcp. See docs/forge.md
for the PM2 config, the scoped-mcp manifest, and the per-agent token wiring.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
No tool schema history has been recorded yet.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
- mcpOAuthnet.todoist
Official Todoist MCP server for AI assistants to manage tasks, projects, and workflows.
MCP server for secureFlows: token-free URL builders and integration-linting tools for AI agents.
Model Context Protocol server for the Apideck Unified API. Connect any MCP-compatible agent framework to 100+ accounting systems, HRIS platforms, file storage providers, and more through one integration. More information https://www.apideck.com/mcp-server
Related MCP Servers
- AlicenseBqualityDmaintenanceConnect your AI assistant to Vikunja, the open-source task manager. This MCP server lets you manage projects, tasks, kanban boards, and more—just by asking.317015MIT
- AlicenseNot gradedqualityDmaintenanceLightweight MCP server for Vikunja task management, providing tools to manage projects, tasks, labels, and comments via API.58MIT
- AlicenseBqualityDmaintenanceFull-featured MCP server for Taiga project management, enabling AI agents to manage projects, epics, user stories, tasks, issues, sprints, wiki pages, memberships, and roles via Taiga API v1.100462MIT
- FlicenseAqualityBmaintenanceAn MCP server that exposes tasks in a Vikunja instance as typed tools for list, get, add, update, complete, and reopen operations, enabling natural language task management via Claude.7-
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/TadMSTR/vikunja-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server