tackboard-mcp 0.8.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.
- tackboard_mcp-0.8.0/PKG-INFO +119 -0
- tackboard_mcp-0.8.0/README.md +109 -0
- tackboard_mcp-0.8.0/pyproject.toml +26 -0
- tackboard_mcp-0.8.0/pyproject.toml.orig +21 -0
- tackboard_mcp-0.8.0/src/tackboard_mcp/__init__.py +14 -0
- tackboard_mcp-0.8.0/src/tackboard_mcp/server.py +166 -0
- tackboard_mcp-0.8.0/src/tackboard_mcp/tools.py +220 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tackboard-mcp
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Dist: tackboard>=0.8.0,<0.9
|
|
7
|
+
Requires-Dist: mcp>=2.3,<3
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# tackboard-mcp — an optional MCP adapter
|
|
12
|
+
|
|
13
|
+
A small [Model Context Protocol](https://modelcontextprotocol.io) server that
|
|
14
|
+
exposes one `tack` project board to an MCP client (an IDE assistant, a chat
|
|
15
|
+
client, another agent runtime).
|
|
16
|
+
|
|
17
|
+
**This package is optional and outside the core.** The `tackboard` package —
|
|
18
|
+
the CLI, the database, the JSON contracts — stays dependency-free and does not
|
|
19
|
+
import anything here. If you only want the board, install `tackboard`; if your
|
|
20
|
+
agent runtime speaks MCP and you would rather it called tools than assembled
|
|
21
|
+
shell commands, install this beside it.
|
|
22
|
+
|
|
23
|
+
## What it is, precisely
|
|
24
|
+
|
|
25
|
+
The adapter contains **no domain logic**. Every tool shells out to the `tack`
|
|
26
|
+
CLI with `--json` and returns that document unchanged, so a client can never
|
|
27
|
+
see a claim, a transition or a validation rule that differs from the board. A
|
|
28
|
+
non-zero exit is raised as an error whose payload is the CLI's own error
|
|
29
|
+
document (`{"schema_version":1,"error":{...}}`).
|
|
30
|
+
|
|
31
|
+
What it adds is discovery and a typed surface: seven named tools with described
|
|
32
|
+
arguments, instead of a CLI string to compose.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install tackboard-mcp # or: pip install tackboard-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This installs the adapter and pulls `tackboard` with it, so the `tack` CLI
|
|
41
|
+
comes along in the same environment. The MCP framework and the core are this
|
|
42
|
+
package's dependencies, and they are declared here — never in the core.
|
|
43
|
+
|
|
44
|
+
`TACK_CMD` remains available for unusual environments where the adapter and the
|
|
45
|
+
CLI live in separate environments; set it to the command that invokes `tack`
|
|
46
|
+
(for example `TACK_CMD="uv run tack"`).
|
|
47
|
+
|
|
48
|
+
## Configure
|
|
49
|
+
|
|
50
|
+
The server reads three environment variables:
|
|
51
|
+
|
|
52
|
+
| Variable | Meaning |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `TACKBOARD_DB` | the board file to operate on (same variable the CLI reads) |
|
|
55
|
+
| `TACKBOARD_AGENT` | the actor every mutation is attributed to |
|
|
56
|
+
| `TACK_CMD` | overrides the CLI command prefix; defaults to the installed core module, or `tack` when the core is not importable |
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
TACKBOARD_DB=~/.local/state/board.sqlite3 TACKBOARD_AGENT=assistant tackboard-mcp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The process speaks MCP over stdio. Register it with your client the way that
|
|
63
|
+
client documents stdio servers:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"tackboard": {
|
|
69
|
+
"command": "tackboard-mcp",
|
|
70
|
+
"env": {
|
|
71
|
+
"TACKBOARD_DB": "/path/to/board.sqlite3",
|
|
72
|
+
"TACKBOARD_AGENT": "assistant"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Tools
|
|
80
|
+
|
|
81
|
+
| Tool | Arguments | Returns |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `next_claim` | `stage?` (`work`/`review`) | the `next --claim` document; `task` is `null` when nothing is eligible |
|
|
84
|
+
| `show` | `task_id` | the `show` document: the task and its notes |
|
|
85
|
+
| `note` | `task_id`, `text`, `refs?` (`KIND:VALUE`) | the `note` document with the appended event |
|
|
86
|
+
| `complete` | `task_id` | the mutation document; completing unblocks dependents |
|
|
87
|
+
| `release` | `task_id`, `reason?` | the mutation document; a reason is the audited force path |
|
|
88
|
+
| `create` | `title`, `description?`, `priority?`, `assignee?`, `labels?`, `depends_on?`, `status?` | the created task document |
|
|
89
|
+
| `board_brief` | — | the counts-only board document |
|
|
90
|
+
|
|
91
|
+
Every tool declares all four MCP annotation hints as explicit booleans. The two
|
|
92
|
+
read tools (`board_brief`, `show`) are read-only and repeatable; every mutating
|
|
93
|
+
tool writes only the one local board and touches no external system
|
|
94
|
+
(`openWorldHint` false); nothing on this surface deletes data
|
|
95
|
+
(`destructiveHint` false); and `complete` is idempotent while `next_claim`,
|
|
96
|
+
`note`, `release` and `create` are not.
|
|
97
|
+
|
|
98
|
+
Each tool returns the CLI's JSON document unchanged. Exit codes are not
|
|
99
|
+
translated into a second vocabulary: a failed call raises with the CLI's
|
|
100
|
+
`error.code`, `exit_code` and `details` intact.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv run pytest packages/tackboard-mcp/tests
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The tool layer is tested without the framework: argument mapping against a fake
|
|
109
|
+
transport, plus a scratch board driven through the real CLI. The binding tests
|
|
110
|
+
(`test_server.py`) run only when `mcp` is installed — including a real client
|
|
111
|
+
handshake over stdio — and skip otherwise, so the core suite never needs the
|
|
112
|
+
framework.
|
|
113
|
+
|
|
114
|
+
## Limits
|
|
115
|
+
|
|
116
|
+
- One board per process: the database is fixed at start-up.
|
|
117
|
+
- The web view and the schema commands are not exposed; they are not tool-shaped.
|
|
118
|
+
- `--force` is reachable only through `release`'s `reason`, deliberately: the
|
|
119
|
+
other force cases are operator decisions, not assistant ones.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# tackboard-mcp — an optional MCP adapter
|
|
2
|
+
|
|
3
|
+
A small [Model Context Protocol](https://modelcontextprotocol.io) server that
|
|
4
|
+
exposes one `tack` project board to an MCP client (an IDE assistant, a chat
|
|
5
|
+
client, another agent runtime).
|
|
6
|
+
|
|
7
|
+
**This package is optional and outside the core.** The `tackboard` package —
|
|
8
|
+
the CLI, the database, the JSON contracts — stays dependency-free and does not
|
|
9
|
+
import anything here. If you only want the board, install `tackboard`; if your
|
|
10
|
+
agent runtime speaks MCP and you would rather it called tools than assembled
|
|
11
|
+
shell commands, install this beside it.
|
|
12
|
+
|
|
13
|
+
## What it is, precisely
|
|
14
|
+
|
|
15
|
+
The adapter contains **no domain logic**. Every tool shells out to the `tack`
|
|
16
|
+
CLI with `--json` and returns that document unchanged, so a client can never
|
|
17
|
+
see a claim, a transition or a validation rule that differs from the board. A
|
|
18
|
+
non-zero exit is raised as an error whose payload is the CLI's own error
|
|
19
|
+
document (`{"schema_version":1,"error":{...}}`).
|
|
20
|
+
|
|
21
|
+
What it adds is discovery and a typed surface: seven named tools with described
|
|
22
|
+
arguments, instead of a CLI string to compose.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv tool install tackboard-mcp # or: pip install tackboard-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This installs the adapter and pulls `tackboard` with it, so the `tack` CLI
|
|
31
|
+
comes along in the same environment. The MCP framework and the core are this
|
|
32
|
+
package's dependencies, and they are declared here — never in the core.
|
|
33
|
+
|
|
34
|
+
`TACK_CMD` remains available for unusual environments where the adapter and the
|
|
35
|
+
CLI live in separate environments; set it to the command that invokes `tack`
|
|
36
|
+
(for example `TACK_CMD="uv run tack"`).
|
|
37
|
+
|
|
38
|
+
## Configure
|
|
39
|
+
|
|
40
|
+
The server reads three environment variables:
|
|
41
|
+
|
|
42
|
+
| Variable | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `TACKBOARD_DB` | the board file to operate on (same variable the CLI reads) |
|
|
45
|
+
| `TACKBOARD_AGENT` | the actor every mutation is attributed to |
|
|
46
|
+
| `TACK_CMD` | overrides the CLI command prefix; defaults to the installed core module, or `tack` when the core is not importable |
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
TACKBOARD_DB=~/.local/state/board.sqlite3 TACKBOARD_AGENT=assistant tackboard-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The process speaks MCP over stdio. Register it with your client the way that
|
|
53
|
+
client documents stdio servers:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"tackboard": {
|
|
59
|
+
"command": "tackboard-mcp",
|
|
60
|
+
"env": {
|
|
61
|
+
"TACKBOARD_DB": "/path/to/board.sqlite3",
|
|
62
|
+
"TACKBOARD_AGENT": "assistant"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Tools
|
|
70
|
+
|
|
71
|
+
| Tool | Arguments | Returns |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `next_claim` | `stage?` (`work`/`review`) | the `next --claim` document; `task` is `null` when nothing is eligible |
|
|
74
|
+
| `show` | `task_id` | the `show` document: the task and its notes |
|
|
75
|
+
| `note` | `task_id`, `text`, `refs?` (`KIND:VALUE`) | the `note` document with the appended event |
|
|
76
|
+
| `complete` | `task_id` | the mutation document; completing unblocks dependents |
|
|
77
|
+
| `release` | `task_id`, `reason?` | the mutation document; a reason is the audited force path |
|
|
78
|
+
| `create` | `title`, `description?`, `priority?`, `assignee?`, `labels?`, `depends_on?`, `status?` | the created task document |
|
|
79
|
+
| `board_brief` | — | the counts-only board document |
|
|
80
|
+
|
|
81
|
+
Every tool declares all four MCP annotation hints as explicit booleans. The two
|
|
82
|
+
read tools (`board_brief`, `show`) are read-only and repeatable; every mutating
|
|
83
|
+
tool writes only the one local board and touches no external system
|
|
84
|
+
(`openWorldHint` false); nothing on this surface deletes data
|
|
85
|
+
(`destructiveHint` false); and `complete` is idempotent while `next_claim`,
|
|
86
|
+
`note`, `release` and `create` are not.
|
|
87
|
+
|
|
88
|
+
Each tool returns the CLI's JSON document unchanged. Exit codes are not
|
|
89
|
+
translated into a second vocabulary: a failed call raises with the CLI's
|
|
90
|
+
`error.code`, `exit_code` and `details` intact.
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run pytest packages/tackboard-mcp/tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The tool layer is tested without the framework: argument mapping against a fake
|
|
99
|
+
transport, plus a scratch board driven through the real CLI. The binding tests
|
|
100
|
+
(`test_server.py`) run only when `mcp` is installed — including a real client
|
|
101
|
+
handshake over stdio — and skip otherwise, so the core suite never needs the
|
|
102
|
+
framework.
|
|
103
|
+
|
|
104
|
+
## Limits
|
|
105
|
+
|
|
106
|
+
- One board per process: the database is fixed at start-up.
|
|
107
|
+
- The web view and the schema commands are not exposed; they are not tool-shaped.
|
|
108
|
+
- `--force` is reachable only through `release`'s `reason`, deliberately: the
|
|
109
|
+
other force cases are operator decisions, not assistant ones.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tackboard-mcp"
|
|
3
|
+
version = "0.8.0"
|
|
4
|
+
description = "Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"tackboard>=0.8.0,<0.9",
|
|
9
|
+
"mcp>=2.3,<3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.license]
|
|
13
|
+
text = "MIT"
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
tackboard-mcp = "tackboard_mcp.server:main"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources.tackboard]
|
|
19
|
+
workspace = true
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "tackboard_mcp"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.7,<0.13"]
|
|
26
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tackboard-mcp"
|
|
3
|
+
version = "0.8.0"
|
|
4
|
+
description = "Optional MCP adapter for the tackboard CLI — exposes board tools to MCP clients"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = ["tackboard>=0.8.0,<0.9", "mcp>=2.3,<3"]
|
|
9
|
+
|
|
10
|
+
[tool.uv.sources]
|
|
11
|
+
tackboard = { workspace = true }
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
tackboard-mcp = "tackboard_mcp.server:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.7,<0.13"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv.build-backend]
|
|
21
|
+
module-name = "tackboard_mcp"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Optional MCP adapter for the ``tack`` board CLI.
|
|
2
|
+
|
|
3
|
+
The tool layer (:mod:`tackboard_mcp.tools`) has no MCP dependency and can be
|
|
4
|
+
used directly; :mod:`tackboard_mcp.server` is the thin MCP binding and imports
|
|
5
|
+
the framework only when it is asked to build a server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .tools import Document, TackCli, TackCommandError
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["Document", "TackCli", "TackCommandError", "__version__"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The MCP binding: one tool per board operation, over the CLI.
|
|
2
|
+
|
|
3
|
+
This module is the only place the MCP framework is imported, and only inside
|
|
4
|
+
:func:`build_server` — so importing :mod:`tackboard_mcp` or its tool layer never
|
|
5
|
+
requires the framework, and the core ``tackboard`` package never depends on it.
|
|
6
|
+
|
|
7
|
+
Every tool returns the CLI's JSON document unchanged. A failed call raises, and
|
|
8
|
+
the framework turns that into a tool error whose text is the CLI's own message.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .tools import TackCli
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - typing only, the framework is optional
|
|
18
|
+
from mcp.server.mcpserver import MCPServer
|
|
19
|
+
|
|
20
|
+
INSTRUCTIONS = """\
|
|
21
|
+
This server exposes one project's tackboard through the `tack` CLI.
|
|
22
|
+
|
|
23
|
+
Read the board before acting: board_brief for counts, then next_claim to take
|
|
24
|
+
work atomically. A claim is a promise to every other agent — complete the task
|
|
25
|
+
when the work is done, and leave a note with evidence (commit ids, paths, urls)
|
|
26
|
+
at every handoff. `show` returns the full task including notes. `release`
|
|
27
|
+
without a reason gives up a claim you hold; with a reason it is the audited
|
|
28
|
+
force path, so use it only to recover a dead agent's claim.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _server_class() -> type[MCPServer]:
|
|
33
|
+
"""The framework's server class, under either release line's name.
|
|
34
|
+
|
|
35
|
+
MCP 2.x renamed ``mcp.server.fastmcp.FastMCP`` to
|
|
36
|
+
``mcp.server.mcpserver.MCPServer``; the decorator surface this adapter uses
|
|
37
|
+
is the same, so it supports both rather than pinning a client's toolchain.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
from mcp.server.mcpserver import MCPServer as _MCPServer
|
|
41
|
+
except ImportError: # pragma: no cover - 1.x
|
|
42
|
+
from mcp.server.fastmcp import FastMCP as _MCPServer # type: ignore[attr-defined,no-redef]
|
|
43
|
+
return _MCPServer
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_server(cli: TackCli) -> MCPServer:
|
|
47
|
+
"""Build the MCP server for ``cli``'s board.
|
|
48
|
+
|
|
49
|
+
Imported lazily: the MCP framework lives only in this package's metadata,
|
|
50
|
+
so a checkout without it can still use the tool layer and its tests.
|
|
51
|
+
"""
|
|
52
|
+
from mcp.types import ToolAnnotations
|
|
53
|
+
|
|
54
|
+
server = _server_class()("tackboard", instructions=INSTRUCTIONS)
|
|
55
|
+
|
|
56
|
+
@server.tool(
|
|
57
|
+
annotations=ToolAnnotations(
|
|
58
|
+
read_only_hint=False,
|
|
59
|
+
destructive_hint=False,
|
|
60
|
+
idempotent_hint=False,
|
|
61
|
+
open_world_hint=False,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
def next_claim(stage: str | None = None) -> dict[str, Any]:
|
|
65
|
+
"""Claim and return the next eligible task. `stage` may be "review"."""
|
|
66
|
+
return cli.next_claim(stage)
|
|
67
|
+
|
|
68
|
+
@server.tool(
|
|
69
|
+
annotations=ToolAnnotations(
|
|
70
|
+
read_only_hint=True,
|
|
71
|
+
destructive_hint=False,
|
|
72
|
+
idempotent_hint=True,
|
|
73
|
+
open_world_hint=False,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
def show(task_id: str) -> dict[str, Any]:
|
|
77
|
+
"""Read one task in full, including its notes."""
|
|
78
|
+
return cli.show(task_id)
|
|
79
|
+
|
|
80
|
+
@server.tool(
|
|
81
|
+
annotations=ToolAnnotations(
|
|
82
|
+
read_only_hint=False,
|
|
83
|
+
destructive_hint=False,
|
|
84
|
+
idempotent_hint=False,
|
|
85
|
+
open_world_hint=False,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
def note(task_id: str, text: str, refs: list[str] | None = None) -> dict[str, Any]:
|
|
89
|
+
"""Append a handoff note; refs are KIND:VALUE strings (commit/url/path/task)."""
|
|
90
|
+
return cli.note(task_id, text, tuple(refs or ()))
|
|
91
|
+
|
|
92
|
+
@server.tool(
|
|
93
|
+
annotations=ToolAnnotations(
|
|
94
|
+
read_only_hint=False,
|
|
95
|
+
destructive_hint=False,
|
|
96
|
+
idempotent_hint=True,
|
|
97
|
+
open_world_hint=False,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
def complete(task_id: str) -> dict[str, Any]:
|
|
101
|
+
"""Finish a task, unblocking its dependents."""
|
|
102
|
+
return cli.complete(task_id)
|
|
103
|
+
|
|
104
|
+
@server.tool(
|
|
105
|
+
annotations=ToolAnnotations(
|
|
106
|
+
read_only_hint=False,
|
|
107
|
+
destructive_hint=False,
|
|
108
|
+
idempotent_hint=False,
|
|
109
|
+
open_world_hint=False,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
def release(task_id: str, reason: str | None = None) -> dict[str, Any]:
|
|
113
|
+
"""Release a claim; a reason makes it the audited force path."""
|
|
114
|
+
return cli.release(task_id, reason)
|
|
115
|
+
|
|
116
|
+
@server.tool(
|
|
117
|
+
annotations=ToolAnnotations(
|
|
118
|
+
read_only_hint=False,
|
|
119
|
+
destructive_hint=False,
|
|
120
|
+
idempotent_hint=False,
|
|
121
|
+
open_world_hint=False,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
def create(
|
|
125
|
+
title: str,
|
|
126
|
+
description: str | None = None,
|
|
127
|
+
priority: str | None = None,
|
|
128
|
+
assignee: str | None = None,
|
|
129
|
+
labels: list[str] | None = None,
|
|
130
|
+
depends_on: list[str] | None = None,
|
|
131
|
+
status: str | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Create a task, optionally with labels, assignment and prerequisite ids."""
|
|
134
|
+
return cli.create(
|
|
135
|
+
title,
|
|
136
|
+
description=description,
|
|
137
|
+
priority=priority,
|
|
138
|
+
assignee=assignee,
|
|
139
|
+
labels=tuple(labels or ()),
|
|
140
|
+
depends_on=tuple(depends_on or ()),
|
|
141
|
+
status=status,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@server.tool(
|
|
145
|
+
annotations=ToolAnnotations(
|
|
146
|
+
read_only_hint=True,
|
|
147
|
+
destructive_hint=False,
|
|
148
|
+
idempotent_hint=True,
|
|
149
|
+
open_world_hint=False,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
def board_brief() -> dict[str, Any]:
|
|
153
|
+
"""The board's counts only: the cheapest look at what is on the board."""
|
|
154
|
+
return cli.board_brief()
|
|
155
|
+
|
|
156
|
+
return server
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> int:
|
|
160
|
+
"""Start the server over stdio, configured from the environment."""
|
|
161
|
+
build_server(TackCli.from_environment()).run()
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__": # pragma: no cover - entry point
|
|
166
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The tool layer: one function per board operation, each a ``tack`` call.
|
|
2
|
+
|
|
3
|
+
There is deliberately no domain logic here. Every tool shells out to the CLI
|
|
4
|
+
with ``--json`` and returns that JSON document unchanged, so the adapter can
|
|
5
|
+
never disagree with the board: the CLI is the only implementation of claims,
|
|
6
|
+
transitions, validation and exit codes. A non-zero exit becomes a
|
|
7
|
+
``TackCommandError`` carrying the CLI's own error document, so an MCP client
|
|
8
|
+
sees exactly what an agent at a terminal would see.
|
|
9
|
+
|
|
10
|
+
The runner is injectable (``runner=``) so tests can exercise argument building
|
|
11
|
+
without a board; production always spawns the real executable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from importlib.util import find_spec
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
#: A runner takes the full argv and stdin text and returns
|
|
28
|
+
#: ``(returncode, stdout, stderr)``. The default spawns a process.
|
|
29
|
+
Runner = Callable[[Sequence[str], str | None], tuple[int, str, str]]
|
|
30
|
+
|
|
31
|
+
#: A ``tack --json`` document.
|
|
32
|
+
Document = dict[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TackCommandError(RuntimeError):
|
|
36
|
+
"""A ``tack`` call failed; carries the CLI's error document when it sent one."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
argv: Sequence[str],
|
|
41
|
+
returncode: int,
|
|
42
|
+
message: str,
|
|
43
|
+
document: Document | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(f"tack exited {returncode}: {' '.join(argv)}")
|
|
46
|
+
self.argv = tuple(argv)
|
|
47
|
+
self.returncode = returncode
|
|
48
|
+
self.stderr = message
|
|
49
|
+
self.document = document
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _subprocess_runner(argv: Sequence[str], stdin: str | None) -> tuple[int, str, str]:
|
|
53
|
+
completed = subprocess.run(
|
|
54
|
+
list(argv), input=stdin, capture_output=True, text=True, check=False
|
|
55
|
+
)
|
|
56
|
+
return completed.returncode, completed.stdout, completed.stderr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse(argv: Sequence[str], returncode: int, text: str) -> Document:
|
|
60
|
+
try:
|
|
61
|
+
document = json.loads(text)
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise TackCommandError(argv, returncode, f"stdout is not JSON: {text[:200]!r}") from exc
|
|
64
|
+
if not isinstance(document, dict):
|
|
65
|
+
raise TackCommandError(
|
|
66
|
+
argv, returncode, f"stdout is not a JSON object: {text[:200]!r}"
|
|
67
|
+
)
|
|
68
|
+
return document
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def _environment(actor: str | None) -> Iterator[None]:
|
|
73
|
+
"""Set ``TACKBOARD_AGENT`` for one call and restore the process state after."""
|
|
74
|
+
saved = os.environ.get("TACKBOARD_AGENT")
|
|
75
|
+
if actor is None:
|
|
76
|
+
os.environ.pop("TACKBOARD_AGENT", None)
|
|
77
|
+
else:
|
|
78
|
+
os.environ["TACKBOARD_AGENT"] = actor
|
|
79
|
+
try:
|
|
80
|
+
yield
|
|
81
|
+
finally:
|
|
82
|
+
if saved is None:
|
|
83
|
+
os.environ.pop("TACKBOARD_AGENT", None)
|
|
84
|
+
else:
|
|
85
|
+
os.environ["TACKBOARD_AGENT"] = saved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _default_command() -> tuple[str, ...]:
|
|
89
|
+
"""Prefer the installed core module, falling back to the console script.
|
|
90
|
+
|
|
91
|
+
The adapter depends on ``tackboard``, so the core is importable from the
|
|
92
|
+
same environment in the normal case: running ``sys.executable -m tackboard``
|
|
93
|
+
avoids depending on ``tack`` being on ``PATH``. When the adapter lives in a
|
|
94
|
+
different environment from the CLI, ``tackboard`` is not importable and the
|
|
95
|
+
historical ``tack`` lookup remains the fallback. ``TACK_CMD`` still takes
|
|
96
|
+
first precedence in ``from_environment``.
|
|
97
|
+
"""
|
|
98
|
+
if find_spec("tackboard") is not None:
|
|
99
|
+
return (sys.executable, "-m", "tackboard")
|
|
100
|
+
return ("tack",)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class TackCli:
|
|
105
|
+
"""A configured ``tack`` invocation: command prefix, board and actor.
|
|
106
|
+
|
|
107
|
+
``command`` is the executable plus any fixed prefix, e.g. ``("tack",)`` or
|
|
108
|
+
``(sys.executable, "-m", "tackboard")``. ``db`` and ``actor`` become
|
|
109
|
+
``--db`` and ``TACKBOARD_AGENT`` on every call.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
command: tuple[str, ...] = ("tack",)
|
|
113
|
+
db: Path | None = None
|
|
114
|
+
actor: str | None = None
|
|
115
|
+
runner: Runner = _subprocess_runner
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_environment(
|
|
119
|
+
cls, environ: Mapping[str, str] | None = None, runner: Runner | None = None
|
|
120
|
+
) -> TackCli:
|
|
121
|
+
"""Build from ``TACK_CMD``, ``TACKBOARD_DB`` and ``TACKBOARD_AGENT``."""
|
|
122
|
+
env = os.environ if environ is None else environ
|
|
123
|
+
command = tuple(env.get("TACK_CMD", "").split()) or _default_command()
|
|
124
|
+
db_text = env.get("TACKBOARD_DB")
|
|
125
|
+
return cls(
|
|
126
|
+
command=command,
|
|
127
|
+
db=Path(db_text) if db_text else None,
|
|
128
|
+
actor=env.get("TACKBOARD_AGENT"),
|
|
129
|
+
runner=runner or _subprocess_runner,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _argv(self, *argv: str) -> list[str]:
|
|
133
|
+
taken = [*self.command, *argv, "--json"]
|
|
134
|
+
if self.db is not None:
|
|
135
|
+
taken.extend(["--db", str(self.db)])
|
|
136
|
+
return taken
|
|
137
|
+
|
|
138
|
+
def run(self, *argv: str, stdin: str | None = None) -> Document:
|
|
139
|
+
"""Run one ``tack`` command and return its JSON document unchanged."""
|
|
140
|
+
full = self._argv(*argv)
|
|
141
|
+
with _environment(self.actor):
|
|
142
|
+
returncode, stdout, stderr = self.runner(full, stdin)
|
|
143
|
+
text = stdout.strip()
|
|
144
|
+
if returncode != 0:
|
|
145
|
+
document = None
|
|
146
|
+
if text:
|
|
147
|
+
try:
|
|
148
|
+
parsed = json.loads(text)
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
parsed = None
|
|
151
|
+
document = parsed if isinstance(parsed, dict) else None
|
|
152
|
+
raise TackCommandError(full, returncode, stderr.strip() or text, document)
|
|
153
|
+
if not text:
|
|
154
|
+
raise TackCommandError(full, returncode, "tack printed no JSON document")
|
|
155
|
+
return _parse(full, returncode, text)
|
|
156
|
+
|
|
157
|
+
# -- the board operations ------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def next_claim(self, stage: str | None = None) -> Document:
|
|
160
|
+
"""Claim and return the next eligible task (``task`` may be ``null``)."""
|
|
161
|
+
argv: list[str] = ["next", "--claim"]
|
|
162
|
+
if stage is not None:
|
|
163
|
+
argv.extend(["--stage", stage])
|
|
164
|
+
return self.run(*argv)
|
|
165
|
+
|
|
166
|
+
def show(self, task_id: str) -> Document:
|
|
167
|
+
"""One task in full, with its notes."""
|
|
168
|
+
return self.run("show", task_id)
|
|
169
|
+
|
|
170
|
+
def note(self, task_id: str, text: str, refs: Sequence[str] = ()) -> Document:
|
|
171
|
+
"""Append a handoff note; ``refs`` are ``KIND:VALUE`` strings."""
|
|
172
|
+
argv = ["note", task_id, text]
|
|
173
|
+
for ref in refs:
|
|
174
|
+
argv.extend(["--ref", ref])
|
|
175
|
+
return self.run(*argv)
|
|
176
|
+
|
|
177
|
+
def complete(self, task_id: str) -> Document:
|
|
178
|
+
"""Finish a task; its dependents become eligible."""
|
|
179
|
+
return self.run("complete", task_id)
|
|
180
|
+
|
|
181
|
+
def release(self, task_id: str, reason: str | None = None) -> Document:
|
|
182
|
+
"""Release a claim; a reason forces (audited) rather than plain release."""
|
|
183
|
+
argv = ["release", task_id]
|
|
184
|
+
if reason is not None:
|
|
185
|
+
argv.extend(["--force", "--reason", reason])
|
|
186
|
+
return self.run(*argv)
|
|
187
|
+
|
|
188
|
+
def create(
|
|
189
|
+
self,
|
|
190
|
+
title: str,
|
|
191
|
+
*,
|
|
192
|
+
description: str | None = None,
|
|
193
|
+
priority: str | None = None,
|
|
194
|
+
assignee: str | None = None,
|
|
195
|
+
labels: Sequence[str] = (),
|
|
196
|
+
depends_on: Sequence[str] = (),
|
|
197
|
+
status: str | None = None,
|
|
198
|
+
) -> Document:
|
|
199
|
+
"""Create a task, optionally with its dependency edges."""
|
|
200
|
+
argv = ["create", title]
|
|
201
|
+
if description is not None:
|
|
202
|
+
argv.extend(["--description", description])
|
|
203
|
+
if priority is not None:
|
|
204
|
+
argv.extend(["--priority", priority])
|
|
205
|
+
if assignee is not None:
|
|
206
|
+
argv.extend(["--assignee", assignee])
|
|
207
|
+
if status is not None:
|
|
208
|
+
argv.extend(["--status", status])
|
|
209
|
+
for label in labels:
|
|
210
|
+
argv.extend(["--label", label])
|
|
211
|
+
for prerequisite in depends_on:
|
|
212
|
+
argv.extend(["--depends-on", prerequisite])
|
|
213
|
+
return self.run(*argv)
|
|
214
|
+
|
|
215
|
+
def board_brief(self) -> Document:
|
|
216
|
+
"""The counts-only board document: the cheapest look at a board."""
|
|
217
|
+
return self.run("board", "--brief")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = ["Document", "Runner", "TackCli", "TackCommandError"]
|