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.
@@ -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())