theaio/theaio-boards

Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix theaio/theaio-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. Every tool is one line
that calls a use case; the rules live in the domain and the use cases, not here.
"""
from typing import Optional

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
from boards.doors.identity import person_from
from boards.domain.model import Forbidden, Invalid

INSTRUCTIONS = """Laneway: the signed-in person's team task boards. Boards hold lists (columns,
in order; the last one counts as done) and lists hold cards. Every card has a short number per
board ("card 12").

Start with my_boards (or show_board if the person names a board). Boards are named by title or
id; cards by number, id or title, and a number is found on all the person's boards (pass `board`
when it exists on several). Lists are named as shown on the board.

For "what's on my plate this week?" call my_cards(within_days=7). For "what's overdue?" call
list_overdue. Dates are YYYY-MM-DD, or today / tomorrow / +N days. Assign to "me" for the
person themselves. Viewers can read but not change anything; only a board's owner manages
members. Never claim something changed unless the tool returned it."""

READ = ToolAnnotations(read_only_hint=True)
EDIT = ToolAnnotations(read_only_hint=False, destructive_hint=False)


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

    def call(ctx: Context, fn, *args, **kwargs):
        person = person_from(ctx.headers or {})
        if person is None:
            raise ToolError("You are not signed in. Reconnect the Laneway connector and sign in.")
        try:
            return fn(person, *args, **kwargs)
        except NotFound as e:
            raise ToolError(f"Not found: {e}. Call my_boards to see the boards you are on.")
        except Forbidden as e:
            raise ToolError(f"Not allowed: {e}.")
        except Invalid as e:
            raise ToolError(f"Can't do that: {e}.")

    # --- boards and lists -------------------------------------------------------------------
    @server.tool(annotations=READ)
    def my_boards(ctx: Context) -> dict:
        """Call this first. The boards you are on, with your role on each (owner, member,
        viewer). Use a board's title or id in the other tools."""
        return {"boards": call(ctx, boards.my_boards)}

    @server.tool(annotations=READ)
    def show_board(board: str, ctx: Context) -> dict:
        """A board (title or id): its members, its lists in order and every card in them, with
        overdue and blocked cards flagged. Call my_boards first if you don't know the title."""
        return call(ctx, boards.show_board, board)

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

    @server.tool(annotations=EDIT)
    def add_list(board: str, name: str, ctx: Context) -> dict:
        """Add a list (a column) to a board, just before its last (done) list. Owners and members
        only; move_list puts it elsewhere."""
        return call(ctx, boards.add_list, board, name)

    @server.tool(annotations=EDIT)
    def rename_list(board: str, list_name: str, new_name: str, ctx: Context) -> dict:
        """Rename a list on a board. Call show_board first to see the list names."""
        return call(ctx, boards.rename_list, board, list_name, new_name)

    @server.tool(annotations=EDIT)
    def move_list(board: str, list_name: str, position: int, ctx: Context) -> dict:
        """Put a list at a position on its board (1 = first). The last list counts as done."""
        return call(ctx, boards.move_list, board, list_name, position)

    # --- cards ------------------------------------------------------------------------------
    @server.tool(annotations=EDIT)
    def add_card(board: str, title: str, ctx: Context, list_name: str = "",
                 description: str = "") -> dict:
        """Add a card. board is a title or id ("Ops"); list_name defaults to the first list.
        e.g. "add 'Send invoice' to Doing on the Ops board" -> add_card("Ops", "Send invoice",
        list_name="Doing"). Returns the card with its number."""
        return call(ctx, boards.add_card, board, title, list_name, description)

    @server.tool(annotations=READ)
    def show_card(card: str, ctx: Context, board: str = "") -> dict:
        """One card in full: fields, what blocks it, what it blocks and its history.
        card is a number (12), an id or a title; add board if the number is on several boards."""
        return call(ctx, boards.show_card, card, board or None)

    @server.tool(annotations=EDIT)
    def move_card(card: str, to_list: str, ctx: Context, position: Optional[int] = None,
                  board: str = "") -> dict:
        """Move a card to a list, e.g. "move card 12 to Done" -> move_card("12", "Done").
        position is 1-based inside the list (default: the end)."""
        return call(ctx, boards.move_card, card, to_list, position, board or None)

    @server.tool(annotations=EDIT)
    def assign_card(card: str, email: str, ctx: Context, board: str = "") -> dict:
        """Assign a card to a board owner or member by email ("me" for yourself, "" to
        unassign). Viewers and people not on the board can't be assigned; show_board lists
        the members."""
        return call(ctx, boards.assign_card, card, email, board or None)

    @server.tool(annotations=EDIT)
    def set_due_date(card: str, due: str, ctx: Context, board: str = "") -> dict:
        """Set a card's due date: YYYY-MM-DD, today, tomorrow or +N days; "" clears it."""
        return call(ctx, boards.set_due_date, card, due, board or None)

    @server.tool(annotations=EDIT)
    def label_card(card: str, ctx: Context, add: Optional[list[str]] = None,
                   remove: Optional[list[str]] = None, board: str = "") -> dict:
        """Add and/or remove labels (short words like "urgent", "client") on a card."""
        return call(ctx, boards.label_card, card, add or [], remove or [], board or None)

    @server.tool(annotations=EDIT)
    def describe_card(card: str, description: str, ctx: Context, board: str = "") -> dict:
        """Replace a card's description (the old one stays in the card's history)."""
        return call(ctx, boards.describe_card, card, description, board or None)

    @server.tool(annotations=EDIT)
    def rename_card(card: str, title: str, ctx: Context, board: str = "") -> dict:
        """Change a card's title."""
        return call(ctx, boards.update_card, card, board or None, title=title)

    @server.tool(annotations=EDIT)
    def comment_card(card: str, text: str, ctx: Context, board: str = "") -> dict:
        """Add a comment to a card. It shows in the card's history."""
        return call(ctx, boards.comment_card, card, text, board or None)

    @server.tool(annotations=READ)
    def card_history(card: str, ctx: Context, board: str = "") -> dict:
        """Everything that happened to a card: who, when, which field, old and new value."""
        return call(ctx, boards.card_history, card, board or None)

    # --- across boards ----------------------------------------------------------------------
    @server.tool(annotations=READ)
    def my_cards(ctx: Context, within_days: Optional[int] = None) -> dict:
        """Open cards assigned to you on all your boards, soonest due first. "What's on my
        plate this week?" -> my_cards(within_days=7): due within 7 days or already overdue."""
        return call(ctx, boards.my_cards, within_days)

    @server.tool(annotations=READ)
    def list_overdue(ctx: Context) -> dict:
        """Cards past their due date and not done, on the boards you can see."""
        return call(ctx, boards.list_overdue)

    # --- people -----------------------------------------------------------------------------
    @server.tool(annotations=EDIT)
    def invite_member(board: str, email: str, ctx: Context, role: str = "member") -> dict:
        """Owner only: put someone on a board as 'member' (can edit) or 'viewer' (read only).
        They see the board when they sign in with that email."""
        return call(ctx, boards.invite_member, board, email, role)

    @server.tool(annotations=EDIT)
    def set_member_role(board: str, email: str, role: str, ctx: Context) -> dict:
        """Owner only: change someone's role to 'member' or 'viewer'. A new viewer's cards are
        unassigned."""
        return call(ctx, boards.set_member_role, board, email, role)

    @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True))
    def remove_member(board: str, email: str, ctx: Context) -> dict:
        """Owner only: take someone off a board. Their cards there are unassigned."""
        return call(ctx, boards.remove_member, board, email)

    # --- dependencies -----------------------------------------------------------------------
    @server.tool(annotations=EDIT)
    def block_card(card: str, blocked_by: str, ctx: Context, board: str = "") -> dict:
        """Record that `card` can't finish before `blocked_by` (both on the same board).
        A link that would make a loop is refused."""
        return call(ctx, boards.block_card, card, blocked_by, board or None)

    @server.tool(annotations=EDIT)
    def unblock_card(card: str, blocked_by: str, ctx: Context, board: str = "") -> dict:
        """Remove a "blocked by" link between two cards."""
        return call(ctx, boards.unblock_card, card, blocked_by, board or None)

    @server.tool(annotations=READ)
    def what_is_blocking(card_or_board: str, ctx: Context) -> dict:
        """"What is blocking the launch?": give a board title (every waiting card and what it
        waits on) or a card number/title (its open blockers, directly and down the chain)."""
        return call(ctx, boards.what_is_blocking, card_or_board)

    # --- AI ---------------------------------------------------------------------------------
    @server.tool(annotations=READ)
    def suggest_checklist(card: str, ctx: Context, board: str = "") -> dict:
        """AI suggestion of a checklist for a card. Changes nothing; you decide what to keep
        (describe_card can save it)."""
        return call(ctx, boards.suggest_checklist, card, board or None)

    @server.tool(annotations=READ)
    def find_similar_cards(card: str, ctx: Context, board: str = "", limit: int = 5) -> dict:
        """Cards that mean something close to this one (search by meaning, not words), on
        boards you can see. Good for spotting duplicates."""
        return call(ctx, boards.similar_cards, card, board or None, limit)

    @server.tool(annotations=READ)
    def board_summary(board: str, ctx: Context) -> dict:
        """A short daily summary of a board: what moved in the last day, what is late, due
        soon or blocked."""
        return call(ctx, boards.board_summary, board)

    return server