sirenamc/stackline
Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix sirenamc/stackline
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 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 = """Stackline is a team task board: boards hold lists, lists hold cards.
- Boards can be named by title ("Ops") or id. Lists by name ("Doing"), case doesn't matter.
- Cards have short numbers: "card 12" and "#12" both mean number 12. Use that number directly.
- "What's on my plate" -> my_plate. "What's overdue" -> overdue_cards.
- Dates are YYYY-MM-DD. "me" works as an assignee.
- "Set up a board for <goal> by <date>" -> set_up_board. Draft the steps yourself and pass them,
so the person sees your plan; leave steps out to let Dodil Models draft it.
- Viewers can read but not change a board; only the owner manages members. If a tool refuses,
tell the person why instead of trying another way round.
- When you answer, name cards as "#12 Title" so the person can find them on the page."""
# What each tool does to the board, shown by Claude and ChatGPT next to the tool.
READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False,
open_world_hint=False)
SAME_AGAIN = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True,
open_world_hint=False)
REMOVES = ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True,
open_world_hint=False)
def build_mcp(boards: Boards) -> MCPServer:
server = MCPServer(name="stackline", 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(annotations=READ)
def my_boards(ctx: Context) -> list[dict]:
"""The boards you are on, with your role on each."""
return call(ctx, boards.my_boards)
@server.tool(annotations=READ)
def show_board(board: str, ctx: Context) -> dict:
"""A board with its lists, cards (number, assignee, due date, labels, overdue,
open blockers) and members. board: title or id."""
return call(ctx, boards.show_board, board)
@server.tool(annotations=READ)
def my_plate(ctx: Context, days: int = 7) -> dict:
"""Your open cards across all boards: overdue, due in the next `days` days (7 = this
week), and assigned without a date."""
return call(ctx, boards.my_plate, days)
@server.tool(annotations=READ)
def overdue_cards(ctx: Context, board: Optional[str] = None) -> list[dict]:
"""Open cards past their due date, most late first. Leave board empty for all boards."""
return call(ctx, boards.overdue, board)
@server.tool(annotations=READ)
def find_cards(text: str, ctx: Context, board: Optional[str] = None) -> list[dict]:
"""Cards whose title, description or labels contain all these words."""
return call(ctx, boards.search_cards, text, board)
@server.tool(annotations=READ)
def card_details(card: str, ctx: Context) -> dict:
"""One card with its checklist, blockers and full change history (who, when, what)."""
return call(ctx, boards.card_details, card)
@server.tool(annotations=READ)
def board_activity(board: str, ctx: Context, limit: int = 30) -> list[dict]:
"""The latest changes on a board, newest first."""
return call(ctx, boards.board_activity, board, limit)
# ---- changing cards ------------------------------------------------------------------
@server.tool(annotations=WRITE)
def add_card(board: str, title: str, ctx: Context, list_name: str = "",
description: str = "", assignee: str = "", due_date: str = "",
labels: Optional[list[str]] = None) -> dict:
"""Add a card. list_name defaults to the first list. Returns the card with its number."""
return call(ctx, boards.add_card, board, title, list_name, description, assignee or None,
due_date or None, labels or ())
@server.tool(annotations=SAME_AGAIN)
def move_card(card: str, list_name: str, ctx: Context) -> dict:
"""Move a card to another list, e.g. card '12' to 'Done'."""
return call(ctx, boards.move_card, card, list_name)
@server.tool(annotations=SAME_AGAIN)
def assign_card(card: str, assignee: str, ctx: Context) -> dict:
"""Assign a card to someone on its board (email, or 'me'). Empty assignee unassigns."""
return call(ctx, boards.assign_card, card, assignee or None)
@server.tool(annotations=SAME_AGAIN)
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) -> dict:
"""Change a card's title, description, due date (YYYY-MM-DD, '' clears it) or labels
(the full new list). Leave out what stays the same."""
return call(ctx, boards.update_card, card, title=title, description=description,
due_date=due_date, labels=labels)
@server.tool(annotations=REMOVES)
def delete_card(card: str, ctx: Context) -> dict:
"""Delete a card. Its history stays in the board's activity."""
return call(ctx, boards.delete_card, card)
# ---- dependencies -------------------------------------------------------------------
@server.tool(annotations=SAME_AGAIN)
def block_card(card: str, blocked_by: str, ctx: Context) -> dict:
"""Record that `card` can't finish until `blocked_by` is done."""
return call(ctx, boards.block_card, card, blocked_by)
@server.tool(annotations=SAME_AGAIN)
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)
@server.tool(annotations=READ)
def what_is_blocking(card: str, ctx: Context) -> dict:
"""Every unfinished card standing between this card and done, following the chain.
Use it for 'what is blocking the launch?' after finding the launch card."""
return call(ctx, boards.what_is_blocking, card)
# ---- checklists and AI --------------------------------------------------------------
@server.tool(annotations=READ)
def suggest_checklist(card: str, ctx: Context) -> dict:
"""AI-suggested checklist steps for a card. Changes nothing; show them to the person
and call add_checklist with the ones they keep."""
return call(ctx, boards.suggest_checklist, card)
@server.tool(annotations=WRITE)
def add_checklist(card: str, items: list[str], ctx: Context) -> dict:
"""Append checklist items to a card."""
return call(ctx, boards.add_checklist, card, items)
@server.tool(annotations=SAME_AGAIN)
def check_item(card: str, index: int, ctx: Context, done: bool = True) -> dict:
"""Tick (or untick) checklist item number `index` (from 0) on a card."""
return call(ctx, boards.check_item, card, index, done)
@server.tool(annotations=READ)
def similar_cards(card: str, ctx: Context, limit: int = 5) -> list[dict]:
"""Cards that mean something close to this one (search by meaning, not words)."""
return call(ctx, boards.similar_cards, card, limit)
@server.tool(annotations=READ)
def board_summary(board: str, ctx: Context) -> dict:
"""A short daily stand-up for a board: what moved in the last day, what's late,
what's blocked."""
return call(ctx, boards.board_summary, board)
# ---- whole boards -------------------------------------------------------------------
@server.tool(annotations=WRITE)
def set_up_board(title: str, ctx: Context, goal: str = "", deadline: str = "",
people: Optional[list[str]] = None,
steps: Optional[list[dict]] = None) -> dict:
"""Create a ready board for a goal: lists, cards with due dates, owners and "blocked by"
links. Use it for "set up a board for the website launch on 2026-11-20, it's me, Ola
and Max". people: emails to invite as members (you are the owner).
steps: your plan, in order, each {"title", "list"? (To do/Doing/Done),
"due_date"? (YYYY-MM-DD) or "days_before"? (days before deadline),
"assignee"? (email or "me"), "after"? [numbers of earlier steps it waits on, from 1]}.
Leave steps out to let Dodil Models draft the plan from goal and deadline.
Nothing is written if any step is wrong."""
return call(ctx, boards.set_up_board, title, goal, deadline or None, people or (),
steps)
@server.tool(annotations=WRITE)
def start_example_board(ctx: Context) -> dict:
"""Create an example board (a website launch: nine cards, two late, real blockers) to try
every feature on. Good for someone who has no boards yet."""
return call(ctx, boards.start_example)
# ---- boards, lists, people ----------------------------------------------------------
@server.tool(annotations=WRITE)
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(annotations=WRITE)
def add_list(board: str, name: str, ctx: Context, is_done: bool = False) -> dict:
"""Add a list at the end of a board. is_done=True marks cards there as finished."""
return call(ctx, boards.add_list, board, name, is_done)
@server.tool(annotations=SAME_AGAIN)
def rename_list(board: str, list_name: str, new_name: str, ctx: Context) -> dict:
"""Rename a list, e.g. 'Doing' to 'In review'. Cards stay where they are."""
return call(ctx, boards.rename_list, board, list_name, new_name)
@server.tool(annotations=READ)
def board_members(board: str, ctx: Context) -> list[dict]:
"""Who is on a board and their role."""
return call(ctx, boards.members, board)
@server.tool(annotations=SAME_AGAIN)
def invite_member(board: str, email: str, ctx: Context, role: str = "member") -> dict:
"""Owner only: add someone to a board as 'member' or 'viewer', or change their role.
They sign in with this email to see the board."""
return call(ctx, boards.invite_member, board, email, role)
@server.tool(annotations=REMOVES)
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)
# ---- prompts: ready-made conversations, shown in Claude as slash commands -------------
@server.prompt(title="Daily stand-up")
def standup(board: str) -> str:
"""Today's stand-up for one board."""
return (f"Give me today's stand-up for the board {board!r}. Call board_summary, then "
"overdue_cards for that board. Answer in three short parts: what moved, what is "
"late and who owns it, what is blocked and by which card. Name cards as #number.")
@server.prompt(title="Plan my week")
def plan_my_week() -> str:
"""What to work on this week, in order."""
return ("Plan my week. Call my_plate with days=7. Put overdue cards first, then by due "
"date. For any card that has open blockers, call what_is_blocking and say who I "
"need to talk to. Finish with one line: the single card to start with today.")
@server.prompt(title="Unblock a card")
def unblock(card: str) -> str:
"""Find what stands in the way of one card and how to clear it."""
return (f"What is blocking card {card}? Call what_is_blocking. For each blocking card, "
"say its owner, its due date and whether it is late. Then suggest the one "
"change that would free the most cards, and ask me before making it.")
return server