rahulpawar/taskdeck

Lanebook, a team task board (boards, lists, cards, roles, history) you run from the web or from Claude/ChatGPT over MCP. FastAPI + DataK3 + appid + Models.

A remix template · 0 remixes · Remix rahulpawar/taskdeck

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 assistant sees exactly
the boards its person sees. Tools are actions in the board's own words, never table access.
Boards can be named by title ("Ops") and cards by their number ("12").
"""
from typing import List, 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 = (
    "Lanebook: a team task board. Boards hold lists (by default 'To do', 'Doing', 'Done'; the "
    "last list is the 'done' list) and lists hold cards. Name boards by title (e.g. 'Ops') and "
    "cards by their number (e.g. 12); if a number exists on several boards, pass board too. "
    "For 'what's on my plate this week' use my_plate; for 'what's overdue' use overdue. Dates "
    "are YYYY-MM-DD (or 'today', 'tomorrow', a weekday). Viewers can read but not change; only "
    "a board's owner manages its members.")


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

    def call(ctx: Context, fn, *args, **kw):
        person = person_from(ctx.headers or {})
        if person is None:
            raise ToolError("401: sign in first")
        try:
            return fn(person, *args, **kw)
        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: str, ctx: Context) -> dict:
        """A board (title or id) with its people, lists and cards (number, title, assignee,
        due date, labels, overdue flag)."""
        return call(ctx, boards.show_board, board)

    @server.tool()
    def my_plate(ctx: Context, days: int = 7) -> dict:
        """What's on my plate: my unfinished cards that are overdue, due in the next `days`
        days, or have no due date. Answers 'what's on my plate this week?'."""
        return call(ctx, boards.my_plate, days)

    @server.tool()
    def overdue(ctx: Context, board: Optional[str] = None) -> list:
        """Unfinished cards past their due date, on every board you can see or on one board."""
        return call(ctx, boards.overdue, board)

    @server.tool()
    def search_cards(text: str, ctx: Context, board: Optional[str] = None) -> list:
        """Find cards by words in their title, description, labels or assignee."""
        return call(ctx, boards.search_cards, text, board)

    @server.tool()
    def show_card(card: str, ctx: Context, board: Optional[str] = None) -> dict:
        """One card with everything on it and its full change history (who, when, what)."""
        return call(ctx, boards.show_card, card, board)

    @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: str, name: str, ctx: Context) -> dict:
        """Add a list to a board (placed just before the last, 'done' list)."""
        return call(ctx, boards.add_list, board, name)

    @server.tool()
    def add_card(board: str, title: str, ctx: Context, list: str = "", description: str = "",
                 assignee: Optional[str] = None, due_date: Optional[str] = None,
                 labels: Optional[List[str]] = None) -> dict:
        """Add a card, e.g. 'add a card Send invoice to Doing on the Ops board'. list defaults to
        the first list; assignee is an email (or 'me'); due_date YYYY-MM-DD."""
        return call(ctx, boards.add_card, board, title, list, description, assignee, due_date,
                    labels)

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

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

    @server.tool()
    def update_card(card: str, ctx: Context, board: Optional[str] = None,
                    title: Optional[str] = None, description: Optional[str] = None,
                    due_date: Optional[str] = None, labels: Optional[List[str]] = None) -> dict:
        """Change a card's title, description, due date ('none' clears it) or labels
        (an empty list clears them). Every change lands in the card's history."""
        clear = tuple(f for f, v in (("due_date", due_date), ("labels", labels))
                      if (v == [] or (isinstance(v, str) and v.lower() in ("none", "clear"))))
        return call(ctx, boards.update_card, card, board, title=title, description=description,
                    due_date=None if "due_date" in clear else due_date,
                    labels=None if "labels" in clear else labels, clear=clear)

    @server.tool()
    def delete_card(card: str, ctx: Context, board: Optional[str] = None) -> dict:
        """Delete a card (recorded in the board's history)."""
        return call(ctx, boards.delete_card, card, board)

    @server.tool()
    def set_blocked_by(card: str, blockers: List[str], ctx: Context,
                       board: Optional[str] = None) -> dict:
        """Say which cards (numbers on the same board) block this one; [] clears it."""
        return call(ctx, boards.set_blocked_by, card, blockers, board)

    @server.tool()
    def what_is_blocking(card: str, ctx: Context, board: Optional[str] = None) -> dict:
        """'What is blocking the launch?': every unfinished card in the way, directly or further
        down the chain."""
        return call(ctx, boards.what_is_blocking, card, board)

    @server.tool()
    def card_history(card: str, ctx: Context, board: Optional[str] = None) -> list:
        """Who changed what on a card, and when (newest first)."""
        return call(ctx, boards.show_card, card, board)["history"]

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

    @server.tool()
    def add_member(board: 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, email, role)

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

    @server.tool()
    def checklist(card: str, ctx: Context, apply: bool = False,
                  board: Optional[str] = None) -> dict:
        """AI (Dodil Models): turn a card into a checklist. apply=false only suggests;
        apply=true adds the steps to the card's description."""
        return call(ctx, boards.suggest_checklist, card, apply, board)

    @server.tool()
    def board_summary(board: str, ctx: Context) -> dict:
        """AI (Dodil Models): a short daily summary of a board: what moved, what's overdue,
        what's next."""
        return call(ctx, boards.board_summary, board)

    @server.tool()
    def similar_cards(card: str, ctx: Context, board: Optional[str] = None) -> list:
        """AI (Dodil Models embeddings): cards like this one, by meaning, on boards you can see."""
        return call(ctx, boards.similar_cards, card, board)

    return server