Skip to main content
Glama
Task.md Utilities MCP Server

Task.md Utilities MCP Server

by DrSkippy
README.md
# Task.md Utilities (HENDRICKSON KANBAN)

A REST API, MCP server, and interactive CLI for managing tasks organized in lanes, where each task is a markdown file. The CLI includes a full-screen Textual TUI (kanban board with keyboard navigation) as well as non-interactive subcommands for scripting.

## About Tasks.md

This project builds on and extends the [Tasks.md](https://github.com/BaldissaraMatheus/Tasks.md) project. For comprehensive information about the Tasks.md system, including task visualization, board views, file format specifications, and VSCode integration, visit the [Tasks.md repository](https://github.com/BaldissaraMatheus/Tasks.md).

This package adds a REST API, a network-capable interactive CLI, and an MCP server for programmatic task management.

## Overview

Tasks are stored as markdown files organized in lane directories:
- Task title = filename (without `.md`)
- Tags: `[tag:tagname]` — one per line at the top of the file
- Due dates: `[due:YYYY-MM-DD]`
- Tasks can be split into subtasks using the `[[split]]` marker

## Architecture

```
┌──────────────────┐   HTTP   ┌───────────────────┐
│ tasks CLI / TUI  │ ────────▶│  Flask REST API   │  :2999 (container) / :3101 (host)
│ (bin/tasks)      │          │  (task_api/)      │──┐
└──────────────────┘          └───────────────────┘  │   ┌──────────────┐   ┌───────────────────┐
                                                     ├──▶│  task_lib/   │──▶│  Markdown files   │
┌──────────────────┐   MCP    ┌───────────────────┐  │   │ (in-process) │   │  /data/tasks/     │
│ AI assistant     │ ────────▶│  MCP server       │──┘   └──────────────┘   └───────────────────┘
│ (Claude etc.)    │          │ (mcp_task_service)│  :3003                     ▲
└──────────────────┘          └───────────────────┘                             │ same folder
                                                     Tasks.md web app ──────────┘
```

The REST API and the MCP server each import `task_lib` and read and write the
card files directly; neither calls the other. The CLI and TUI only talk to the
REST API. The Tasks.md web app reads the same folder.

## Project Structure

```
task.md-utilities/
├── bin/
│   ├── tasks               # CLI — TUI (no args) or subcommands (scripting)
│   └── tag-utility.py      # One-time tag format migration utility
├── docs/
│   └── screenshot.svg      # TUI kanban board screenshot
├── task_lib/
│   ├── api_client.py       # Shared HTTP helpers (used by CLI and TUI)
│   ├── cli_config.py       # CLI settings (~/.config/tasks/config.yaml)
│   ├── config.py           # Configuration (YAML)
│   ├── lane_order.py       # Per-machine lane (column) order for CLI and TUI
│   ├── errors.py           # Typed errors: not_found, exists, invalid, conflict
│   ├── sections.py         # Partial edits: append, upsert section, check items
│   ├── task.py             # Task model and file I/O
│   └── task_manager.py     # Lane, task and trash operations
├── task_tui/               # Textual TUI package
│   ├── app.py              # KanbanApp — main board, keybindings, workers
│   ├── api.py              # Sync API wrappers for use in workers
│   ├── screens.py          # Detail, form, confirm, filter, move screens
│   └── widgets.py          # LaneColumn, TaskItem, FunctionKeyBar
├── task_api/               # Flask REST API service
│   ├── app.py
│   ├── config.py
│   ├── models.py           # Pydantic request/response schemas
│   ├── routes/             # tasks, trash, lanes, operations, health
│   ├── gunicorn.conf.py
│   ├── Dockerfile
│   ├── docker-compose.yml
│   └── README.md
├── mcp_task_service/       # FastMCP server for AI assistants
│   ├── server.py
│   ├── Dockerfile
│   ├── docker-compose.yml
│   └── README.md
├── tests/                  # pytest suite (conftest.py isolates $HOME)
├── config.yaml             # Task data location (baked into Docker images)
├── tasks.config.example.yaml  # Every CLI setting with its default
├── docker-compose.yml      # Deploys both services together
├── Makefile                # build / push / deploy / test / lint
└── .mcp.json               # Connects Claude Code in this repo to the MCP server
```

## Deployment

Both services are deployed as Docker containers. Build from the **repo root** (build context must include both `task_lib/` and the service directory).

### Build images

```bash
make push      # build and push both images (make build to only build)
make release   # run the tests first, then build and push
make deploy    # recreate the containers in /opt/stacks/mcp-task

# Single service, or another registry/tag
make push-mcp
make push-api REGISTRY=registry.example:5000 TAG=0.3.0
```

`make help` lists every target.

### Deploy

The running services are the Dockge stack in `/opt/stacks/mcp-task`, whose
compose file matches the root `docker-compose.yml`. After `make push`:

```bash
make deploy    # docker compose pull && up -d --force-recreate in /opt/stacks/mcp-task
```

Restarting the MCP server drops connected MCP clients; reconnect them (in
Claude Code: `/mcp reconnect`). To run the stack from the repo instead, use
`docker compose up -d` in the repo root.

The compose file starts both services on a shared network:

| Service | Internal port | Host port |
|---------|--------------|-----------|
| REST API | 2999 | 3101 |
| MCP server | 3003 | 3003 |

Both containers mount the task data directory:
```yaml
volumes:
  - /mnt/raid1/lib/tasks.md/tasks:/data/tasks
```

### Configuration

`config.yaml` (committed to the repo) sets the task data location inside the container. It is copied into the image at build time — no volume mount needed:

```yaml
base_dir: /data/tasks
```

To change the data path, edit `config.yaml` and rebuild the images.

### Running services individually

Each service has its own `docker-compose.yml` for standalone use:

```bash
cd task_api && docker-compose up -d       # REST API only
cd mcp_task_service && docker-compose up -d  # MCP only
```

## CLI (`bin/tasks`)

The `tasks` binary has two modes:

- **No arguments** — launches the full-screen interactive TUI (kanban board)
- **With a subcommand** — runs non-interactively for scripting

### Installation

```bash
poetry install
poetry run bin/tasks --help
```

`poetry install` does not put `tasks` on your `PATH`; the examples below write
`tasks` for `poetry run bin/tasks`. To type `tasks` directly, add an alias:

```bash
alias tasks='poetry -C ~/Working/task.md-utilities run ~/Working/task.md-utilities/bin/tasks'
```

### Configuration

The CLI reads its settings from a YAML file, the first of:

1. `--config PATH` flag
2. `TASKS_CONFIG` environment variable
3. `~/.config/tasks/config.yaml`

Every key is optional and a missing default file is fine. Copy
[`tasks.config.example.yaml`](tasks.config.example.yaml), which lists every key
with its default: `api_url`, request `timeout`, the board's `app_title`,
`default_sort` for `tasks show`, the `empty_cell` placeholder, and the table
`styles`. Unknown keys and badly typed values are reported as errors.

The board's column order is per machine: Shift+←/→ in the board or
`tasks lanes order` saves it to `~/.local/state/tasks/lane_order.yaml`
(`$XDG_STATE_HOME`, or the `lane_order_file` config key). `tasks lanes list`
and `tasks show` follow it too. The server, the MCP tools and other machines
still list lanes alphabetically.

The API URL comes from the first of:

1. `--api-url URL` flag
2. `TASKS_API_URL` environment variable
3. `api_url` in the config file
4. Default: `http://localhost:3101`

```yaml
api_url: http://your-server:3101
```

---

### Interactive TUI

```bash
tasks
```

Launches a full-screen kanban board. Lanes are displayed as side-by-side columns; tasks are listed under each lane.

![Kanban Board](docs/screenshot.svg)

#### Keyboard navigation

| Key | Action |
|-----|--------|
| `←` / `→` | Move between lanes |
| `Shift+←` / `Shift+→` | Move the focused lane (column) left / right; the order is saved |
| `↑` / `↓` | Move between tasks within a lane |
| `Enter` | Open task detail view |
| `Esc` | Go back / close dialog |
| `F1` | Show keyboard shortcut help |
| `F2` | New task (in the focused lane) |
| `F3` | Edit selected task |
| `F4` | Delete selected task (confirm prompt) |
| `F5` | Filter tasks (by lane, tag, or title substring) |
| `F6` | Move selected task to another lane |
| `F7` | Open / close task detail view |
| `F8` | Refresh board from the API |
| `F9` | Quit |

Active filters are shown in a status bar at the top; press `F5` again to change or clear them.

---

### Subcommands

```
tasks [--api-url URL] [--config PATH] COMMAND

Commands:
  show         List tasks (--lane, --tag, --string, --query, --due-before,
               --due-after, --sort lane|title|due|modified)
  find         Search titles and bodies, showing the matching text
  get          Show a single task in full
  add          Create a new task (lane must exist unless --create-lane)
  update       Update task fields
  append       Append text to the end of a task
  section      Replace (or add) one "## Heading" section of a task
  check        Tick "- [ ]" checklist items (--uncheck to untick)
  delete       Move a task to Trash
  move         Change a task's lane (--create-lane for a new lane)
  trash        Manage the Trash
    list       List trashed cards, newest first
    restore    Restore a card into a lane
    empty      Permanently delete Trash (--dry-run, --yes)
  empty-trash  Same as `trash empty`
  split        Split tasks containing [[split]] marker
  lanes        Manage lanes
    list       List lanes with task counts
    order      Show or set the column order (--reset)
    add        Create a new lane
  stats        Show statistics summary
```

Adding, renaming, moving and restoring never overwrite a card: reusing an
existing title fails, and so does naming a lane that doesn't exist (unless you
pass `--create-lane`). `split` also checks first: a card whose `1-<title>`,
`2-<title>`, … names are already taken is left unsplit and reported.

### Examples

```bash
# List all tasks
tasks show

# Filter by lane, tag, or title substring (all case-insensitive)
tasks show --lane "In Progress"
tasks show --tag urgent
tasks show --string "login"

# Show full task detail
tasks get "Implement login"

# Create a task (prompts for missing fields)
tasks add --title "Fix bug" --content "Reproduce and fix" --lane Backlog --tags "bug,urgent"

# Update fields (only provided fields change)
tasks update "Fix bug" --tags "bug,urgent,p1" --due-date 2026-06-01

# Search titles and bodies; sort by due date
tasks find "books db"
tasks show --sort due --due-before 2026-06-30

# Edit part of a card without resending the whole body
tasks section "Fix bug" Progress "Reproduced on staging"
tasks append --no-blank-line "Fix bug" -- "- [ ] write regression test"
tasks check "Fix bug" "regression test"
echo "Patch the null check" | tasks section "Fix bug" "Next action" -

# Move between lanes
tasks move "Fix bug" "In Progress"

# Delete (moves to Trash, prompts for confirmation)
tasks delete "Fix bug"

# Trash: list, restore, preview, then permanently delete (prompts for confirmation)
tasks trash list
tasks trash restore "Fix bug" Backlog
tasks trash empty --dry-run
tasks trash empty

# Lane management
tasks lanes list
tasks lanes add "Sprint 3"

# Column order: named lanes first, the rest keep their order after them
tasks lanes order                 # show it
tasks lanes order Doing Backlog   # set it
tasks lanes order --reset         # back to alphabetical

# Split a task (divides on [[split]] marker)
tasks split

# Statistics
tasks stats
```

## MCP Server

The MCP server exposes kanban operations to AI assistants (Claude, Cursor, etc.) via the [Model Context Protocol](https://modelcontextprotocol.io/).

### Connecting a client

Add to your Claude Desktop / Claude Code config (`~/.claude/claude_desktop_config.json` or `.mcp.json`):

```json
{
  "mcpServers": {
    "task-manager": {
      "type": "http",
      "url": "http://localhost:3003/mcp"
    }
  }
}
```

### Available tools

Every tool returns `{"ok": true, ...}` or `{"ok": false, "error": {"code", "message"}}`.

| Tool | Description |
|------|-------------|
| `info` | Server version, data folder, counts |
| `list_lanes` | Lanes with task counts, plus `trash_count` |
| `list_tasks` | Task summaries; lane/tag/due filters, sort, limit, optional bodies |
| `find_tasks` | Search titles and bodies |
| `get_task` | One task in full |
| `get_statistics` | Lane, tag, due-date, overdue and due-this-week counts |
| `add_task` | Create a task (refuses existing titles and unknown lanes) |
| `update_task` | Update task fields |
| `append_to_task` / `upsert_section` / `check_items` | Edit part of a card |
| `move_task_to_lane` | Move a task to another lane |
| `delete_task` | Move a task to Trash |
| `list_trash` / `restore_task` | See and undo deletions |
| `empty_trash` | Permanently delete Trash contents (`dry_run` supported) |
| `add_lane` | Create a new lane |
| `split_tasks` | Split tasks with `[[split]]` marker; skips (and reports) cards whose subtask titles are taken |

Title, lane and tag matching is **case-insensitive** across all tools. Writes
accept `expected_version` (from any task result) to guard against concurrent edits,
and return the card's summary, or the full card with `include_content=true`.

See [`mcp_task_service/README.md`](mcp_task_service/README.md) for full details.

## REST API

The REST API is documented in [`task_api/README.md`](task_api/README.md).

Quick reference:

```bash
# Health check
curl http://localhost:3101/health

# List tasks
curl "http://localhost:3101/tasks"
curl "http://localhost:3101/tasks?lane=Backlog&tag=urgent"
curl "http://localhost:3101/tasks?search=login"          # title substring
curl "http://localhost:3101/tasks?query=books%20db&sort=due"  # title or body

# Create a task
curl -X POST http://localhost:3101/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"My Task","content":"Details","lane":"Backlog","tags":["urgent"]}'

# Move a task
curl -X POST "http://localhost:3101/tasks/My%20Task/move" \
  -H 'Content-Type: application/json' \
  -d '{"lane":"In Progress"}'

# Trash: list, preview emptying
curl http://localhost:3101/trash
curl -X DELETE "http://localhost:3101/operations/trash?dry_run=true"

# Statistics
curl http://localhost:3101/operations/statistics
```

## Task File Format

```markdown
[tag:frontend]
[tag:urgent]
[due:2026-06-01]

Task body text goes here.
```

Only lines that contain nothing but `[tag:…]` tokens (or a single `[due:…]`) are
metadata; a tag mentioned inside a sentence is ordinary text.

## Data Directory Structure

```
/data/tasks/
├── Backlog/
│   └── Implement login.md
├── In Progress/
│   └── Fix auth bug.md
├── Done/
│   └── Setup CI.md
└── Trash/
    └── old-task~20261014-120102.md   # timestamp suffix keeps same-named cards apart
```

## Development

### Set up

```bash
poetry install --extras dev   # adds black, mypy and type stubs
```

### Run tests and checks

```bash
make test   # pytest with coverage for task_lib, task_api, mcp_task_service
make lint   # black --check and mypy on the modules kept clean so far
```

`make test` doesn't measure `task_tui`; for that, run
`poetry run pytest --cov=task_tui --cov-report=term-missing tests/`. Much of the
older code doesn't pass `black` or `mypy` yet, so `make lint` covers only the
files that do.

`tests/conftest.py` points `Path.home()` at an empty temporary directory and
clears `TASKS_CONFIG`, `TASKS_API_URL` and `XDG_STATE_HOME`, so your own
`~/.config/tasks/config.yaml` and saved lane order never affect a test run.

### Run the services locally

The committed `config.yaml` points at `/data/tasks`, the path inside the
containers. Locally, point `TASK_CONFIG_PATH` at a config for a scratch folder:

```bash
mkdir -p /tmp/tasks-dev && echo "base_dir: /tmp/tasks-dev" > /tmp/tasks-dev.yaml

TASK_CONFIG_PATH=/tmp/tasks-dev.yaml poetry run gunicorn --config task_api/gunicorn.conf.py "task_api.app:create_app()"
TASK_CONFIG_PATH=/tmp/tasks-dev.yaml poetry run python mcp_task_service/server.py
```

## Utilities

### Tag Format Migration

`bin/tag-utility.py` converts old-format tags (`tags: tag1, tag2`) to the current format (`[tag:tagname]`). Edit the hardcoded directory path in the script before running. Supports dry-run mode.