sawera/sawera-boards

Sawera Boards - a team task board for the Dodil Boards Challenge. Boards, lists, cards, roles, activity log, MCP actions and AI extras. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sawera/sawera-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, never raw table access.
"""
from datetime import date
from typing import Optional

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

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

INSTRUCTIONS = ("A team task board (Sawera Boards). Start with my_boards to find board ids, "
                "then show_board to see a board's lists and cards. Lists belong to each board "
                "(a new board starts with 'To do', 'Doing', 'Done'). Cards have ids; move_card, "
                "assign_card and set_blockers act on them. my_cards answers 'what is on my "
                "plate', overdue_cards answers 'what is overdue', and what_is_blocking follows "
                "blocked-by links. Cards carry comments: card_comments reads a card's "
                "discussion and add_comment joins it. You only ever see boards the "
                "signed-in person is on.")


def _date(value):
    if value in (None, ""):
        return None
    try:
        return date.fromisoformat(str(value))
    except ValueError:
        raise Invalid("due date must look like 2026-11-03")


def build_mcp(boards: Boards) -> MCPServer:
    server = MCPServer(name="sawera-boards", 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) as e:
            raise ToolError(str(e))

    @server.tool()
    def my_boards(ctx: Context) -> list:
        """The boards you are on, with your role on each."""
        return call(ctx, boards.my_boards)

    @server.tool()
    def show_board(board_id: str, ctx: Context) -> dict:
        """A board: its lists, members and all its cards (with overdue / blocked flags)."""
        return call(ctx, boards.show_board, board_id)

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

    @server.tool()
    def delete_board(board_id: str, ctx: Context) -> dict:
        """Owner only: delete a board and everything on it (lists, cards, comments)."""
        return call(ctx, boards.delete_board, board_id)

    @server.tool()
    def add_member(board_id: str, email: str, ctx: Context, role: str = "member") -> dict:
        """Owner only: add someone to a board as 'member' (can edit) or 'viewer' (read-only)."""
        return call(ctx, boards.add_member, board_id, email, role)

    @server.tool()
    def remove_member(board_id: str, email: str, ctx: Context) -> dict:
        """Owner only: remove someone from a board."""
        return call(ctx, boards.remove_member, board_id, email)

    @server.tool()
    def add_list(board_id: str, name: str, ctx: Context) -> dict:
        """Add a list to a board (e.g. 'Review')."""
        return call(ctx, boards.add_list, board_id, name)

    @server.tool()
    def rename_list(board_id: str, list_id: str, name: str, ctx: Context) -> dict:
        """Rename one of a board's lists."""
        return call(ctx, boards.rename_list, board_id, list_id, name)

    @server.tool()
    def add_card(board_id: str, title: str, ctx: Context, list_name: str = "",
                 description: str = "", assignee: str = "", due_date: str = "",
                 labels: Optional[list] = None) -> dict:
        """Add a card to a list on a board. list_name defaults to the board's first list.
        due_date looks like 2026-11-03. Labels are short words like 'invoice'."""
        return call(ctx, boards.add_card, board_id, title,
                    list_name=list_name or None, description=description,
                    assignee=assignee or None, due_date=_date(due_date),
                    labels=labels or ())

    @server.tool()
    def update_card(card_id: str, ctx: Context, title: str = "", description: str = "",
                    due_date: str = "") -> dict:
        """Change a card's title, description or due date (only what you pass)."""
        fields = {}
        if title:
            fields["title"] = title
        if description:
            fields["description"] = description
        if due_date:
            fields["due_date"] = _date(due_date)
        return call(ctx, boards.update_card, card_id, **fields)

    @server.tool()
    def assign_card(card_id: str, email: str, ctx: Context) -> dict:
        """Give a card to someone (their email). Pass an empty email to unassign."""
        return call(ctx, boards.assign_card, card_id, email or None)

    @server.tool()
    def move_card(card_id: str, list_name: str, ctx: Context) -> dict:
        """Move a card to another list on its board, by list name (e.g. 'Done')."""
        return call(ctx, boards.move_card, card_id, list_name=list_name)

    @server.tool()
    def delete_card(card_id: str, ctx: Context) -> dict:
        """Delete a card. Cards it was blocking are freed."""
        return call(ctx, boards.delete_card, card_id)

    @server.tool()
    def set_blockers(card_id: str, blocker_ids: list, ctx: Context) -> dict:
        """Set which cards block this one ('blocked by'). Same board only; circles are refused."""
        return call(ctx, boards.set_blockers, card_id, blocker_ids)

    @server.tool()
    def what_is_blocking(card_id: str, ctx: Context) -> dict:
        """The unfinished cards holding this card up, following blocked-by links."""
        return call(ctx, boards.what_is_blocking, card_id)

    @server.tool()
    def card_comments(card_id: str, ctx: Context) -> list:
        """The discussion on a card: who said what, oldest first."""
        return call(ctx, boards.card_comments, card_id)

    @server.tool()
    def add_comment(card_id: str, body: str, ctx: Context) -> dict:
        """Leave a comment on a card, as the signed-in person. Viewers cannot comment."""
        return call(ctx, boards.add_comment, card_id, body)

    @server.tool()
    def my_cards(ctx: Context, days: int = 7) -> list:
        """Your plate: your unfinished cards across your boards, overdue first,
        then due within `days` days."""
        return call(ctx, boards.my_cards, days)

    @server.tool()
    def overdue_cards(ctx: Context) -> list:
        """Every overdue card on the boards you can see."""
        return call(ctx, boards.overdue_cards)

    @server.tool()
    def search_cards(query: str, ctx: Context) -> list:
        """Find cards by words in their title, description or labels, on your boards."""
        return call(ctx, boards.search_cards, query)

    @server.tool()
    def board_activity(board_id: str, ctx: Context, limit: int = 20) -> list:
        """The board's recent activity: who changed what, and when."""
        return call(ctx, boards.board_activity, board_id, limit)

    @server.tool()
    def suggest_checklist(card_id: str, ctx: Context) -> dict:
        """AI suggestion of a checklist for a card. Changes nothing; you decide what to keep."""
        return call(ctx, boards.suggest_checklist, card_id)

    @server.tool()
    def daily_summary(board_id: str, ctx: Context) -> dict:
        """A short AI-written summary of where a board stands today."""
        return call(ctx, boards.daily_summary, board_id)

    return server