SkillAgentSearch skills...

vikunja-mcp

FastMCP server exposing the Vikunja REST API as scoped, per-agent MCP tools

Install / Use

claude mcp add TadMSTR -- npx -y github:TadMSTR/vikunja-mcp

If the server publishes to npm under a different name, use that package instead — check the repo README.

About this skill
🔌

MCP Server

Model Context Protocol server

Quality Score

80/100

Supported Platforms

Claude Code
Claude Desktop

Our assessment of vikunja-mcp

vikunja-mcp scores 80/100 on our quality scale, 260th of 1,283 Development & Engineering skills we index (top 21%).

Its MCP Server is 25 KB long, well organised into 28 sections with 12 code examples: a thorough specification that gives an agent plenty to work with.

It has 3 GitHub stars, so there is little community track record yet; judge it on its content.

Substance
30/30
Structure
20/20
Description
12/15
Adoption
3/20
Freshness
15/15

Maintenance, license and trust

  • The repository was last updated yesterday, so vikunja-mcp is actively maintained.
  • It is released under the MIT license, a permissive license that allows use, modification and commercial use with attribution.
  • Its trust signals score 87/100, with 2 cautions from licensing, adoption, age or documentation. These come from repository metadata, not a code audit — read the skill file before letting an agent act on it.

vikunja-mcp compared with similar skills

All 4 of these similar skills score higher than vikunja-mcp; compare them before choosing.

SkillScoreStarsUpdatedFormat
vikunja-mcp (this skill)by TadMSTR8031d agoMCP Server
Agent-Reachby Panniantong10085.0k8d agoCLAUDE.md
headroomby headroomlabs-ai10073.6ktodayCLAUDE.md
rufloby ruvnet10073.1ktodayCLAUDE.md
career-opsby career-ops-hq10072.5ktodayCLAUDE.md

Frequently asked questions

How do I install vikunja-mcp?
Run claude mcp add TadMSTR -- npx -y github:TadMSTR/vikunja-mcp. The install tabs above show the steps for each supported agent.
Which AI agents does vikunja-mcp work with?
It is written for Claude Code and Claude Desktop, as a MCP Server file. Other agents that read the same format can often use it too.
Is vikunja-mcp safe to use?
It is MIT-licensed and scores 87/100 on trust signals. Skills are instructions an agent will follow, so read the file before installing it and do not approve commands you do not understand.
Is vikunja-mcp still maintained?
The repository was last updated yesterday, so vikunja-mcp is actively maintained.

Built with Claude Code License: MIT

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| S

Because 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.

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 | whoami | | Projects | project_list, project_get, project_create, project_update, project_delete | | Project sharing | project_team_list, project_team_add, project_team_update, project_team_remove, project_user_list, project_user_add, project_user_update, project_user_remove, project_share_list, project_share_get, project_share_create, project_share_delete | | Tasks | task_list, task_search, task_get, task_create, task_update, task_delete, tasks_bulk_update | | Assignees | task_assignee_list, task_assignee_add, task_assignee_remove, task_assignees_add_bulk | | Relations / reminders | task_relation_add, task_relation_remove, task_reminders_set | | Backlinks | task_link_commit | | Kanban buckets / views | bucket_list, bucket_create, bucket_update, bucket_delete, task_bucket_move, view_list, view_get, view_create, view_update, view_delete | | Labels | label_list, label_get, label_create, label_update, label_delete, task_label_add, task_label_remove | | Comments | comment_list, comment_create, comment_delete | | Filters | filter_get, filter_create, filter_update, filter_delete | | Attachments | attachment_list, attachment_upload, attachment_delete | | Teams | team_list, team_get, team_create, team_update, team_delete, team_member_add, team_member_remove, team_member_toggle_admin | | Webhooks | webhook_events, webhook_list, webhook_create, webhook_delete |

Vikunja v2's REST idiom: POST creates, PUT replaces, PATCH merges. The tool names hide this, but it's why *_create and *_update hit 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 no GET /filters; saved filters are exposed as pseudo-projects, so list them via project_list and fetch with filter_get.
  • Project sharing permission ints: 0 = read, 1 = write, 2 = admin.
  • Attachments upload base64 (multipart on the wire); attachment_upload handles 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_id also accepts a ticket reference. It is resolved server-side with one filtered lookup:

    | You pass | Meaning | Lookup? | |---|---|---| | 473 or "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 from id. Constructing /tasks/454 from 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 include index. Verified against /api/v2 on 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.py carries 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 the projects → tasks_by_index permission, 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 #N refs 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 | |---|---|---|---| | task_list / task_search | id, identifier, title, done, project_id, priority, due_date, updated, url, labels, *_count | description (132 KB of the 182 KB), related tasks, attachments, reactions, assignee bodies | 182 KB → ~13 KB | | task_get | everything, including description | related-task bodies (reduced to {id, identifier, title, done}), attachments, reactions | 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) → return

Register 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 `d

Truncated for display — read the full file on GitHub.

Related Skills

View on GitHub
GitHub Stars3
CategoryDevelopment
Updated1d ago
Forks0

Languages

Python

Trust signals

87/100

From repository metadata: license, adoption, age and documentation. Not a code audit — see the Safety scan above for what the skill file itself contains.

2 low