rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

app/boards/doors/mcp.py

"""The MCP door: the same use cases as the web routes, as tools Claude or ChatGPT can call.

Each tool acts as the person who connected (their gateway identity), so an agent sees exactly
the boards its person sees. Tools are actions (move_card, assign_card), never table access.
Every tool carries MCP annotations: reads are marked read-only (ChatGPT and Claude run them
without asking), archive and member removal are marked destructive.
"""
from typing import Optional, Union

from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

from boards.application.use_cases import Boards, NotFound, Unavailable
from boards.doors.identity import person_from
from boards.domain.model import Conflict, Forbidden, Invalid

INSTRUCTIONS = """Lanes: team task boards (boards -> lists -> cards).
- Boards can be named by title ("Ops", "the Ops board") or id. Cards are numbered per board:
  "card 12" is card=12; add board=... when the same number exists on several boards. A card
  can also be named by its title ("the launch card" -> card="launch"); if several match, the
  error lists them with their numbers so you can pick one.
- "What's on my plate this week?" -> my_cards(days=7). "What's overdue?" -> overdue().
- "Add a card 'Send invoice' to Doing on the Ops board" -> add_card(board="Ops",
  title="Send invoice", list_name="Doing").
- "Move card 12 to Done" -> move_card(card=12, list_name="Done").
- "What is blocking the launch?" -> what_blocks(card="launch") (or blocked_cards() for
  everything that is waiting on something).
- Viewers can read but not change a board; only the owner manages members. You only ever see
  boards the signed-in person is on: a board or card you can't see answers "no such ...".
- Due dates: 2026-11-07, or today / tomorrow / friday / "in 3 days". Assignee is an email,
  "me", or "nobody". Times in history are UTC (ISO 8601)."""

Ref = Union[str, int]
Strings = Union[list[str], str, None]

READ = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=False)
WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
IDEMPOTENT = ToolAnnotations(read_only_hint=False, destructive_hint=False,
                             idempotent_hint=True, open_world_hint=False)
DESTRUCTIVE = ToolAnnotations(read_only_hint=False, destructive_hint=True, open_world_hint=False)


def build_mcp(boards: Boards) -> MCPServer:
    server = MCPServer(name="lanes", instructions=INSTRUCTIONS)

    def call(ctx: Context, fn, *args, **kwargs):
        person = person_from(ctx.headers or {})
        if person is None:
            raise ToolError("401: sign in first")
        try:
            return fn(person, *args, **kwargs)
        except (NotFound, Forbidden, Invalid, Conflict, Unavailable) as e:
            raise ToolError(str(e))

    def many(key: str, rows: list) -> dict:
        """Lists go out as {"count": n, key: [...]}: one structured result, and "count": 0
        tells the assistant plainly that there is nothing, instead of an empty reply."""
        return {"count": len(rows), key: rows}

    # ---------------------------------------------------------------- boards
    @server.tool(title="My boards", annotations=READ)
    def my_boards(ctx: Context) -> dict:
        """The boards you are on, with your role, card count and overdue count."""
        return many("boards", call(ctx, boards.my_boards))

    @server.tool(title="Show a board", annotations=READ)
    def show_board(board: str, ctx: Context) -> dict:
        """A board (title or id) with its lists, cards, members and your role."""
        return call(ctx, boards.show_board, board)

    @server.tool(title="Create a board", annotations=WRITE)
    def create_board(title: str, ctx: Context) -> dict:
        """Create a board with lists To do, Doing, Done. You become its owner."""
        return call(ctx, boards.create_board, title)

    @server.tool(title="Rename a board", annotations=IDEMPOTENT)
    def rename_board(board: str, title: str, ctx: Context) -> dict:
        """Owner only: give a board a new title."""
        return call(ctx, boards.rename_board, board, title)

    @server.tool(title="Add a list", annotations=WRITE)
    def add_list(board: str, name: str, ctx: Context) -> dict:
        """Add a list (column) to a board. It goes just before Done."""
        return call(ctx, boards.add_list, board, name)

    @server.tool(title="Remove a list", annotations=DESTRUCTIVE)
    def remove_list(board: str, name: str, ctx: Context) -> dict:
        """Remove an empty list from a board (move its cards out first)."""
        return call(ctx, boards.remove_list, board, name)

    # ---------------------------------------------------------------- cards
    @server.tool(title="Add a card", annotations=WRITE)
    def add_card(board: str, title: str, ctx: Context, list_name: str = "",
                 description: str = "", assignee: Optional[str] = None,
                 due_date: Optional[str] = None, labels: Strings = None) -> dict:
        """Add a card. list_name defaults to the first list; assignee is an email or 'me';
        due_date is YYYY-MM-DD or today / tomorrow / friday / 'in 3 days'; labels is a list
        or a comma-separated string."""
        return call(ctx, boards.add_card, board, title, list_name, description, assignee,
                    due_date, labels)

    @server.tool(title="Move a card", annotations=IDEMPOTENT)
    def move_card(card: Ref, list_name: str, ctx: Context, board: Optional[str] = None) -> dict:
        """Move a card (its number, id or title) to another list, e.g. 'Done'."""
        return call(ctx, boards.move_card, card, list_name, board)

    @server.tool(title="Assign a card", annotations=IDEMPOTENT)
    def assign_card(card: Ref, assignee: str, ctx: Context,
                    board: Optional[str] = None) -> dict:
        """Assign a card to someone on the board (email, or 'me'); 'nobody' unassigns."""
        return call(ctx, boards.assign_card, card, assignee, board)

    @server.tool(title="Update a card", annotations=IDEMPOTENT)
    def update_card(card: Ref, ctx: Context, board: Optional[str] = None,
                    title: Optional[str] = None, description: Optional[str] = None,
                    due_date: Optional[str] = None, labels: Strings = None) -> dict:
        """Change a card's title, description, due date ('' clears it) or labels (the full
        new list). Only the fields you pass change."""
        return call(ctx, boards.update_card, card, board, title, description, due_date, labels)

    @server.tool(title="Archive a card", annotations=DESTRUCTIVE)
    def archive_card(card: Ref, ctx: Context, board: Optional[str] = None) -> dict:
        """Archive a card (it leaves the board; its history is kept)."""
        return call(ctx, boards.archive_card, card, board)

    @server.tool(title="Card details", annotations=READ)
    def card_details(card: Ref, ctx: Context, board: Optional[str] = None) -> dict:
        """Everything about a card: fields, checklist, what blocks it, what it blocks, and
        its full history."""
        return call(ctx, boards.card_details, card, board)

    @server.tool(title="Card history", annotations=READ)
    def card_history(card: Ref, ctx: Context, board: Optional[str] = None) -> dict:
        """Every change to a card, newest first: who, when (UTC), which field, old and new."""
        return many("history", call(ctx, boards.card_history, card, board))

    @server.tool(title="Board activity", annotations=READ)
    def board_activity(board: str, ctx: Context, limit: int = 30) -> dict:
        """Recent changes across a board, newest first."""
        return many("activity", call(ctx, boards.board_activity, board, limit))

    # ---------------------------------------------------------------- questions
    @server.tool(title="My cards", annotations=READ)
    def my_cards(ctx: Context, days: Optional[int] = None) -> dict:
        """Open cards assigned to you on every board. days=7 means 'this week': only cards
        due within 7 days, overdue ones included. Leave days out for everything."""
        return many("cards", call(ctx, boards.my_cards, days))

    @server.tool(title="Overdue cards", annotations=READ)
    def overdue(ctx: Context, board: Optional[str] = None) -> dict:
        """Cards past their due date and not done, on all your boards (or one)."""
        return many("cards", call(ctx, boards.overdue, board))

    @server.tool(title="Search cards", annotations=READ)
    def search_cards(text: str, ctx: Context, board: Optional[str] = None) -> dict:
        """Find cards whose title, description or labels contain all these words."""
        return many("cards", call(ctx, boards.search_cards, text, board))

    @server.tool(title="Find similar cards", annotations=READ)
    def find_similar(ctx: Context, card: Optional[Ref] = None, text: Optional[str] = None,
                     board: Optional[str] = None, limit: int = 5) -> dict:
        """Search by meaning (Dodil Models embeddings): cards like a given card, or like a
        sentence, across the boards you can see."""
        return many("cards", call(ctx, boards.find_similar, card, text, board, limit))

    # ---------------------------------------------------------------- dependencies
    @server.tool(title="Add a blocker", annotations=IDEMPOTENT)
    def add_blocker(card: Ref, blocked_by: Ref, ctx: Context,
                    board: Optional[str] = None) -> dict:
        """Record that `card` is blocked by `blocked_by` (both on the same board)."""
        return call(ctx, boards.add_blocker, card, blocked_by, board)

    @server.tool(title="Remove a blocker", annotations=WRITE)
    def remove_blocker(card: Ref, blocked_by: Ref, ctx: Context,
                       board: Optional[str] = None) -> dict:
        """Remove a 'blocked by' link."""
        return call(ctx, boards.remove_blocker, card, blocked_by, board)

    @server.tool(title="What blocks a card", annotations=READ)
    def what_blocks(card: Ref, ctx: Context, board: Optional[str] = None) -> dict:
        """Everything still open that blocks a card, all the way down the chain, and which of
        those can be started now. 'What is blocking the launch?' -> card='launch'."""
        return call(ctx, boards.what_blocks, card, board)

    @server.tool(title="Blocked cards", annotations=READ)
    def blocked_cards(ctx: Context, board: Optional[str] = None) -> dict:
        """Every open card that is waiting on another open card, with what it waits on."""
        return many("cards", call(ctx, boards.blocked_cards, board))

    # ---------------------------------------------------------------- AI
    @server.tool(title="Suggest a checklist", annotations=READ)
    def suggest_checklist(card: Ref, ctx: Context, board: Optional[str] = None) -> dict:
        """AI suggestion of a checklist for a card. Changes nothing."""
        return call(ctx, boards.suggest_checklist, card, board)

    @server.tool(title="Turn a card into a checklist", annotations=WRITE)
    def make_checklist(card: Ref, ctx: Context, items: Strings = None,
                       board: Optional[str] = None) -> dict:
        """Turn a card into a checklist and save it: the items you give (a list, or one item
        per line), or the AI's own when you give none."""
        return call(ctx, boards.make_checklist, card, items, board)

    @server.tool(title="Tick a checklist item", annotations=IDEMPOTENT)
    def tick_checklist_item(card: Ref, item_id: str, ctx: Context, done: bool = True,
                            board: Optional[str] = None) -> dict:
        """Tick (or untick) one checklist item on a card."""
        return call(ctx, boards.tick_item, card, item_id, done, board)

    @server.tool(title="Daily summary", annotations=READ)
    def daily_summary(board: str, ctx: Context, refresh: bool = False) -> dict:
        """Today's short AI summary of a board: what moved, what's overdue or blocked, who has
        what. If status is 'writing', ask again in a minute."""
        return call(ctx, boards.daily_summary, board, refresh)

    # ---------------------------------------------------------------- people
    @server.tool(title="Board members", annotations=READ)
    def members(board: str, ctx: Context) -> dict:
        """Who is on a board and their role (owner, member, viewer)."""
        return many("members", call(ctx, boards.members, board))

    @server.tool(title="Add a member", annotations=IDEMPOTENT)
    def add_member(board: str, email: str, ctx: Context, role: str = "member") -> dict:
        """Owner only: add someone to a board as 'member' or 'viewer' (or change their role)."""
        return call(ctx, boards.add_member, board, email, role)

    @server.tool(title="Remove a member", annotations=DESTRUCTIVE)
    def remove_member(board: str, email: str, ctx: Context) -> dict:
        """Owner only: take someone off a board (their cards are unassigned)."""
        return call(ctx, boards.remove_member, board, email)

    return server