ai-task-board-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_task_board_mcp-0.1.0/.gitignore +45 -0
- ai_task_board_mcp-0.1.0/PKG-INFO +104 -0
- ai_task_board_mcp-0.1.0/README.md +85 -0
- ai_task_board_mcp-0.1.0/atb_mcp/__init__.py +1 -0
- ai_task_board_mcp-0.1.0/atb_mcp/client.py +77 -0
- ai_task_board_mcp-0.1.0/atb_mcp/config.py +27 -0
- ai_task_board_mcp-0.1.0/atb_mcp/server.py +210 -0
- ai_task_board_mcp-0.1.0/pyproject.toml +44 -0
- ai_task_board_mcp-0.1.0/scripts/verify_mcp.py +97 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# --- Secrets / credentials (NEVER commit) ---
|
|
2
|
+
.secrets/
|
|
3
|
+
*.secret
|
|
4
|
+
*.secrets.md
|
|
5
|
+
*credentials*.local.*
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
|
|
10
|
+
# --- Node ---
|
|
11
|
+
node_modules/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
*.log
|
|
15
|
+
npm-debug.log*
|
|
16
|
+
.pnpm-debug.log*
|
|
17
|
+
|
|
18
|
+
# --- Python ---
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.py[cod]
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.eggs/
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
.mypy_cache/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
|
|
29
|
+
# --- DB / local data ---
|
|
30
|
+
*.sqlite
|
|
31
|
+
*.sqlite3
|
|
32
|
+
*.db
|
|
33
|
+
data/
|
|
34
|
+
|
|
35
|
+
# --- Build artifacts ---
|
|
36
|
+
deploy/dist/
|
|
37
|
+
|
|
38
|
+
# --- Editor / OS ---
|
|
39
|
+
.DS_Store
|
|
40
|
+
.idea/
|
|
41
|
+
.vscode/
|
|
42
|
+
*.swp
|
|
43
|
+
|
|
44
|
+
# nitro build output
|
|
45
|
+
.output/
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-task-board-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing the AI Task Board to any MCP-capable AI agent
|
|
5
|
+
Project-URL: Homepage, https://board.filbert.games
|
|
6
|
+
Project-URL: Documentation, https://board.filbert.games/docs
|
|
7
|
+
Author: Danylo Lahodniuk
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agents,ai-task-board,claude,kanban,llm,mcp
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: mcp>=1.2
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# AI Task Board — MCP Server
|
|
21
|
+
|
|
22
|
+
Exposes the board to **any MCP-capable AI agent** (Claude Code/Desktop, Cursor,
|
|
23
|
+
Cline, Windsurf, or a custom client — MCP is model-agnostic) as **8 typed tools**
|
|
24
|
+
mapped 1:1 onto the [Board REST API](../docs/api/openapi.yaml). It is a thin
|
|
25
|
+
client: it holds one scoped API key (one *actor*) and forwards calls. See the tool
|
|
26
|
+
contract in [`docs/api/mcp-tools.md`](../docs/api/mcp-tools.md) and per-client
|
|
27
|
+
wiring + behavioral rules in [`docs/INTEGRATION.md`](../docs/INTEGRATION.md).
|
|
28
|
+
|
|
29
|
+
## Tools
|
|
30
|
+
`list_projects` · `get_board` · `list_tasks` · `get_task` (read) ·
|
|
31
|
+
`claim_task` · `create_task` · `update_task` (write) · `add_report` (report)
|
|
32
|
+
|
|
33
|
+
Conflict handling baked in:
|
|
34
|
+
- `claim_task` on a contested task returns **who holds it and for how long**
|
|
35
|
+
(never steals it).
|
|
36
|
+
- `update_task` on a stale `expected_version` returns a `version_conflict` with
|
|
37
|
+
advice to re-read and retry (never overwrites a human's edit).
|
|
38
|
+
|
|
39
|
+
## Install & run
|
|
40
|
+
```bash
|
|
41
|
+
cd mcp-server
|
|
42
|
+
uv venv --python 3.14 .venv
|
|
43
|
+
uv pip install --python .venv -e ".[dev]"
|
|
44
|
+
|
|
45
|
+
export ATB_API_BASE_URL="http://127.0.0.1:8077" # the backend
|
|
46
|
+
export ATB_API_KEY="datb_danylo_full" # this agent's scoped key (= an actor)
|
|
47
|
+
export ATB_PROJECT_ID="<project-id>" # optional default project
|
|
48
|
+
.venv/bin/python -m atb_mcp.server # speaks MCP over stdio
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Connect to an agent
|
|
52
|
+
Any MCP client uses the same command + args + env (see
|
|
53
|
+
[`docs/INTEGRATION.md`](../docs/INTEGRATION.md) for Claude Desktop, Cursor,
|
|
54
|
+
Windsurf, Cline, and custom clients). Claude Code example:
|
|
55
|
+
```bash
|
|
56
|
+
claude mcp add ai-task-board \
|
|
57
|
+
--env ATB_API_BASE_URL=http://127.0.0.1:8077 \
|
|
58
|
+
--env ATB_API_KEY=datb_danylo_full \
|
|
59
|
+
--env ATB_PROJECT_ID=<project-id> \
|
|
60
|
+
-- /media/work/GitHub/ai-task-board/mcp-server/.venv/bin/python -m atb_mcp.server
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or add to a `.mcp.json` / `claude_desktop_config.json`:
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"ai-task-board": {
|
|
68
|
+
"command": "/media/work/GitHub/ai-task-board/mcp-server/.venv/bin/python",
|
|
69
|
+
"args": ["-m", "atb_mcp.server"],
|
|
70
|
+
"env": {
|
|
71
|
+
"ATB_API_BASE_URL": "http://127.0.0.1:8077",
|
|
72
|
+
"ATB_API_KEY": "datb_danylo_full",
|
|
73
|
+
"ATB_PROJECT_ID": "<project-id>"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Pair it with the behavioral skill in [`../skill/`](../skill/) so the agent knows
|
|
81
|
+
*how* to run the board (claim before working, one report per step, respect
|
|
82
|
+
priorities and other actors' claims).
|
|
83
|
+
|
|
84
|
+
## Config (env)
|
|
85
|
+
| Var | Default | Meaning |
|
|
86
|
+
|-----|---------|---------|
|
|
87
|
+
| `ATB_API_BASE_URL` | `http://127.0.0.1:8077` | backend base URL |
|
|
88
|
+
| `ATB_API_KEY` | — | scoped key = the agent's actor identity |
|
|
89
|
+
| `ATB_PROJECT_ID` | — | default project when a tool omits `project_id` |
|
|
90
|
+
| `ATB_TIMEOUT` | `15` | HTTP timeout (s) |
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
```bash
|
|
94
|
+
.venv/bin/python -m pytest
|
|
95
|
+
```
|
|
96
|
+
Spins up the real backend (via `backend/.venv`) as a subprocess and exercises all
|
|
97
|
+
8 tools end-to-end, including the claim-conflict and version-conflict paths.
|
|
98
|
+
|
|
99
|
+
## Verify end-to-end (manual script)
|
|
100
|
+
With a seeded backend already running:
|
|
101
|
+
```bash
|
|
102
|
+
ATB_API_BASE_URL=http://127.0.0.1:8077 PYTHONPATH=. \
|
|
103
|
+
.venv/bin/python scripts/verify_mcp.py
|
|
104
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# AI Task Board — MCP Server
|
|
2
|
+
|
|
3
|
+
Exposes the board to **any MCP-capable AI agent** (Claude Code/Desktop, Cursor,
|
|
4
|
+
Cline, Windsurf, or a custom client — MCP is model-agnostic) as **8 typed tools**
|
|
5
|
+
mapped 1:1 onto the [Board REST API](../docs/api/openapi.yaml). It is a thin
|
|
6
|
+
client: it holds one scoped API key (one *actor*) and forwards calls. See the tool
|
|
7
|
+
contract in [`docs/api/mcp-tools.md`](../docs/api/mcp-tools.md) and per-client
|
|
8
|
+
wiring + behavioral rules in [`docs/INTEGRATION.md`](../docs/INTEGRATION.md).
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
`list_projects` · `get_board` · `list_tasks` · `get_task` (read) ·
|
|
12
|
+
`claim_task` · `create_task` · `update_task` (write) · `add_report` (report)
|
|
13
|
+
|
|
14
|
+
Conflict handling baked in:
|
|
15
|
+
- `claim_task` on a contested task returns **who holds it and for how long**
|
|
16
|
+
(never steals it).
|
|
17
|
+
- `update_task` on a stale `expected_version` returns a `version_conflict` with
|
|
18
|
+
advice to re-read and retry (never overwrites a human's edit).
|
|
19
|
+
|
|
20
|
+
## Install & run
|
|
21
|
+
```bash
|
|
22
|
+
cd mcp-server
|
|
23
|
+
uv venv --python 3.14 .venv
|
|
24
|
+
uv pip install --python .venv -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
export ATB_API_BASE_URL="http://127.0.0.1:8077" # the backend
|
|
27
|
+
export ATB_API_KEY="datb_danylo_full" # this agent's scoped key (= an actor)
|
|
28
|
+
export ATB_PROJECT_ID="<project-id>" # optional default project
|
|
29
|
+
.venv/bin/python -m atb_mcp.server # speaks MCP over stdio
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Connect to an agent
|
|
33
|
+
Any MCP client uses the same command + args + env (see
|
|
34
|
+
[`docs/INTEGRATION.md`](../docs/INTEGRATION.md) for Claude Desktop, Cursor,
|
|
35
|
+
Windsurf, Cline, and custom clients). Claude Code example:
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add ai-task-board \
|
|
38
|
+
--env ATB_API_BASE_URL=http://127.0.0.1:8077 \
|
|
39
|
+
--env ATB_API_KEY=datb_danylo_full \
|
|
40
|
+
--env ATB_PROJECT_ID=<project-id> \
|
|
41
|
+
-- /media/work/GitHub/ai-task-board/mcp-server/.venv/bin/python -m atb_mcp.server
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or add to a `.mcp.json` / `claude_desktop_config.json`:
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"ai-task-board": {
|
|
49
|
+
"command": "/media/work/GitHub/ai-task-board/mcp-server/.venv/bin/python",
|
|
50
|
+
"args": ["-m", "atb_mcp.server"],
|
|
51
|
+
"env": {
|
|
52
|
+
"ATB_API_BASE_URL": "http://127.0.0.1:8077",
|
|
53
|
+
"ATB_API_KEY": "datb_danylo_full",
|
|
54
|
+
"ATB_PROJECT_ID": "<project-id>"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pair it with the behavioral skill in [`../skill/`](../skill/) so the agent knows
|
|
62
|
+
*how* to run the board (claim before working, one report per step, respect
|
|
63
|
+
priorities and other actors' claims).
|
|
64
|
+
|
|
65
|
+
## Config (env)
|
|
66
|
+
| Var | Default | Meaning |
|
|
67
|
+
|-----|---------|---------|
|
|
68
|
+
| `ATB_API_BASE_URL` | `http://127.0.0.1:8077` | backend base URL |
|
|
69
|
+
| `ATB_API_KEY` | — | scoped key = the agent's actor identity |
|
|
70
|
+
| `ATB_PROJECT_ID` | — | default project when a tool omits `project_id` |
|
|
71
|
+
| `ATB_TIMEOUT` | `15` | HTTP timeout (s) |
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
```bash
|
|
75
|
+
.venv/bin/python -m pytest
|
|
76
|
+
```
|
|
77
|
+
Spins up the real backend (via `backend/.venv`) as a subprocess and exercises all
|
|
78
|
+
8 tools end-to-end, including the claim-conflict and version-conflict paths.
|
|
79
|
+
|
|
80
|
+
## Verify end-to-end (manual script)
|
|
81
|
+
With a seeded backend already running:
|
|
82
|
+
```bash
|
|
83
|
+
ATB_API_BASE_URL=http://127.0.0.1:8077 PYTHONPATH=. \
|
|
84
|
+
.venv/bin/python scripts/verify_mcp.py
|
|
85
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI Task Board — MCP server package."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Thin HTTP client for the Board REST API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BoardAPIError(Exception):
|
|
12
|
+
"""Raised for non-2xx responses; carries status and parsed body."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status: int, body: Any):
|
|
15
|
+
self.status = status
|
|
16
|
+
self.body = body
|
|
17
|
+
super().__init__(f"Board API error {status}: {body}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BoardClient:
|
|
21
|
+
def __init__(self, config: Config):
|
|
22
|
+
self._config = config
|
|
23
|
+
self._client = httpx.Client(
|
|
24
|
+
base_url=f"{config.base_url}/api/v1",
|
|
25
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
26
|
+
timeout=config.timeout,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _request(self, method: str, path: str, **kwargs) -> Any:
|
|
30
|
+
if not self._config.api_key:
|
|
31
|
+
raise BoardAPIError(
|
|
32
|
+
401,
|
|
33
|
+
{"error": "no_api_key", "message": "Set ATB_API_KEY for the MCP server."},
|
|
34
|
+
)
|
|
35
|
+
resp = self._client.request(method, path, **kwargs)
|
|
36
|
+
try:
|
|
37
|
+
body = resp.json() if resp.content else None
|
|
38
|
+
except ValueError:
|
|
39
|
+
body = resp.text
|
|
40
|
+
if resp.status_code >= 400:
|
|
41
|
+
raise BoardAPIError(resp.status_code, body)
|
|
42
|
+
return body
|
|
43
|
+
|
|
44
|
+
# --- read ---
|
|
45
|
+
def list_projects(self) -> Any:
|
|
46
|
+
return self._request("GET", "/projects")
|
|
47
|
+
|
|
48
|
+
def get_board(self, project_id: str) -> Any:
|
|
49
|
+
return self._request("GET", f"/projects/{project_id}/board")
|
|
50
|
+
|
|
51
|
+
def list_tasks(self, project_id: str, params: dict) -> Any:
|
|
52
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
53
|
+
return self._request("GET", f"/projects/{project_id}/tasks", params=clean)
|
|
54
|
+
|
|
55
|
+
def get_task(self, task_id: str) -> Any:
|
|
56
|
+
return self._request("GET", f"/tasks/{task_id}")
|
|
57
|
+
|
|
58
|
+
# --- write ---
|
|
59
|
+
def create_task(self, project_id: str, body: dict) -> Any:
|
|
60
|
+
clean = {k: v for k, v in body.items() if v is not None}
|
|
61
|
+
return self._request("POST", f"/projects/{project_id}/tasks", json=clean)
|
|
62
|
+
|
|
63
|
+
def update_task(self, task_id: str, body: dict) -> Any:
|
|
64
|
+
clean = {k: v for k, v in body.items() if v is not None}
|
|
65
|
+
return self._request("PATCH", f"/tasks/{task_id}", json=clean)
|
|
66
|
+
|
|
67
|
+
def claim_task(self, task_id: str, lease_seconds: int | None) -> Any:
|
|
68
|
+
body = {} if lease_seconds is None else {"leaseSeconds": lease_seconds}
|
|
69
|
+
return self._request("POST", f"/tasks/{task_id}/claim", json=body)
|
|
70
|
+
|
|
71
|
+
# --- report ---
|
|
72
|
+
def add_report(self, task_id: str, body: dict) -> Any:
|
|
73
|
+
clean = {k: v for k, v in body.items() if v is not None}
|
|
74
|
+
return self._request("POST", f"/tasks/{task_id}/reports", json=clean)
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
self._client.close()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""MCP server configuration (from environment)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Config:
|
|
10
|
+
base_url: str
|
|
11
|
+
api_key: str
|
|
12
|
+
default_project_id: str | None
|
|
13
|
+
timeout: float
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_env(cls) -> "Config":
|
|
17
|
+
base = os.environ.get("ATB_API_BASE_URL", "http://127.0.0.1:8077").rstrip("/")
|
|
18
|
+
key = os.environ.get("ATB_API_KEY", "")
|
|
19
|
+
if not key:
|
|
20
|
+
# Not fatal at import; tools return a clear error if it's missing.
|
|
21
|
+
pass
|
|
22
|
+
return cls(
|
|
23
|
+
base_url=base,
|
|
24
|
+
api_key=key,
|
|
25
|
+
default_project_id=os.environ.get("ATB_PROJECT_ID") or None,
|
|
26
|
+
timeout=float(os.environ.get("ATB_TIMEOUT", "15")),
|
|
27
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""MCP server exposing the AI Task Board to Claude agents.
|
|
2
|
+
|
|
3
|
+
Eight tools mapped 1:1 onto the Board REST API, grouped by scope
|
|
4
|
+
(read / write / report). See docs/api/mcp-tools.md for the contract.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from .client import BoardAPIError, BoardClient
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
config = Config.from_env()
|
|
16
|
+
client = BoardClient(config)
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("ai-task-board")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _project(project_id: str | None) -> str:
|
|
22
|
+
pid = project_id or config.default_project_id
|
|
23
|
+
if not pid:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"No project_id given and ATB_PROJECT_ID is not set. "
|
|
26
|
+
"Call list_projects first, then pass project_id."
|
|
27
|
+
)
|
|
28
|
+
return pid
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ----------------------------- read scope -----------------------------
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def list_projects() -> Any:
|
|
34
|
+
"""List the projects this API key can see. Start here to discover project ids."""
|
|
35
|
+
return client.list_projects()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def get_board(project_id: str | None = None) -> Any:
|
|
40
|
+
"""Get a full board snapshot: columns (by status) with their tasks, plus milestones."""
|
|
41
|
+
return client.get_board(_project(project_id))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def list_tasks(
|
|
46
|
+
project_id: str | None = None,
|
|
47
|
+
status: str | None = None,
|
|
48
|
+
priority: str | None = None,
|
|
49
|
+
milestone_id: str | None = None,
|
|
50
|
+
assignee_id: str | None = None,
|
|
51
|
+
claimable: bool | None = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""List/filter tasks. Use claimable=true to get only unclaimed, unblocked work.
|
|
54
|
+
|
|
55
|
+
status: backlog|todo|in_progress|review|done|blocked. priority: low|medium|high|urgent.
|
|
56
|
+
"""
|
|
57
|
+
return client.list_tasks(
|
|
58
|
+
_project(project_id),
|
|
59
|
+
{
|
|
60
|
+
"status": status,
|
|
61
|
+
"priority": priority,
|
|
62
|
+
"milestoneId": milestone_id,
|
|
63
|
+
"assigneeId": assignee_id,
|
|
64
|
+
"claimable": claimable,
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def get_task(task_id: str) -> Any:
|
|
71
|
+
"""Get one task in full: description, acceptanceCriteria, dependencies, claim state, version."""
|
|
72
|
+
return client.get_task(task_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ----------------------------- write scope ----------------------------
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def claim_task(task_id: str, lease_seconds: int | None = None) -> Any:
|
|
78
|
+
"""Claim/lease a task before working it, so other agents skip it.
|
|
79
|
+
|
|
80
|
+
If another actor already holds it, this does NOT take the task — it returns who
|
|
81
|
+
holds it and for how long, so you can tell the user instead of stealing it.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
task = client.claim_task(task_id, lease_seconds)
|
|
85
|
+
return {"claimed": True, "task": task}
|
|
86
|
+
except BoardAPIError as e:
|
|
87
|
+
if e.status == 409 and isinstance(e.body, dict):
|
|
88
|
+
b = e.body
|
|
89
|
+
holder = (b.get("claimedBy") or {}).get("displayName", "another actor")
|
|
90
|
+
return {
|
|
91
|
+
"claimed": False,
|
|
92
|
+
"reason": "already_claimed",
|
|
93
|
+
"message": b.get("message")
|
|
94
|
+
or f"Task is already being worked on by {holder}.",
|
|
95
|
+
"claimedBy": b.get("claimedBy"),
|
|
96
|
+
"heldForSeconds": b.get("heldForSeconds"),
|
|
97
|
+
"claimExpiresAt": b.get("claimExpiresAt"),
|
|
98
|
+
"advice": "Do not take this task. Tell the user who holds it and pick another.",
|
|
99
|
+
}
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def create_task(
|
|
105
|
+
title: str,
|
|
106
|
+
project_id: str | None = None,
|
|
107
|
+
description: str | None = None,
|
|
108
|
+
status: str | None = None,
|
|
109
|
+
priority: str | None = None,
|
|
110
|
+
acceptance_criteria: str | None = None,
|
|
111
|
+
assignee_id: str | None = None,
|
|
112
|
+
milestone_id: str | None = None,
|
|
113
|
+
labels: list[str] | None = None,
|
|
114
|
+
blocked_by: list[str] | None = None,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Create a task (defaults to the backlog). Search existing tasks first — do not duplicate."""
|
|
117
|
+
return client.create_task(
|
|
118
|
+
_project(project_id),
|
|
119
|
+
{
|
|
120
|
+
"title": title,
|
|
121
|
+
"description": description,
|
|
122
|
+
"status": status,
|
|
123
|
+
"priority": priority,
|
|
124
|
+
"acceptanceCriteria": acceptance_criteria,
|
|
125
|
+
"assigneeId": assignee_id,
|
|
126
|
+
"milestoneId": milestone_id,
|
|
127
|
+
"labels": labels,
|
|
128
|
+
"blockedBy": blocked_by,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@mcp.tool()
|
|
134
|
+
def update_task(
|
|
135
|
+
task_id: str,
|
|
136
|
+
expected_version: int,
|
|
137
|
+
title: str | None = None,
|
|
138
|
+
description: str | None = None,
|
|
139
|
+
status: str | None = None,
|
|
140
|
+
priority: str | None = None,
|
|
141
|
+
acceptance_criteria: str | None = None,
|
|
142
|
+
assignee_id: str | None = None,
|
|
143
|
+
milestone_id: str | None = None,
|
|
144
|
+
labels: list[str] | None = None,
|
|
145
|
+
blocked_by: list[str] | None = None,
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""Update a task and/or move its status. Requires expected_version (optimistic lock).
|
|
148
|
+
|
|
149
|
+
On a version conflict the update is rejected — re-read with get_task and retry
|
|
150
|
+
with the new version, so you never overwrite a human's concurrent edit.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
return client.update_task(
|
|
154
|
+
task_id,
|
|
155
|
+
{
|
|
156
|
+
"expectedVersion": expected_version,
|
|
157
|
+
"title": title,
|
|
158
|
+
"description": description,
|
|
159
|
+
"status": status,
|
|
160
|
+
"priority": priority,
|
|
161
|
+
"acceptanceCriteria": acceptance_criteria,
|
|
162
|
+
"assigneeId": assignee_id,
|
|
163
|
+
"milestoneId": milestone_id,
|
|
164
|
+
"labels": labels,
|
|
165
|
+
"blockedBy": blocked_by,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
except BoardAPIError as e:
|
|
169
|
+
if e.status == 409 and isinstance(e.body, dict):
|
|
170
|
+
return {
|
|
171
|
+
"updated": False,
|
|
172
|
+
"reason": "version_conflict",
|
|
173
|
+
"message": e.body.get("message"),
|
|
174
|
+
"currentVersion": e.body.get("currentVersion"),
|
|
175
|
+
"advice": "Re-read the task with get_task and retry with the current version.",
|
|
176
|
+
}
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ----------------------------- report scope ---------------------------
|
|
181
|
+
@mcp.tool()
|
|
182
|
+
def add_report(
|
|
183
|
+
task_id: str,
|
|
184
|
+
type: str,
|
|
185
|
+
body: str,
|
|
186
|
+
agent_session_id: str | None = None,
|
|
187
|
+
artifacts: list[dict] | None = None,
|
|
188
|
+
) -> Any:
|
|
189
|
+
"""Append a report to a task. type: progress|done|blocker.
|
|
190
|
+
|
|
191
|
+
For 'done' reports, link artifacts: [{"kind":"pr|commit|file|url","ref":"..."}].
|
|
192
|
+
Write one report per meaningful step — do not spam the timeline.
|
|
193
|
+
"""
|
|
194
|
+
return client.add_report(
|
|
195
|
+
task_id,
|
|
196
|
+
{
|
|
197
|
+
"type": type,
|
|
198
|
+
"body": body,
|
|
199
|
+
"agentSessionId": agent_session_id,
|
|
200
|
+
"artifacts": artifacts,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main() -> None:
|
|
206
|
+
mcp.run()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-task-board-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server exposing the AI Task Board to any MCP-capable AI agent"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Danylo Lahodniuk" }]
|
|
13
|
+
keywords = ["mcp", "ai-task-board", "kanban", "agents", "claude", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp>=1.2",
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://board.filbert.games"
|
|
26
|
+
Documentation = "https://board.filbert.games/docs"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
ai-task-board-mcp = "atb_mcp.server:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["atb_mcp"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["atb_mcp", "README.md", "scripts"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
addopts = "-q"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""End-to-end check: drive the MCP tools against a running Board backend.
|
|
2
|
+
|
|
3
|
+
Usage: ATB_API_BASE_URL + ATB_API_KEY must point at a seeded backend.
|
|
4
|
+
Exercises the read/write/report tools and the two conflict paths.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
BASE = os.environ.setdefault("ATB_API_BASE_URL", "http://127.0.0.1:8077")
|
|
15
|
+
os.environ.setdefault("ATB_API_KEY", "datb_danylo_full") # acts as Claude (Danylo)
|
|
16
|
+
|
|
17
|
+
# Import AFTER env is set (config is read at import time).
|
|
18
|
+
from atb_mcp import server # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ok(label: str, cond: bool, extra: str = "") -> None:
|
|
22
|
+
mark = "✓" if cond else "✗"
|
|
23
|
+
print(f" {mark} {label} {extra}")
|
|
24
|
+
if not cond:
|
|
25
|
+
raise SystemExit(f"FAILED: {label}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
print("MCP end-to-end verification")
|
|
30
|
+
|
|
31
|
+
# tool registration
|
|
32
|
+
tools = asyncio.run(server.mcp.list_tools())
|
|
33
|
+
names = {t.name for t in tools}
|
|
34
|
+
expected = {
|
|
35
|
+
"list_projects", "get_board", "list_tasks", "get_task",
|
|
36
|
+
"claim_task", "create_task", "update_task", "add_report",
|
|
37
|
+
}
|
|
38
|
+
ok("8 tools registered", expected <= names, extra=str(sorted(names)))
|
|
39
|
+
|
|
40
|
+
# read
|
|
41
|
+
projects = server.list_projects()
|
|
42
|
+
ok("list_projects", len(projects) >= 1)
|
|
43
|
+
pid = projects[0]["id"]
|
|
44
|
+
|
|
45
|
+
board = server.get_board(pid)
|
|
46
|
+
ok("get_board", "columns" in board)
|
|
47
|
+
|
|
48
|
+
claimable = server.list_tasks(project_id=pid, claimable=True)
|
|
49
|
+
ok("list_tasks(claimable)", isinstance(claimable, list) and len(claimable) >= 1)
|
|
50
|
+
|
|
51
|
+
# write: create
|
|
52
|
+
created = server.create_task(
|
|
53
|
+
title="MCP smoke task", project_id=pid, priority="high",
|
|
54
|
+
acceptance_criteria="verified by script",
|
|
55
|
+
)
|
|
56
|
+
ok("create_task", created["version"] == 1 and created["createdBy"]["id"] == "claude/danylo")
|
|
57
|
+
tid = created["id"]
|
|
58
|
+
|
|
59
|
+
# write: claim success
|
|
60
|
+
claim = server.claim_task(tid)
|
|
61
|
+
ok("claim_task (success)", claim["claimed"] is True)
|
|
62
|
+
|
|
63
|
+
# write: update with wrong version -> conflict dict
|
|
64
|
+
bad = server.update_task(tid, expected_version=999, status="review")
|
|
65
|
+
ok("update_task version conflict", bad.get("reason") == "version_conflict")
|
|
66
|
+
|
|
67
|
+
# write: update correct version
|
|
68
|
+
good = server.update_task(tid, expected_version=created["version"], status="in_progress")
|
|
69
|
+
ok("update_task success", good["version"] == created["version"] + 1)
|
|
70
|
+
|
|
71
|
+
# report
|
|
72
|
+
rep = server.add_report(
|
|
73
|
+
tid, type="progress", body="working on it",
|
|
74
|
+
artifacts=[{"kind": "url", "ref": "https://example.com"}],
|
|
75
|
+
)
|
|
76
|
+
ok("add_report", rep["type"] == "progress" and rep["author"]["id"] == "claude/danylo")
|
|
77
|
+
|
|
78
|
+
# claim conflict: have Bob claim a fresh task, then MCP (Danylo) must be told who/how long
|
|
79
|
+
with httpx.Client(base_url=f"{BASE}/api/v1",
|
|
80
|
+
headers={"Authorization": "Bearer datb_bob_full"}) as bob:
|
|
81
|
+
t = bob.post(f"/projects/{pid}/tasks", json={"title": "Contended (MCP)"}).json()
|
|
82
|
+
c = bob.post(f"/tasks/{t['id']}/claim")
|
|
83
|
+
assert c.status_code == 200, c.text
|
|
84
|
+
conflict = server.claim_task(t["id"])
|
|
85
|
+
ok(
|
|
86
|
+
"claim_task conflict reports holder + duration",
|
|
87
|
+
conflict["claimed"] is False
|
|
88
|
+
and conflict["claimedBy"]["displayName"] == "Claude (Bob)"
|
|
89
|
+
and conflict["heldForSeconds"] >= 0,
|
|
90
|
+
extra=f'-> "{conflict["message"]}"',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
print("\nALL MCP CHECKS PASSED")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
sys.exit(main())
|