feat(kanban): add optional board parameter to all MCP tools

Salvages #27598 by @nnnet. Adds optional 'board' parameter to all 9
kanban_* MCP tools via shared _connect helper. Backwards compatible —
omitting board keeps current pinned-board behavior. Useful for
orchestrator profiles that route across multiple boards.

Two-file scope: tools/kanban_tools.py + tests.
This commit is contained in:
nnnet 2026-05-18 21:11:24 -07:00 committed by Teknium
parent 3ee7a5546d
commit 8e193cf05c
2 changed files with 411 additions and 12 deletions

View file

@ -161,11 +161,19 @@ def _enforce_worker_task_ownership(tid: str) -> Optional[str]:
return None
def _connect():
def _connect(board: Optional[str] = None):
"""Import + connect lazily so the module imports cleanly in non-kanban
contexts (e.g. test rigs that import every tool module)."""
contexts (e.g. test rigs that import every tool module).
When ``board`` is provided it's forwarded to :func:`kb.connect`, which
routes the connection to that board's sqlite file. ``None`` (the
default) preserves the legacy resolution chain
(``HERMES_KANBAN_DB`` ``HERMES_KANBAN_BOARD`` env current symlink
``default``). Per-tool ``board`` lets a Telegram-side agent override
the env-pinned active board without restarting Hermes.
"""
from hermes_cli import kanban_db as kb
return kb, kb.connect()
return kb, kb.connect(board=board)
def _ok(**fields: Any) -> str:
@ -252,8 +260,9 @@ def _handle_show(args: dict, **kw) -> str:
return tool_error(
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
)
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
task = kb.get_task(conn, tid)
if task is None:
@ -311,6 +320,9 @@ def _handle_show(args: dict, **kw) -> str:
})
finally:
conn.close()
except ValueError as e:
# Invalid board slug surfaces as ValueError from _normalize_board_slug.
return tool_error(f"kanban_show: {e}")
except Exception as e:
logger.exception("kanban_show failed")
return tool_error(f"kanban_show: {e}")
@ -338,8 +350,9 @@ def _handle_list(args: dict, **kw) -> str:
return tool_error("limit must be >= 1")
if limit > KANBAN_LIST_MAX_LIMIT:
return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}")
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
# Match CLI list: dependencies that cleared since the last
# dispatcher tick should be visible to orchestrators immediately.
@ -452,8 +465,9 @@ def _handle_complete(args: dict, **kw) -> str:
f"metadata must be an object/dict, got {type(metadata).__name__}"
)
metadata = _stamp_worker_session_metadata(tid, metadata)
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
try:
ok = kb.complete_task(
@ -490,6 +504,8 @@ def _handle_complete(args: dict, **kw) -> str:
return _ok(task_id=tid, run_id=run.id if run else None)
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_complete: {e}")
except Exception as e:
logger.exception("kanban_complete failed")
return tool_error(f"kanban_complete: {e}")
@ -508,8 +524,9 @@ def _handle_block(args: dict, **kw) -> str:
reason = args.get("reason")
if not reason or not str(reason).strip():
return tool_error("reason is required — explain what input you need")
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
ok = kb.block_task(
conn, tid,
@ -525,6 +542,8 @@ def _handle_block(args: dict, **kw) -> str:
return _ok(task_id=tid, run_id=run.id if run else None)
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_block: {e}")
except Exception as e:
logger.exception("kanban_block failed")
return tool_error(f"kanban_block: {e}")
@ -549,8 +568,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
if ownership_err:
return ownership_err
note = args.get("note")
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
# Extend the claim TTL first. The dispatcher pins
# HERMES_KANBAN_CLAIM_LOCK in the worker env at spawn time
@ -573,6 +593,8 @@ def _handle_heartbeat(args: dict, **kw) -> str:
return _ok(task_id=tid)
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_heartbeat: {e}")
except Exception as e:
logger.exception("kanban_heartbeat failed")
return tool_error(f"kanban_heartbeat: {e}")
@ -599,13 +621,16 @@ def _handle_comment(args: dict, **kw) -> str:
# Cross-task commenting itself remains unrestricted (see #19713) —
# comments are the deliberate handoff channel between tasks.
author = os.environ.get("HERMES_PROFILE") or "worker"
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
cid = kb.add_comment(conn, tid, author=author, body=str(body))
return _ok(task_id=tid, comment_id=cid)
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_comment: {e}")
except Exception as e:
logger.exception("kanban_comment failed")
return tool_error(f"kanban_comment: {e}")
@ -652,8 +677,9 @@ def _handle_create(args: dict, **kw) -> str:
return tool_error(
f"parents must be a list of task ids, got {type(parents).__name__}"
)
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
new_tid = kb.create_task(
conn,
@ -700,8 +726,9 @@ def _handle_unblock(args: dict, **kw) -> str:
ownership_err = _enforce_worker_task_ownership(str(tid))
if ownership_err:
return ownership_err
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
ok = kb.unblock_task(conn, str(tid))
if not ok:
@ -709,6 +736,8 @@ def _handle_unblock(args: dict, **kw) -> str:
return _ok(task_id=str(tid), status="ready")
finally:
conn.close()
except ValueError as e:
return tool_error(f"kanban_unblock: {e}")
except Exception as e:
logger.exception("kanban_unblock failed")
return tool_error(f"kanban_unblock: {e}")
@ -720,8 +749,9 @@ def _handle_link(args: dict, **kw) -> str:
child_id = args.get("child_id")
if not parent_id or not child_id:
return tool_error("both parent_id and child_id are required")
board = args.get("board")
try:
kb, conn = _connect()
kb, conn = _connect(board=board)
try:
kb.link_tasks(conn, parent_id=parent_id, child_id=child_id)
return _ok(parent_id=parent_id, child_id=child_id)
@ -744,6 +774,24 @@ _DESC_TASK_ID_DEFAULT = (
"(the task the dispatcher spawned you to work on)."
)
_DESC_BOARD = (
"Kanban board slug to target. When omitted, the call resolves the "
"active board the usual way: HERMES_KANBAN_DB env → "
"HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban "
"home → 'default'. Pass an explicit slug only when the caller (e.g. "
"a Telegram routing layer) needs to override the env-pinned active "
"board for this one call."
)
def _board_schema_prop() -> dict[str, str]:
"""Schema fragment for the optional ``board`` parameter.
Centralised so a future tweak to the description / validation hint
only has to land in one place.
"""
return {"type": "string", "description": _DESC_BOARD}
KANBAN_SHOW_SCHEMA = {
"name": "kanban_show",
"description": (
@ -761,6 +809,7 @@ KANBAN_SHOW_SCHEMA = {
"type": "string",
"description": _DESC_TASK_ID_DEFAULT,
},
"board": _board_schema_prop(),
},
"required": [],
},
@ -805,6 +854,7 @@ KANBAN_LIST_SCHEMA = {
"type": "integer",
"description": "Optional maximum rows to return (default 50, max 200).",
},
"board": _board_schema_prop(),
},
"required": [],
},
@ -897,6 +947,7 @@ KANBAN_COMPLETE_SCHEMA = {
"are silently skipped."
),
},
"board": _board_schema_prop(),
},
"required": [],
},
@ -926,6 +977,7 @@ KANBAN_BLOCK_SCHEMA = {
"the board and can ask follow-ups via comments."
),
},
"board": _board_schema_prop(),
},
"required": ["reason"],
},
@ -953,6 +1005,7 @@ KANBAN_HEARTBEAT_SCHEMA = {
"Shown in the event log."
),
},
"board": _board_schema_prop(),
},
"required": [],
},
@ -980,6 +1033,7 @@ KANBAN_COMMENT_SCHEMA = {
"type": "string",
"description": "Markdown-supported comment body.",
},
"board": _board_schema_prop(),
},
"required": ["task_id", "body"],
},
@ -1107,6 +1161,7 @@ KANBAN_CREATE_SCHEMA = {
"assignee's profile."
),
},
"board": _board_schema_prop(),
},
"required": ["title", "assignee"],
},
@ -1126,6 +1181,7 @@ KANBAN_UNBLOCK_SCHEMA = {
"type": "string",
"description": "Blocked task id to return to ready.",
},
"board": _board_schema_prop(),
},
"required": ["task_id"],
},
@ -1143,6 +1199,7 @@ KANBAN_LINK_SCHEMA = {
"properties": {
"parent_id": {"type": "string", "description": "Parent task id."},
"child_id": {"type": "string", "description": "Child task id."},
"board": _board_schema_prop(),
},
"required": ["parent_id", "child_id"],
},