damien-kaiser/taskloft
Taskloft — team task boards (owner / member / viewer), card history, my plate, overdue, blockers, AI checklists and summaries, usable from Claude and ChatGPT over MCP.
A remix template · 0 remixes · Remix damien-kaiser/taskloft
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 (move_card, assign_card), never raw table access.
Boards can be named by title ("the Ops board"), lists by name ("Doing"), cards by number (12).
"""
from typing import Optional
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from boards.application.use_cases import UNSET, Boards, NotFound
from boards.doors.identity import person_from
from boards.domain.model import Forbidden, Invalid
INSTRUCTIONS = (
"Taskloft, a team task board. Boards hold lists (e.g. 'To do', 'Doing', 'Done') and lists hold "
"cards. Cards have a number (#12): use it to name a card. A board can be named by its title. "
"For 'what's on my plate this week?' call my_plate; for 'what's overdue?' call overdue; to find a "
"card by words call search_cards. Viewers can read but not change a board; only the owner manages people.")
def build_mcp(boards: Boards) -> MCPServer:
server = MCPServer(name="taskloft", 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))
# --- reading -------------------------------------------------------------------------
@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 (id or title) with its lists, their cards, and its people."""
return call(ctx, boards.show_board, board)
@server.tool()
def my_plate(ctx: Context, days: int = 7) -> dict:
"""What's on my plate: cards assigned to me that aren't finished — overdue first, then due
within `days` (7 = this week), then those without a due date."""
return call(ctx, boards.my_plate, days)
@server.tool()
def overdue(ctx: Context, board: Optional[str] = None) -> list:
"""Cards past their due date and not finished, on one board or on all my boards."""
return call(ctx, boards.overdue, board)
@server.tool()
def search_cards(text: str, ctx: Context, board: Optional[str] = None) -> list:
"""Find cards whose title, description, labels or assignee contain all these words."""
return call(ctx, boards.search_cards, text, board)
@server.tool()
def card_details(card: str, ctx: Context) -> dict:
"""One card (number or id): everything about it, its checklist and its full history."""
return call(ctx, boards.card, card)
@server.tool()
def card_history(card: str, ctx: Context) -> list:
"""Who changed this card, when, and what changed (newest first)."""
return call(ctx, boards.card_history, card)
@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.board_activity, board, limit)
# --- 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] = None) -> dict:
"""Add a card. `list` is a list name like 'Doing' (default: the first list). `due_date` is
YYYY-MM-DD; `assignee` is a member's email; `labels` is a list of short words."""
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) -> dict:
"""Move a card (number or id) to a list by name, e.g. move card 12 to 'Done'."""
return call(ctx, boards.move_card, card, list)
@server.tool()
def assign_card(card: str, ctx: Context, assignee: Optional[str] = None) -> dict:
"""Give a card to a board member (their email, or 'me'); leave empty to unassign."""
return call(ctx, boards.assign_card, card, assignee)
@server.tool()
def update_card(card: str, ctx: Context, title: Optional[str] = None,
description: Optional[str] = None, due_date: Optional[str] = None,
clear_due_date: bool = False, labels: Optional[list] = None) -> dict:
"""Change a card's title, description, due date (YYYY-MM-DD, or clear_due_date) or labels.
Only the fields you pass change."""
due = None if clear_due_date else (due_date if due_date is not None else UNSET)
return call(ctx, boards.update_card, card, title, description, due, labels)
@server.tool()
def set_blocked_by(card: str, blocked_by: str, ctx: Context, blocked: bool = True) -> dict:
"""Mark a card as waiting on another card of the same board (or unmark with blocked=false)."""
return call(ctx, boards.set_blocked_by, card, blocked_by, blocked)
@server.tool()
def what_is_blocking(card: str, ctx: Context) -> dict:
"""Everything unfinished that this card waits on, directly or further up the chain
('what is blocking the launch?')."""
return call(ctx, boards.what_is_blocking, card)
@server.tool()
def add_checklist_items(card: str, items: list, ctx: Context) -> dict:
"""Add checklist items to a card."""
return call(ctx, boards.add_checklist_items, card, items)
@server.tool()
def tick_checklist_item(card: str, item: str, ctx: Context, done: bool = True) -> dict:
"""Tick (or untick) a checklist item, by its position (1, 2…) or id."""
return call(ctx, boards.tick_checklist_item, card, item, done)
# --- people and lists ----------------------------------------------------------------
@server.tool()
def create_board(title: str, ctx: Context, lists: Optional[list] = None) -> dict:
"""Create a board; you become its owner. Default lists: To do, Doing, Done."""
return call(ctx, boards.create_board, title, lists)
@server.tool()
def add_list(board: str, name: str, ctx: Context, done: bool = False) -> dict:
"""Add a list to a board. done=true means cards there count as finished."""
return call(ctx, boards.add_list, board, name, done)
@server.tool()
def board_members(board: str, ctx: Context) -> dict:
"""Who is on a board, with their role."""
return call(ctx, boards.members, board)
@server.tool()
def invite_member(board: str, email: str, ctx: Context, role: str = "member") -> dict:
"""Owner only: add someone (or change their role) as 'member' or 'viewer'."""
return call(ctx, boards.invite_member, board, email, role)
@server.tool()
def remove_member(board: str, email: str, ctx: Context) -> dict:
"""Owner only: take someone off a board. Their cards become unassigned."""
return call(ctx, boards.remove_member, board, email)
# --- AI (Dodil Models) ---------------------------------------------------------------
@server.tool()
def turn_into_checklist(card: str, ctx: Context) -> dict:
"""Turn a card into a checklist: Dodil Models drafts the steps and they're added to the card."""
return call(ctx, boards.make_checklist, card)
@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 similar_cards(card: str, ctx: Context, k: int = 5) -> list:
"""Find cards like this one, by meaning (not just shared words), on the boards you can see."""
return call(ctx, boards.similar_cards, card, k)
@server.tool()
def search_by_meaning(text: str, ctx: Context, board: Optional[str] = None) -> list:
"""Cards about this topic even with different words ('bill the client' finds 'Send invoice')."""
return call(ctx, boards.search_by_meaning, text, board)
@server.tool()
def board_summary(board: str, ctx: Context) -> dict:
"""A short daily summary of a board, written by Dodil Models from the board's own facts."""
return call(ctx, boards.board_summary, board)
return server