Vikunja MCP Server / Gemini CLI Extension

Published: Oct 15, 2025 by Isaac Johnson

All this talk of MCP servers made me want to create my own with the goal of engaging with my own Vikunja instance. I saw that some had integrations with Github for Issues and JIRA for tickets - I wanted to do similar.

The goal will be to have a working MCP server running locally (or externally) that can search a Vikunja instance for tickets, add tickets and maybe more.

Let’s get started…

Vikunja MCP

My first idea is to just get going with FastMCP and see how far I can get:


import os
import requests
from fastmcp import FastMCP

# --- Configuration ---
VIKUNJA_URL = os.getenv("VIKUNJA_URL")
VIKUNJA_USERNAME = os.getenv("VIKUNJA_USERNAME")
VIKUNJA_PASSWORD = os.getenv("VIKUNJA_PASSWORD")

# --- MCP Application Setup ---
mcp = FastMCP()
session = requests.Session()

# --- Input Validation ---
if not all([VIKUNJA_URL, VIKUNJA_USERNAME, VIKUNJA_PASSWORD]):
    print("Error: Please set the VIKUNJA_URL, VIKUNJA_USERNAME, and VIKUNJA_PASSWORD environment variables.")
    exit(1)

@mcp.tool()
def login():
    """
    Authenticates with the Vikunja API to get a session token.
    """
    global session
    try:
        response = session.post(
            f"{VIKUNJA_URL}/api/v1/login",
            json={"username": VIKUNJA_USERNAME, "password": VIKUNJA_PASSWORD}
        )
        response.raise_for_status()
        token = response.json().get("token")
        if not token:
            return "Login failed: Token not found in response."
        
        session.headers.update({"Authorization": f"Bearer {token}"})
        return "Login successful. Token stored for session."
    except requests.exceptions.RequestException as e:
        return f"Login failed: {e}"

@mcp.tool()
def search_tasks(query: str):
    """
    Searches for tasks in Vikunja.
    
    :param query: The search string to use for finding tasks.
    """
    if "Authorization" not in session.headers:
        return "Please run the 'login' command first."
    
    try:
        response = session.get(f"{VIKUNJA_URL}/api/v1/tasks/search?query={query}")
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        return f"Error searching tasks: {e}"

@mcp.tool()
def add_task(project_id: int, title: str, description: str = ""):
    """
    Adds a new task to a Vikunja project.
    
    :param project_id: The ID of the project to add the task to.
    :param title: The title of the new task.
    :param description: An optional description for the task.
    """
    if "Authorization" not in session.headers:
        return "Please run the 'login' command first."
        
    task_payload = {
        "project_id": project_id,
        "title": title,
        "description": description
    }
    
    try:
        response = session.put(
            f"{VIKUNJA_URL}/api/v1/projects/{project_id}/tasks",
            json=task_payload
        )
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        return f"Error adding task: {e}"

if __name__ == "__main__":
    print("--- Vikunja MCP Client ---")
    print("Available commands: login, search_tasks, add_task, help, exit")
    mcp.run()

Trying to test with Python was a pain. I could fire it up local in a virutal environment (venv) after installing the local depenencies

$ cat requirements.txt
fastmcp
requests

However, in adding to Gemini CLI, I had issues:

builder@LuiGi:~/Workspaces/somethingnew$ cat .gemini/settings.json
{
  "mcpServers": {
    "vikunja": {
      "command": "python main.py",
      "args": [],
      "env": {
         "VIKUNJA_URL": "$VIKUNJA_URL",
         "VIKUNJA_USERNAME": "$VIKUNJA_USERNAME",
         "VIKUNJA_PASSWORD": "$VIKUNJA_PASSWORD"
      },
      "cwd": "/home/builder/Workspaces/gextn",
      "trust": true
    }
  }
}

As it then needs a proper Python3 virtual env and even then, needs libraries installed

I mean, it works locally, if I did all that already:

(.venv) builder@LuiGi:~/Workspaces/gextn$ python main.py
--- Vikunja MCP Client ---
Available commands: login, search_tasks, add_task, help, exit


╭────────────────────────────────────────────────────────────────────────────╮
│                                                                            │
│        _ __ ___  _____           __  __  _____________    ____    ____     │
│       _ __ ___ .'____/___ ______/ /_/  |/  / ____/ __ \  |___ \  / __ \    │
│      _ __ ___ / /_  / __ `/ ___/ __/ /|_/ / /   / /_/ /  ___/ / / / / /    │
│     _ __ ___ / __/ / /_/ (__  ) /_/ /  / / /___/ ____/  /  __/_/ /_/ /     │
│    _ __ ___ /_/    \____/____/\__/_/  /_/\____/_/      /_____(*)____/      │
│                                                                            │
│                                                                            │
│                                FastMCP  2.0                                │
│                                                                            │
│                                                                            │
│                 🖥️  Server name:     FastMCP-e19a                           │
│                 📦 Transport:       STDIO                                  │
│                                                                            │
│                 🏎️  FastMCP version: 2.12.4                                 │
│                 🤝 MCP SDK version: 1.16.0                                 │
│                                                                            │
│                 📚 Docs:            https://gofastmcp.com                  │
│                 🚀 Deploy:          https://fastmcp.cloud                  │
│                                                                            │
╰────────────────────────────────────────────────────────────────────────────╯


[10/06/25 18:14:48] INFO     Starting MCP server 'FastMCP-e19a' with transport 'stdio'        server.py:1502

But I really do not want to have to launch Python each time before firing up Gemini CLI or any other tool that is looking for this MCP server.

This prompted me to explore containerized MCP servers…

Docker

The next step was to look at Docker:

(.venv) builder@LuiGi:~/Workspaces/gextn$ cat ./Dockerfile
FROM python:3.13-slim

ARG VIKUNJA_URL
ARG VIKUNJA_USERNAME
ARG VIKUNJA_PASSWORD

ENV VIKUNJA_URL=$VIKUNJA_URL
ENV VIKUNJA_USERNAME=$VIKUNJA_USERNAME
ENV VIKUNJA_PASSWORD=$VIKUNJA_PASSWORD

WORKDIR /app

COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "main.py"]

I built it locally

(.venv) builder@LuiGi:~/Workspaces/gextn$ docker build -t vikunjamcp:0.1 .
[+] Building 32.5s (11/11) FINISHED                                                          docker:default
 => [internal] load build definition from Dockerfile                                                   0.1s
 => => transferring dockerfile: 369B                                                                   0.0s
 => [internal] load metadata for docker.io/library/python:3.13-slim                                    2.1s
 => [auth] library/python:pull token for registry-1.docker.io                                          0.0s
 => [internal] load .dockerignore                                                                      0.1s
 => => transferring context: 2B                                                                        0.0s
 => [internal] load build context                                                                      2.7s
 => => transferring context: 80.48MB                                                                   2.6s
 => [1/5] FROM docker.io/library/python:3.13-slim@sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673  5.4s
 => => resolve docker.io/library/python:3.13-slim@sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673  0.1s
 => => sha256:2be5d3cb08aa616c6e38d922bd7072975166b2de772004f79ee1bae59fe983dc 1.75kB / 1.75kB         0.0s
 => => sha256:7b444340715da1bb14bdb39c8557e0195455f5f281297723c693a51bc38a2c4a 5.44kB / 5.44kB         0.0s
 => => sha256:8c7716127147648c1751940b9709b6325f2256290d3201662eca2701cadb2cdf 29.78MB / 29.78MB       2.1s
 => => sha256:31fd2a94d72338ac6bbe103da6448d7e4cb7e7a29b9f56fa61d307b4395edf86 1.29MB / 1.29MB         0.8s
 => => sha256:66b685f2f76ba4e1e04b26b98a2aca385ea829c3b1ec637fbd82df8755973a60 11.74MB / 11.74MB       2.5s
 => => sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673173b8cda8f8dcacef689 10.37kB / 10.37kB       0.0s
 => => sha256:7d456e82f89bfe09aec396e93d830ba74fe0257fe2454506902adf46fb4377b3 250B / 250B             1.3s
 => => extracting sha256:8c7716127147648c1751940b9709b6325f2256290d3201662eca2701cadb2cdf              1.7s
 => => extracting sha256:31fd2a94d72338ac6bbe103da6448d7e4cb7e7a29b9f56fa61d307b4395edf86              0.2s
 => => extracting sha256:66b685f2f76ba4e1e04b26b98a2aca385ea829c3b1ec637fbd82df8755973a60              0.7s
 => => extracting sha256:7d456e82f89bfe09aec396e93d830ba74fe0257fe2454506902adf46fb4377b3              0.0s
 => [2/5] WORKDIR /app                                                                                 0.5s
 => [3/5] COPY requirements.txt requirements.txt                                                       0.2s
 => [4/5] RUN pip install --no-cache-dir -r requirements.txt                                          20.3s
 => [5/5] COPY . .                                                                                     2.4s
 => exporting to image                                                                                 1.1s
 => => exporting layers                                                                                1.0s
 => => writing image sha256:0d3b06f60cde237771c1f8ceda2be6ff6bac72cc881f39c5247a66a5562ef3fa           0.0s
 => => naming to docker.io/library/vikunjamcp:0.1                                                      0.0s

 2 warnings found (use docker --debug to expand):
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "VIKUNJA_PASSWORD") (line 5)
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "VIKUNJA_PASSWORD") (line 9)

I then added it to my local Gemini settings json

builder@LuiGi:~/Workspaces/somethingnew$ cat ~/.gemini/settings.json
{
  "security": {
    "auth": {
      "selectedType": "oauth-personal"
    }
  },
  "telemetry": {
    "enabled": false,
    "target": "local",
    "otlpEndpoint": "http://75.73.224.240:30921",
    "logPrompts": true
  },
  "ui": {
    "theme": "Shades Of Purple"
  }
}
builder@LuiGi:~/Workspaces/somethingnew$ cat .gemini/settings.json
{
  "mcpServers": {
    "vikunja": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "-e",
        "VIKUNJA_URL",
		"-e",
		"VIKUNJA_USERNAME",
		"-e",
		"VIKUNJA_PASSWORD",
        "vikunjamcp:0.1"
      ],
      "env": {
        "VIKUNJA_URL": "$VIKUNJA_URL",
        "VIKUNJA_USERNAME": "$VIKUNJA_USERNAME",
        "VIKUNJA_PASSWORD": "$VIKUNJA_PASSWORD"
      }
    }
  }
}

and now I can see the MCP server has started and is viewable

/content/images/2025/10/vikunjamcp-01.png

This made it clear I needed to set my environment variables before launching Gemini (or hardcode them in the settings.json of course)

/content/images/2025/10/vikunjamcp-02.png

I’ll try that then invoke Gemini

builder@LuiGi:~/Workspaces/somethingnew$ export VIKUNJA_URL="https://vikunja.steeped.space"
builder@LuiGi:~/Workspaces/somethingnew$ export VIKUNJA_USERNAME="idjohnson"
builder@LuiGi:~/Workspaces/somethingnew$ export VIKUNJA_PASSWORD="Not My Real Password"
builder@LuiGi:~/Workspaces/somethingnew$ gemini

I finally fixed the search.. one of the big issues it was leaving default page and not searching beyond the first 50

/content/images/2025/10/vikunjamcp-03.png

One of the Ways i figured this out was kicking a lot of debug to the docker logs then checking it when searches were not working

/content/images/2025/10/vikunjamcp-04.png

One of the next steps is to share this out. I’ll start with the LCD and send it up to Dockerhub

(.venv) builder@LuiGi:~/Workspaces/gextn$ docker tag vikunjamcp:0.7 idjohnson/vikunjamcp:0.7
(.venv) builder@LuiGi:~/Workspaces/gextn$ docker push idjohnson/vikunjamcp:0.7
The push refers to repository [docker.io/idjohnson/vikunjamcp]
23eb002dedfd: Pushed
169d81f94f75: Pushed
df515bf70ee9: Pushed
dff5eee7f8d0: Pushed
b5c1dcdb544b: Mounted from library/python
640b90da204d: Mounted from library/python
9377bf80783c: Mounted from library/python
1d46119d249f: Mounted from library/python
0.7: digest: sha256:48482634d5e599f82349e7010a2167876d77c4013040596c2b38f10ed7b64c48 size: 1996

Let’s push to my public registry hosted in Harbor in my Kubernetes

(.venv) builder@LuiGi:~/Workspaces/gextn$ docker tag vikunjamcp:0.7  harbor.freshbrewed.science/library/vikunjamcp:0.7
(.venv) builder@LuiGi:~/Workspaces/gextn$ docker push  harbor.freshbrewed.science/library/vikunjamcp:0.7
The push refers to repository [harbor.freshbrewed.science/library/vikunjamcp]
23eb002dedfd: Pushed
169d81f94f75: Pushed
df515bf70ee9: Pushed
dff5eee7f8d0: Pushed
b5c1dcdb544b: Pushed
640b90da204d: Pushed
9377bf80783c: Pushed
1d46119d249f: Pushed
0.7: digest: sha256:e325a5b21b6d429eb060b8b1459a87ecff6cdf971907723c72fb21ca327a0dcd size: 1996

I wanted, of course, to share the code at this point. I created and populated a new Forgejo repo at https://forgejo.freshbrewed.science/builderadmin/vikunjamcp

/content/images/2025/10/vikunjamcp-05.png

I can create a Pull Request for these latest changes.

A future step would be to add a proper CICD flow, but we will save that for next time.

I got some errors in the log when I tried to add a task

2025-10-07 23:57:02,233 DEBUG Dispatching request of type CallToolRequest
2025-10-07 23:57:02,286 DEBUG https://vikunja.steeped.space:443⁠ "POST /api/v1/projects/1/tasks HTTP/1.1" 404 24
2025-10-07 23:57:02,287 DEBUG Response sent
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"Error adding task: 404 Client Error: Not Found for url: https://vikunja.steeped.space/api/v1/projects/1/tasks"}],"isError":false}}⁠

Ended up that we used POST instead of PUT. Once corrected (tag 0.8) it works

/content/images/2025/10/vikunjamcp-06.png

(.venv) builder@LuiGi:~/Workspaces/gextn$ export MYTAG=0.8 && docker build -t vikunjamcp:$MYTAG . && docker tag vikunjamcp:$MYTAG harbor.freshbrewed.science/library/vikunjamcp:$MYTAG && docker push harbor.freshbrewed.science/library/vikunjamcp:$MYTAG && docker tag vikunjamcp:$MYTAG idjohnson/vikunjamcp:$MYTAG && docker push idjohnson/vikunjamcp:$MYTAG
[+] Building 1.0s (10/10) FINISHED                                                                                 docker:default
 => [internal] load build definition from Dockerfile                                                                         0.0s
 => => transferring dockerfile: 369B                                                                                         0.0s
 => [internal] load metadata for docker.io/library/python:3.13-slim                                                          0.3s
 => [internal] load .dockerignore                                                                                            0.0s
 => => transferring context: 2B                                                                                              0.0s
 => [internal] load build context                                                                                            0.4s
 => => transferring context: 641.71kB                                                                                        0.4s
 => [1/5] FROM docker.io/library/python:3.13-slim@sha256:52447c36201cb022aad32e33117746266ac8bd23705d33d999f6e57f1938428b    0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                0.0s
 => CACHED [3/5] COPY requirements.txt requirements.txt                                                                      0.0s
 => CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt                                                          0.0s
 => CACHED [5/5] COPY . .                                                                                                    0.0s
 => exporting to image                                                                                                       0.0s
 => => exporting layers                                                                                                      0.0s
 => => writing image sha256:13662104ce270e22471137bc84f4056dbd1038f4addefe3c8d05d00f4abab653                                 0.0s
 => => naming to docker.io/library/vikunjamcp:0.8                                                                            0.0s

 2 warnings found (use docker --debug to expand):
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "VIKUNJA_PASSWORD") (line 5)
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "VIKUNJA_PASSWORD") (line 9)
The push refers to repository [harbor.freshbrewed.science/library/vikunjamcp]
253add230c69: Pushed
4c12b2f50d34: Pushed
df515bf70ee9: Layer already exists
dff5eee7f8d0: Layer already exists
b5c1dcdb544b: Layer already exists
640b90da204d: Layer already exists
9377bf80783c: Layer already exists
1d46119d249f: Layer already exists
0.8: digest: sha256:3db5d0004da02bbd40648737b947028d3821c95c1de19cb1de3d118446ae282b size: 1996
The push refers to repository [docker.io/idjohnson/vikunjamcp]
253add230c69: Pushed
4c12b2f50d34: Pushed
df515bf70ee9: Layer already exists
dff5eee7f8d0: Layer already exists
b5c1dcdb544b: Layer already exists
640b90da204d: Layer already exists
9377bf80783c: Layer already exists
1d46119d249f: Layer already exists
0.8: digest: sha256:da54ffd8fad5b5baf8bf21faba3d728bcdd0b434a2b71674721e00b8ec8cffa2 size: 1996
(.venv) builder@LuiGi:~/Workspaces/gextn$

Each time I test this, I just update the image in my local settings:

builder@LuiGi:~/Workspaces/somethingnew$ cat .gemini/settings.json
{
  "mcpServers": {
    "vikunja": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "-e",
        "VIKUNJA_URL",
        "-e",
        "VIKUNJA_USERNAME",
        "-e",
        "VIKUNJA_PASSWORD",
        "vikunjamcp:0.8"
      ],
      "env": {
        "VIKUNJA_URL": "$VIKUNJA_URL",
        "VIKUNJA_USERNAME": "$VIKUNJA_USERNAME",
        "VIKUNJA_PASSWORD": "$VIKUNJA_PASSWORD"
      }
    }
  }
}

That works fine on the laptop I built this on. Let’s move to an entirely different machine…

New host

Let’s try adding this latest build on a different host.

I can just add it as a “mcpServers” block

$ cat ~/.gemini/settings.json
{
  "security": {
    "auth": {
      "selectedType": "oauth-personal"
    }
  },
  "telemetry": {
    "enabled": false,
    "target": "local",
    "otlpEndpoint": "https://otlp.nr-data.net:4318",
    "otlpProtocol": "http",
    "OLDERotlpEndpoint": "http://192.168.1.121:4317",
    "OLDotlpEndpoint": "http://192.168.1.33:30921",
    "otlpHeaders": {
      "api-key": "xxxxxxxxxxxxxxxxxxxxxxxxxxNRAL"
    },
    "logPrompts": true
  },
  "ui": {
    "theme": "Shades Of Purple"
  },
  "mcpServers": {
    "vikunja": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "-e",
        "VIKUNJA_URL",
        "-e",
        "VIKUNJA_USERNAME",
        "-e",
        "VIKUNJA_PASSWORD",
        "idjohnson/vikunjamcp:0.8"
      ],
      "env": {
        "VIKUNJA_URL": "$VIKUNJA_URL",
        "VIKUNJA_USERNAME": "$VIKUNJA_USERNAME",
        "VIKUNJA_PASSWORD": "$VIKUNJA_PASSWORD"
      }
    }
  }
}

The first time up took a moment to pull the image from Dockerhub

/content/images/2025/10/vikunjamcp-08.png

But it did come up

/content/images/2025/10/vikunjamcp-07.png

and I could see it in the configured servers:

/content/images/2025/10/vikunjamcp-09.png

In testing, it looked like it was using the old routine

I’ll try again and check the logs in docker this time

/content/images/2025/10/vikunjamcp-11.png

I’m going to take care of the PR first.

Actually, this shows how easy the flow is to rebuild and push the container then test it using the Dockerhub instance:

CICD

Setting up Forgejo Actions are quite easy. We merely need to enable them in the repo settings

/content/images/2025/10/vikunjamcp-12.png

And add a runner if you don’t have one (they are the same as Gitea runners)

/content/images/2025/10/vikunjamcp-13.png

I’ll need some secrets to login and push to harbor and Dockerhub

/content/images/2025/10/vikunjamcp-14.png

Once set

/content/images/2025/10/vikunjamcp-15.png

I’ll create a .gitea/workflows/cicd.yaml file:

name: CICD
run-name: $ triggered CICD
on: [push]

jobs:
  Explore-Gitea-Actions:
    runs-on: ubuntu-latest
    steps:
      - name: Build Dockerfile
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          docker build -t $BUILDIMGTAG .
          docker images
      - name: Tag and Push (Harbor)
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          export FINALBUILDTAG="`cat Dockerfile | tail -n1 | sed 's/^#//g'`"
          docker tag $BUILDIMGTAG $FINALBUILDTAG
          docker images
          echo $CR_PAT | docker login harbor.freshbrewed.science -u $CR_USER --password-stdin
          docker push $FINALBUILDTAG
        env: # Or as an environment variable
          CR_PAT: $
          CR_USER: $
      - name: Tag and Push (Dockerhub)
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          docker tag $BUILDIMGTAG $DHUSER/$BUILDIMGTAG
          docker images
          echo $DHPAT | docker login -u $DHUSER --password-stdin
          docker push $DHUSER/$BUILDIMGTAG
        env: # Or as an environment variable
          DHPAT: $
          DHUSER: $

And make sure to put the version as a comment in my Dockerfile:

FROM python:3.13-slim

ARG VIKUNJA_URL
ARG VIKUNJA_USERNAME
ARG VIKUNJA_PASSWORD

ENV VIKUNJA_URL=$VIKUNJA_URL
ENV VIKUNJA_USERNAME=$VIKUNJA_USERNAME
ENV VIKUNJA_PASSWORD=$VIKUNJA_PASSWORD

# Comment out for less verbose debug
ENV LOG_LEVEL=DEBUG
ENV DEBUG_TASK_MATCHES=true

WORKDIR /app

COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "main.py"]
#harbor.freshbrewed.science/library/vikunjamcp:0.10

I can push the updates and see the CICD build kick off

/content/images/2025/10/vikunjamcp-16.png

It took a while to tease out the proper Forgejo build file


name: CICD
run-name: $ triggered CICD
on: [push]

jobs:
  Explore-Gitea-Actions:
    runs-on: my_custom_label
    container: node:22
    steps:
      - uses: actions/checkout@v3 # Checks out your repository
      - name: Build Dockerfile
        run: |
          whoami
          which docker || true
          apt update
          cat /etc/os-release
          apt install -y ca-certificates curl gnupg
          mkdir -p /etc/apt/keyrings
          curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
          echo \
            "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
            focal stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
          apt update
          DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin          
      - name: Build Dockerfile
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          docker build -t $BUILDIMGTAG .
          docker images          
      - name: Tag and Push (Harbor)
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          export FINALBUILDTAG="`cat Dockerfile | tail -n1 | sed 's/^#//g'`"
          docker tag $BUILDIMGTAG $FINALBUILDTAG
          docker images
          echo $CR_PAT | docker login harbor.freshbrewed.science -u $CR_USER --password-stdin
          docker push $FINALBUILDTAG          
        env: # Or as an environment variable
          CR_PAT: $
          CR_USER: $
      - name: Tag and Push (Dockerhub)
        run: |
          export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
          docker tag $BUILDIMGTAG $DHUSER/$BUILDIMGTAG
          docker images
          echo $DHPAT | docker login -u $DHUSER --password-stdin
          docker push $DHUSER/$BUILDIMGTAG          
        env: # Or as an environment variable
          DHPAT: $
          DHUSER: $

with a proper Forgejo (Gitea) Docker-compose based agent:

builder@builder-T100:~/forgejo-runner$ cat docker-compose.yml 
services:
  runner:
    image: "gitea/act_runner:latest"
    privileged: true
    restart: always
    environment:
      GITEA_INSTANCE_URL: "https://forgejo.freshbrewed.science"
      GITEA_RUNNER_REGISTRATION_TOKEN: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
      GITEA_RUNNER_NAME: "t100-runner"
      GITEA_RUNNER_LABELS: "ubuntu-latest:docker://node:16-bullseye,my_custom_label:host" # Define runner labels
    volumes:
      - ./data:/data # Persistent storage for runner data
      - /var/run/docker.sock:/var/run/docker.sock # Allows runner to spin up containers for jobs

But eventually I had it working:

/content/images/2025/10/vikunjamcp-20.png

As an admin in Harbor, I can initiate a scan of the new container for vulnerabilities

/content/images/2025/10/vikunjamcp-21.png

I can also go to Dockerhub to see the tags for this repository

/content/images/2025/10/vikunjamcp-22.png

Adding a project lookup feature

Let’s try adding one more endpoint to this now that we have login, search and add working. “Add” requires a project number but that can be a pain to lookup (or remember) on Vikunja instances with lots of projects.

I’m going to start by adding a task (perhaps for the last time via the WebUI)

/content/images/2025/10/vikunjamcp-23.png

This time, I will use Verdent AI to fire up the work:

/content/images/2025/10/vikunjamcp-24.png

The code looks pretty good, I read through it to make sure there were no obvious gaffs. I appreciate how Verdent, by default, doesn’t go bananas adding comments. I find some of these AI tools really go too far adding notes everywhere.

/content/images/2025/10/vikunjamcp-25.png

A consequence of my flow is I presently need to update a number in the Dockerfile so CICD applies the proper tags. That’s a quick update

/content/images/2025/10/vikunjamcp-26.png

The CICD build system really makes this far far easier

Let’s switch to Harbor (as Docker hub is rate limiting me again) and try these out:

Adding to Github Copilot

Adding to Copilot did take a bit of finagling…

In agent mode, I went to add a tool and clicked the MCP icon. This time I picked a Docker image

/content/images/2025/10/vikunjamcp-29.png

I tried to enter the FQDN to my image

/content/images/2025/10/vikunjamcp-30.png

but it was really hung up on a “user/repository” path. It wouldn’t even let me pick a tag. So, I ultimately just entered “idjohnson/vikunjamcp” which it took (and yes, I know that is not valid, but that is what VS Code would accept).

Of course, that was in error when first loaded (it lacks a tag)

/content/images/2025/10/vikunjamcp-31.png

But then I updated my settings in mcp.json:

		"vikunjamcp": {
			"type": "stdio",
			"command": "docker",
			"args": [
				"run",
				"-i",
				"--rm",
				"-e",
				"VIKUNJA_URL",
				"-e",
				"VIKUNJA_USERNAME",
				"-e",
				"VIKUNJA_PASSWORD",
				"harbor.freshbrewed.science/library/vikunjamcp:0.11"
			],
			"env": {
				"VIKUNJA_URL": "https://vikunja.steeped.space",
				"VIKUNJA_USERNAME": "idjohnson",
				"VIKUNJA_PASSWORD": "xxxxxxxxxxxxxxxxxxxxxxxx"
			}
		}

And clicked restart

/content/images/2025/10/vikunjamcp-32.png

I knew it was working now because I could see the docker logs at the bottom (in “Output”) and the list of tools showed up above the entry

/content/images/2025/10/vikunjamcp-33.png

The first time I asked it, it sort of forgot it had a good tool to solve the problem and gave me tips on using an API call

/content/images/2025/10/vikunjamcp-34.png

I was a bit more explicit on my second request which it did swimmingly

/content/images/2025/10/vikunjamcp-35.png

Note: it will ask for permission to run tools so you’ll need to allow that

/content/images/2025/10/vikunjamcp-36.png

Just like in Gemini, I can now close my tasks via Copilot

/content/images/2025/10/vikunjamcp-37.png

Gemini Extension

There is an easier way, at least with Gemini CLI - Gemini CLI Extensions.

Let’s start by using Gemini CLI to create the extension directory

builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ gemini extensions new vikunja mcp-server
Successfully created new extension from template "mcp-server" at vikunja.
You can install this using "gemini extensions link vikunja" to test it out.

This creates a blank framework directory we can now update

/content/images/2025/10/vikunjamcp-38.png

builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ cat gemini-extension.json
{
  "name": "vikunja",
  "version": "1.0.11",
  "mcpServers": {
    "nodeServer": {
      "command": "docker",
      "args": [
         "run",
         "-i",
         "--rm",
         "-e",
         "VIKUNJA_URL",
         "-e",
         "VIKUNJA_USERNAME",
         "-e",
         "VIKUNJA_PASSWORD",
         "harbor.freshbrewed.science/library/vikunjamcp:0.11"
          ],
      "env": {
         "VIKUNJA_URL": "$VIKUNJA_URL",
         "VIKUNJA_USERNAME": "$VIKUNJA_USERNAME",
         "VIKUNJA_PASSWORD": "$VIKUNJA_PASSWORD"
      }
    }
  }
}builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ gemini extensions link .
Installing extension "vikunja".
**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**
This extension will run the following MCP servers:
  * nodeServer (local): docker run -i --rm -e VIKUNJA_URL -e VIKUNJA_USERNAME -e VIKUNJA_PASSWORD harbor.freshbrewed.science/library/vikunjamcp:0.11
Do you want to continue? [Y/n]: Y
Extension "vikunja" linked successfully and enabled.

Remove my hand-entered entry from the global settings:

$ cat ~/.gemini/settings.json
{
  "security": {
    "auth": {
      "selectedType": "oauth-personal"
    }
  },
  "telemetry": {
    "enabled": false,
    "target": "local",
    "otlpEndpoint": "https://otlp.nr-data.net:4318",
    "otlpProtocol": "http",
    "OLDERotlpEndpoint": "http://192.168.1.121:4317",
    "OLDotlpEndpoint": "http://192.168.1.33:30921",
    "otlpHeaders": {
      "api-key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },
    "logPrompts": true
  },
  "ui": {
    "theme": "Shades Of Purple"
  },
  "mcpServers": { }
}

Now I can fire up Gemini CLI and see if the tool loads:

$ export VIKUNJA_URL='https://vikunja.steeped.space'
$ export VIKUNJA_USERNAME='idjohnson'
$ export VIKUNJA_PASSWORD='xxxxxxxxxxxxxxxxxxxxxxx'
$ gemini

/content/images/2025/10/vikunjamcp-39.png

Now using it shows it is connecting and finding tasks

/content/images/2025/10/vikunjamcp-40.png

I would likely not want to have to use export each time, so I might very well move those commands into my ~/.bashrc file.

I can list the extensions now and it shows the one i added locally

$ gemini extensions list
✓ nanobanana (1.0.9)
 Path: /home/builder/.gemini/extensions/nanobanana
 Source: https://github.com/gemini-cli-extensions/nanobanana (Type: github-release)
 Release tag: v1.0.9
 Enabled (User): true
 Enabled (Workspace): true
 Context files:
  /home/builder/.gemini/extensions/nanobanana/GEMINI.md
 MCP servers:
  nanobanana

✓ vikunja (1.0.11)
 Path: /home/builder/Workspaces/vikunjamcp
 Source: /home/builder/Workspaces/vikunjamcp (Type: link)
 Enabled (User): true
 Enabled (Workspace): true
 MCP servers:
  nodeServer

But wait, we don’t want that - we want to pull from the Git repo!

so let’s remove our local linked extension:

builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ gemini extensions uninstall vikunja
Extension "vikunja" successfully uninstalled.
builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ gemini extensions list
✓ nanobanana (1.0.9)
 Path: /home/builder/.gemini/extensions/nanobanana
 Source: https://github.com/gemini-cli-extensions/nanobanana (Type: github-release)
 Release tag: v1.0.9
 Enabled (User): true
 Enabled (Workspace): true
 Context files:
  /home/builder/.gemini/extensions/nanobanana/GEMINI.md
 MCP servers:
  nanobanana

I’ll now push it up to Forgejo:

builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ git add gemini-extension.json
builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ git commit -m "initial Gemini CLI extension"
[main 31c0a90] initial Gemini CLI extension
 1 file changed, 26 insertions(+)
 create mode 100644 gemini-extension.json
builder@DESKTOP-QADGF36:~/Workspaces/vikunjamcp$ git push
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 16 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 514 bytes | 514.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: . Processing 1 references
remote: Processed 1 references in total
To https://forgejo.freshbrewed.science/builderadmin/vikunjamcp.git
   a429c41..31c0a90  main -> main

Now, we can install it using a URL instead of local path:

$ gemini extensions install https://forgejo.freshbrewed.science/builderadmin/vikunjamcp

Now we can see it in the list

$ gemini extensions list
✓ nanobanana (1.0.9)
 Path: /home/builder/.gemini/extensions/nanobanana
 Source: https://github.com/gemini-cli-extensions/nanobanana (Type: github-release)
 Release tag: v1.0.9
 Enabled (User): true
 Enabled (Workspace): true
 Context files:
  /home/builder/.gemini/extensions/nanobanana/GEMINI.md
 MCP servers:
  nanobanana

✓ vikunja (1.0.11)
 Path: /home/builder/.gemini/extensions/vikunja
 Source: https://forgejo.freshbrewed.science/builderadmin/vikunjamcp (Type: git)
 Enabled (User): true
 Enabled (Workspace): true
 MCP servers:
  nodeServer

Lastly, I would like to write this up in a USAGE.md so users know how to use it.

/content/images/2025/10/vikunjamcp-41.png

Summary

Today we built out a Vikunja MCP server based on FastMCP. After getting it to work locally, we moved on to using Docker to make it far more portable. We then pushed the code to a public Forgejo instance and setup CICD to send the built containers to Docker hub as well as my public endpoint in Harbor. We showed adding the MCP server to both Gemini CLI and Github Copilot.

We took a moment to use Verdent.AI to update the code to add Project listing and Issue closing, testing locally then publishing a new version with the feature (0.11). Lastly, we tackled setting this up as a Gemini CLI Extension to make it far easier to share out to the world (without loosing the containerized MCP server any tool could use).

I found the whole process rather fun. The only part I spent way to much time on was fighting Forgejo with the CICD docker build. I had so many iterations figuring out the right way to fire up a Gitea agent that would work with Docker. I knew I could just Github, but I was trying to hold the line on Open-Source-Uber-Alles. Once I crossed that hurdle, however, the rest was smooth sailing.

vikunja copilot mcp gemini geminiextenision ai verdantai

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes