flc/crewboard

Crewboard - team task board (boards, lists, cards, roles, history, drag and drop, MCP, AI extras). FastAPI + DataK3 + appid + Models.

A remix template · 1 remixes · Remix flc/crewboard

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 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 Conflict, Forbidden, Invalid

INSTRUCTIONS = (
    "Crewboard, a team task board. Boards have ordered lists (the last list means done) and "
    "cards with ids like OPS-12. Call my_boards first to learn board ids and list names. "
    "Card arguments accept 'OPS-12' or just '12' when only one of your boards has that number. "
    "Dates are YYYY-MM-DD. For 'what's on my plate' use my_cards; for 'what's overdue' use "
    "overdue; for 'what is blocking X' use what_blocks. AI tools only suggest; nothing changes "
    "until a person applies it.")


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

    # --- read -------------------------------------------------------------------------------
    @server.tool()
    def my_boards(ctx: Context) -> list:
        """The boards you are on, with your role and the board's lists."""
        return call(ctx, boards.my_boards)

    @server.tool()
    def show_board(board_id: str, ctx: Context) -> dict:
        """A board: its lists, people and every open card in list order."""
        return call(ctx, boards.show_board, board_id)

    @server.tool()
    def show_card(card: str, ctx: Context) -> dict:
        """One card with its details, what it waits for, and its recent history."""
        return call(ctx, boards.show_card, card)

    @server.tool()
    def my_cards(ctx: Context, days: int = 7) -> dict:
        """What's on my plate: my unfinished cards on every board, grouped as overdue, due in the
        next `days` days, later, and without a due date."""
        return call(ctx, boards.my_cards, days)

    @server.tool()
    def overdue(ctx: Context, board_id: Optional[str] = None) -> list:
        """Unfinished cards past their due date, most late first. Optionally on one board."""
        return call(ctx, boards.overdue, board_id)

    @server.tool()
    def find_cards(text: str, ctx: Context, board_id: Optional[str] = None) -> list:
        """Find cards whose title, description or labels contain all these words."""
        return call(ctx, boards.find_cards, text, board_id)

    @server.tool()
    def card_history(card: str, ctx: Context) -> list:
        """Every change to a card: who, when, and each field's before and after."""
        return call(ctx, boards.card_history, card)

    @server.tool()
    def board_activity(board_id: str, ctx: Context, limit: int = 30) -> list:
        """The latest changes on a board, newest first."""
        return call(ctx, boards.board_activity, board_id, limit)

    @server.tool()
    def what_blocks(card: str, ctx: Context) -> dict:
        """What is blocking this card: every unfinished card it waits for, including chains."""
        return call(ctx, boards.what_blocks, card)

    @server.tool()
    def people(board_id: str, ctx: Context) -> list:
        """Who is on a board and their role."""
        return call(ctx, boards.people, board_id)

    # --- change cards (owner and members) -----------------------------------------------------
    @server.tool()
    def add_card(board_id: str, title: str, ctx: Context, list_name: Optional[str] = None,
                 description: str = "", assignee: Optional[str] = None,
                 due_date: Optional[str] = None, labels: Optional[list[str]] = None) -> dict:
        """Add a card (to the first list unless list_name is given). assignee is an email or
        'me'."""
        return call(ctx, boards.add_card, board_id, title, list_name, description, assignee,
                    due_date, labels)

    @server.tool()
    def move_card(card: str, list_name: str, ctx: Context) -> dict:
        """Move a card to another list, e.g. move 'OPS-12' to 'Done'."""
        return call(ctx, boards.move_card, card, list_name)

    @server.tool()
    def assign_card(card: str, ctx: Context, email: Optional[str] = None) -> dict:
        """Give a card to an owner or member of its board ('me' for yourself); no email
        unassigns it."""
        return call(ctx, boards.assign_card, card, email)

    @server.tool()
    def update_card(card: str, ctx: Context, title: Optional[str] = None,
                    description: Optional[str] = None, due_date: Optional[str] = None,
                    labels: Optional[list[str]] = None, clear_due_date: bool = False) -> dict:
        """Change a card's title, description, due date (YYYY-MM-DD) or labels. Only the fields
        you pass change."""
        fields = {k: v for k, v in (("title", title), ("description", description),
                                    ("due_date", due_date), ("labels", labels)) if v is not None}
        if clear_due_date:
            fields["due_date"] = None
        return call(ctx, boards.update_card, card, None, **fields)

    @server.tool()
    def archive_card(card: str, ctx: Context, restore: bool = False) -> dict:
        """Archive a card (hidden from the board, kept with its history); restore=True brings it
        back."""
        return call(ctx, boards.archive_card, card, not restore)

    @server.tool()
    def block_card(card: str, blocked_by: str, ctx: Context) -> dict:
        """Record that `card` can't finish before `blocked_by` does (same board, no loops)."""
        return call(ctx, boards.block_card, card, blocked_by)

    @server.tool()
    def unblock_card(card: str, blocked_by: str, ctx: Context) -> dict:
        """Remove a 'blocked by' link."""
        return call(ctx, boards.unblock_card, card, blocked_by)

    # --- boards, lists, people (owner) ------------------------------------------------------
    @server.tool()
    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()
    def add_list(board_id: str, name: str, ctx: Context, before: Optional[str] = None) -> dict:
        """Owner only: add a list (by default just before the last, done list)."""
        return call(ctx, boards.add_list, board_id, name, before)

    @server.tool()
    def rename_list(board_id: str, old: str, new: str, ctx: Context) -> dict:
        """Owner only: rename a list; its cards follow."""
        return call(ctx, boards.rename_list, board_id, old, new)

    @server.tool()
    def add_member(board_id: str, email: str, ctx: Context, role: str = "member") -> dict:
        """Owner only: invite someone to a board as 'member' or 'viewer' (or change their role)."""
        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; their cards become unassigned."""
        return call(ctx, boards.remove_member, board_id, email)

    # --- AI (Dodil Models) ------------------------------------------------------------------
    @server.tool()
    def suggest_checklist(card: 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)

    @server.tool()
    def apply_checklist(card: str, items: list[str], ctx: Context) -> dict:
        """Write the checklist items a person chose into the card's description."""
        return call(ctx, boards.apply_checklist, card, items)

    @server.tool()
    def similar_cards(card: str, ctx: Context, limit: int = 5) -> dict:
        """Cards on the same board that mean the same thing (search by meaning)."""
        return call(ctx, boards.similar_cards, card, limit)

    @server.tool()
    def board_summary(board_id: str, ctx: Context) -> dict:
        """A short daily summary: what moved in the last day, what is late, what is stuck."""
        return call(ctx, boards.board_summary, board_id)

    return server