ghpsolutions/quadro

Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix ghpsolutions/quadro

app/boards/doors/mcp.py

"""The MCP door: the same use cases as the web page, as tools for Claude or ChatGPT.

Every tool runs as the person who connected (their gateway identity), so an assistant sees
exactly the boards that person sees and is held to their role. Tools are actions; none of them
exposes tables or SQL.
"""
from __future__ import annotations

from typing import Any, Optional

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

from boards.application.use_cases import Boards
from boards.doors.identity import person_from
from boards.domain.model import DomainError

INSTRUCTIONS = (
    "Quadro is a team task board. Each board has ordered lists (by default 'To do', 'Doing', "
    "'Done') and numbered cards. Refer to boards by title or key (e.g. 'Ops') and to cards by "
    "reference: 'OPS-12', or just '12' when only one visible board has a card 12. "
    "For 'what is on my plate' use my_plate; for 'what is overdue' use overdue_cards; to change "
    "a card's list use move_card. Everything you do is recorded in the board history under the "
    "signed-in person's name, and viewers cannot change anything.")


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

    # ----------------------------------------------------------------- reading

    @server.tool()
    def my_boards(ctx: Context) -> list[dict[str, Any]]:
        """List the boards you can see, with their key and your role (owner, member, viewer)."""
        return call(ctx, boards.my_boards)

    @server.tool()
    def show_board(board: str, ctx: Context) -> dict[str, Any]:
        """Show a board: its lists in order and every card on them, with references, assignees,
        due dates, labels, overdue and blocked flags. `board` is a title, key or id."""
        return call(ctx, boards.show_board, board)

    @server.tool()
    def my_plate(ctx: Context, days: int = 7) -> dict[str, Any]:
        """What is on my plate: open cards assigned to me across all my boards, split into
        overdue, due within `days` (default 7, i.e. this week), and the rest."""
        return call(ctx, boards.my_plate, days)

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

    @server.tool()
    def search_cards(query: str, ctx: Context, board: Optional[str] = None) -> list[dict[str, Any]]:
        """Find cards whose title, description, labels or assignee contain all the words."""
        return call(ctx, boards.search, query, board)

    @server.tool()
    def card_details(card: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """One card in full: description, checklist progress, blockers, comments and its
        change history (who changed what, when)."""
        return call(ctx, boards.card_detail, card, board)

    @server.tool()
    def board_activity(board: str, ctx: Context, limit: int = 30) -> list[dict[str, Any]]:
        """Recent history of a board, newest first: who did what to which card."""
        return call(ctx, boards.board_activity, board, limit)

    # ----------------------------------------------------------------- changing cards

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

    @server.tool()
    def move_card(card: str, list: str, ctx: Context, board: Optional[str] = None,
                  position: Optional[int] = None) -> dict[str, Any]:
        """Move a card to another list (e.g. card='12', list='Done'). `position` is the
        0-based place in the target list; it goes to the bottom when omitted."""
        return call(ctx, boards.move_card, card, list, position, None, 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[str, Any]:
        """Edit a card. Only the fields you pass change. Pass due_date='' to clear it.
        `labels` replaces the whole label set."""
        fields = {k: v for k, v in {"title": title, "description": description,
                                     "due_date": due_date, "labels": labels}.items()
                  if v is not None}
        return call(ctx, boards.update_card, card, None, board, **fields)

    @server.tool()
    def assign_card(card: str, ctx: Context, email: str = "", board: Optional[str] = None) -> dict[str, Any]:
        """Assign a card to a board member by email, or unassign it with email=''."""
        return call(ctx, boards.update_card, card, None, board, assignee=email or None)

    @server.tool()
    def comment_on_card(card: str, text: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """Add a comment to a card."""
        return call(ctx, boards.comment, card, text, board)

    @server.tool()
    def delete_card(card: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """Archive a card. Its number and history are kept."""
        return call(ctx, boards.delete_card, card, board)

    # ----------------------------------------------------------------- dependencies

    @server.tool()
    def block_card(card: str, blocked_by: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """Record that `card` cannot finish until `blocked_by` is done (same board; loops are
        refused)."""
        return call(ctx, boards.block, card, blocked_by, board)

    @server.tool()
    def unblock_card(card: str, blocked_by: str, ctx: Context,
                     board: Optional[str] = None) -> dict[str, Any]:
        """Remove a blocker from a card."""
        return call(ctx, boards.unblock, card, blocked_by, board)

    @server.tool()
    def what_is_blocking(card: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """Answer 'what is blocking X?': every unfinished card it waits on, directly or further
        down the chain, with owner and due date."""
        return call(ctx, boards.what_blocks, card, board)

    # ----------------------------------------------------------------- boards, lists, people

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

    @server.tool()
    def add_list(board: str, name: str, ctx: Context) -> dict[str, Any]:
        """Add a list at the right end of a board."""
        return call(ctx, boards.add_list, board, name)

    @server.tool()
    def invite_member(board: str, email: str, ctx: Context, role: str = "member") -> dict[str, Any]:
        """Owner only: add someone to a board, or change their role. Roles: member (can edit),
        viewer (read only)."""
        return call(ctx, boards.invite_member, board, email, role)

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

    # ----------------------------------------------------------------- Dodil Models

    @server.tool()
    def suggest_checklist(card: str, ctx: Context, board: Optional[str] = None) -> dict[str, Any]:
        """Suggest checklist steps for a card. Saves nothing: show them to the person and call
        add_checklist with the steps they keep."""
        return call(ctx, boards.suggest_checklist, card, board)

    @server.tool()
    def add_checklist(card: str, steps: list[str], ctx: Context,
                      board: Optional[str] = None) -> dict[str, Any]:
        """Append steps to a card as a checklist."""
        return call(ctx, boards.apply_checklist, card, steps, board)

    @server.tool()
    def similar_cards(card: str, ctx: Context, board: Optional[str] = None, limit: int = 5) -> dict[str, Any]:
        """Find cards on the same board that are about the same thing (meaning, not just
        words), e.g. to spot duplicates."""
        return call(ctx, boards.similar_cards, card, limit, board)

    @server.tool()
    def board_summary(board: str, ctx: Context) -> dict[str, Any]:
        """Today's summary of a board: counts per list, what moved in the last 24 hours, what is
        overdue, due soon or blocked."""
        return call(ctx, boards.board_summary, board)

    return server