"""Todo MCP server โ€” lightweight JSON-backed task management. Exposes ``todo_*`` tools for listing, creating, updating, completing and deleting tasks. Follows the same style as the Harrier / OSINT-MCP servers: flattened, action-oriented tool names and individual parameters. Persistence lives in ``store.py`` (a single JSON file; default ``~/.todo-mcp/todos.json``, override with ``TODO_MCP_STORE``). """ from __future__ import annotations import json import uuid from datetime import date as date_cls from typing import List, Optional, Union from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, ConfigDict, Field, field_validator from .store import Store, now_iso mcp = FastMCP("todo-mcp") PRIORITIES = ("urgent", "high", "medium", "low") PRIOR_ORDER = {p: i for i, p in enumerate(PRIORITIES)} STATUSES = ("active", "done") # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # def _store() -> Store: return Store() def _new_id() -> str: return "t_" + uuid.uuid4().hex[:8] def _find(tasks: List[dict], tid: str) -> Optional[dict]: for t in tasks: if t.get("id") == tid: return t return None def _sort(tasks: List[dict]) -> List[dict]: return sorted( tasks, key=lambda t: ( PRIOR_ORDER.get(t.get("priority"), len(PRIOR_ORDER)), t.get("due") or "z", t.get("created_at") or "", ), ) def _fmt(item: dict, fmt: str) -> str: if fmt == "markdown": return _task_markdown(item) return json.dumps(item, indent=2) def _task_markdown(t: dict) -> str: box = "[x]" if t.get("status") == "done" else "[ ]" line = f"- {box} `{t['id']}`" if t.get("priority"): line += f" ๐Ÿ”ด {t['priority']}" line += f" **{t['title']}**" if t.get("due"): line += f" ๐Ÿ“… {t['due']}" meta = [] if t.get("project"): meta.append(f"๐Ÿ“ {t['project']}") if t.get("tags"): meta.append("๐Ÿท๏ธ " + ", ".join(t["tags"])) if meta: line += " ยท " + " ยท ".join(meta) out = [line] if t.get("description"): out.extend((" " + ln).rstrip() for ln in t["description"].splitlines()) return "\n".join(out) def _list_markdown(tasks: List[dict]) -> str: if not tasks: return "_No tasks._" active = _sort([t for t in tasks if t.get("status") == "active"]) done = _sort([t for t in tasks if t.get("status") == "done"]) lines = ["## Active", ""] lines.extend(_task_markdown(t) for t in active) or ["_None._"] lines.append("") lines.append("## Done") lines.append("") lines.extend(_task_markdown(t) for t in done) or ["_None._"] return "\n".join(lines) # --------------------------------------------------------------------------- # # Validation models (single source of truth for coercion / constraints) # --------------------------------------------------------------------------- # class _Base(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") class AddInput(_Base): title: str = Field(..., min_length=1, max_length=500, description="Task title.") description: Optional[str] = Field(None, max_length=5000, description="Optional description.") priority: Optional[str] = Field(None, description="urgent | high | medium | low (default medium).") due: Optional[str] = Field(None, description="Optional due date, YYYY-MM-DD.") tags: Optional[List[str]] = Field(None, description="Tags (list or comma-separated string).") project: Optional[str] = Field(None, max_length=100, description="Project/group name.") format: str = Field("json", description="Output format: json (default) or markdown.") @field_validator("priority", mode="before") @classmethod def _norm_priority(cls, v: Optional[str]) -> Optional[str]: if v is None: return None v = str(v).strip().lower() if v not in PRIORITIES: raise ValueError(f"priority must be one of {PRIORITIES}") return v @field_validator("due", mode="before") @classmethod def _norm_due(cls, v: Optional[str]) -> Optional[str]: if v is None or str(v).strip() == "": return None try: return date_cls.fromisoformat(str(v)).isoformat() except ValueError: raise ValueError("due must be an ISO date (YYYY-MM-DD)") @field_validator("tags", mode="before") @classmethod def _norm_tags(cls, v: Optional[object]) -> Optional[List[str]]: if v is None: return None if isinstance(v, str): v = v.split(",") return [str(x).strip() for x in v if str(x).strip()] class ListInput(_Base): status: Optional[str] = Field(None, description="Filter: active | done.") priority: Optional[str] = Field(None, description="Filter by priority.") project: Optional[str] = Field(None, description="Filter by project name.") tag: Optional[str] = Field(None, description="Filter by a single tag.") limit: int = Field(50, ge=1, le=500, description="Max results to return.") offset: int = Field(0, ge=0, description="Results to skip (pagination).") format: str = Field("json", description="Output format: json (default) or markdown.") @field_validator("status", "priority", "tag", mode="before") @classmethod def _norm_filter(cls, v: Optional[str]) -> Optional[str]: if v is None: return None v = str(v).strip().lower() return v or None @field_validator("priority", mode="before") @classmethod def _norm_pri(cls, v: Optional[str]) -> Optional[str]: if v is None: return None if v in PRIORITIES: return v raise ValueError(f"priority must be one of {PRIORITIES}") class GetInput(_Base): id: str = Field(..., min_length=1, description="Task id (e.g. t_1a2b3c4d).") format: str = Field("json", description="Output format: json (default) or markdown.") class UpdateInput(_Base): id: str = Field(..., min_length=1, description="Task id to update.") title: Optional[str] = Field(None, min_length=1, max_length=500, description="New title.") description: Optional[str] = Field(None, max_length=5000, description="New description.") priority: Optional[str] = Field(None, description="urgent | high | medium | low.") due: Optional[str] = Field(None, description="New due date, YYYY-MM-DD.") tags: Optional[List[str]] = Field(None, description="Replace tags (list or comma string).") project: Optional[str] = Field(None, max_length=100, description="New project name.") status: Optional[str] = Field(None, description="New status: active | done.") format: str = Field("json", description="Output format: json (default) or markdown.") @field_validator("priority", "status", mode="before") @classmethod def _norm_update(cls, v: Optional[str]) -> Optional[str]: if v is None: return None v = str(v).strip().lower() if v in PRIORITIES or v in STATUSES: return v raise ValueError(f"value must be one of {PRIORITIES + STATUSES}") @field_validator("due", mode="before") @classmethod def _norm_due(cls, v: Optional[str]) -> Optional[str]: if v is None or str(v).strip() == "": return None try: return date_cls.fromisoformat(str(v)).isoformat() except ValueError: raise ValueError("due must be an ISO date (YYYY-MM-DD)") @field_validator("tags", mode="before") @classmethod def _norm_tags(cls, v: Optional[object]) -> Optional[List[str]]: if v is None: return None if isinstance(v, str): v = v.split(",") return [str(x).strip() for x in v if str(x).strip()] # --------------------------------------------------------------------------- # # Tools (flattened parameters, matching the Harrier convention) # # Plain Python defaults are used on the signatures so the tools work whether # called directly or through FastMCP; the Pydantic models above do all the # real validation and coercion. # --------------------------------------------------------------------------- # @mcp.tool( name="todo_add", annotations={ "title": "Add a task", "readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False, }, ) async def todo_add( title: str, description: Optional[str] = None, priority: Optional[str] = None, due: Optional[str] = None, tags: Optional[Union[str, List[str]]] = None, project: Optional[str] = None, format: str = "json", ) -> str: '''Create a new task and persist it. Args: title (str): Task title (required). description (Optional[str]): Optional longer description. priority (Optional[str]): urgent | high | medium | low (default medium). due (Optional[str]): Optional due date, YYYY-MM-DD. tags (Optional[Union[str, list[str]]]): Tags; list or comma-separated string. project (Optional[str]): Optional project/group name. format (str): json (default) or markdown. Returns: str: The created task as JSON, or markdown if format="markdown". ''' params = AddInput(title=title, description=description, priority=priority, due=due, tags=tags, project=project, format=format) tasks = _store().load() task = { "id": _new_id(), "title": params.title, "description": params.description, "priority": params.priority or "medium", "status": "active", "due": params.due, "tags": params.tags or [], "project": params.project, "created_at": now_iso(), "updated_at": now_iso(), } tasks.append(task) _store().save(tasks) return _fmt(task, params.format) @mcp.tool( name="todo_list", annotations={ "title": "List tasks", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_list( status: Optional[str] = None, priority: Optional[str] = None, project: Optional[str] = None, tag: Optional[str] = None, limit: int = 50, offset: int = 0, format: str = "json", ) -> str: '''List tasks with optional filters and pagination. Args: status (Optional[str]): active | done. priority (Optional[str]): urgent | high | medium | low. project (Optional[str]): project/group name. tag (Optional[str]): single tag. limit (int): 1-500 (default 50). offset (int): results to skip (default 0). format (str): json (default) or markdown. Returns: str: JSON {total, count, offset, tasks} or a markdown grouping. ''' params = ListInput(status=status, priority=priority, project=project, tag=tag, limit=limit, offset=offset, format=format) tasks = _store().load() if params.status: tasks = [t for t in tasks if t.get("status") == params.status] if params.priority: tasks = [t for t in tasks if t.get("priority") == params.priority] if params.project: tasks = [t for t in tasks if t.get("project") == params.project] if params.tag: tasks = [t for t in tasks if params.tag in (t.get("tags") or [])] tasks = _sort(tasks) page = tasks[params.offset:params.offset + params.limit] if params.format == "markdown": return _list_markdown(page) return json.dumps( {"total": len(tasks), "count": len(page), "offset": params.offset, "tasks": page}, indent=2, ) @mcp.tool( name="todo_get", annotations={ "title": "Get a task", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_get( id: str, format: str = "json", ) -> str: '''Fetch a single task by id. Args: id (str): Task id. format (str): json (default) or markdown. Returns: str: The task as JSON, or an error string if not found. ''' params = GetInput(id=id, format=format) task = _find(_store().load(), params.id) if not task: return f"Error: no todo with id '{params.id}'" return _fmt(task, params.format) @mcp.tool( name="todo_update", annotations={ "title": "Update a task", "readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_update( id: str, title: Optional[str] = None, description: Optional[str] = None, priority: Optional[str] = None, due: Optional[str] = None, tags: Optional[Union[str, List[str]]] = None, project: Optional[str] = None, status: Optional[str] = None, format: str = "json", ) -> str: '''Update mutable fields on an existing task. Only the fields you pass are changed; omitted fields are left as-is. Pass tags to replace the whole tag set. Args: id (str): Task id. title (Optional[str]): New title. description (Optional[str]): New description. priority (Optional[str]): urgent|high|medium|low. due (Optional[str]): New due date, YYYY-MM-DD. tags (Optional[Union[str, list[str]]]): Replace tags. project (Optional[str]): New project name. status (Optional[str]): active | done. format (str): json (default) or markdown. Returns: str: The updated task as JSON, or an error string if not found. ''' params = UpdateInput(id=id, title=title, description=description, priority=priority, due=due, tags=tags, project=project, status=status, format=format) store = _store() tasks = store.load() task = _find(tasks, params.id) if not task: return f"Error: no todo with id '{params.id}'" changes = { k: getattr(params, k) for k in ("title", "description", "priority", "due", "tags", "project", "status") if getattr(params, k) is not None } task.update(changes) task["updated_at"] = now_iso() store.save(tasks) return _fmt(task, params.format) @mcp.tool( name="todo_toggle", annotations={ "title": "Toggle task status", "readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_toggle(id: str) -> str: '''Flip a task between active and done (quick complete/uncomplete). Args: id (str): Task id to toggle. Returns: str: The updated task as JSON, or an error string if not found. ''' store = _store() tasks = store.load() task = _find(tasks, id) if not task: return f"Error: no todo with id '{id}'" task["status"] = "done" if task.get("status") == "active" else "active" task["updated_at"] = now_iso() store.save(tasks) return _fmt(task, "json") @mcp.tool( name="todo_delete", annotations={ "title": "Delete a task", "readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_delete(id: str) -> str: '''Permanently delete a task by id. Args: id (str): Task id to delete. Returns: str: JSON confirming deletion, or an error string if not found. ''' store = _store() tasks = store.load() if _find(tasks, id) is None: return f"Error: no todo with id '{id}'" store.save([t for t in tasks if t.get("id") != id]) return json.dumps({"deleted": id}) @mcp.tool( name="todo_clear", annotations={ "title": "Clear completed tasks", "readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_clear(format: str = "json") -> str: '''Remove all completed (done) tasks. Args: format (str): json (default) or markdown. Returns: str: JSON {cleared, remaining} counts. ''' store = _store() tasks = store.load() keep = [t for t in tasks if t.get("status") != "done"] removed = len(tasks) - len(keep) store.save(keep) return json.dumps({"cleared": removed, "remaining": len(keep)}, indent=2) @mcp.tool( name="todo_stats", annotations={ "title": "Task summary", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, }, ) async def todo_stats() -> str: '''Summarize the todo list: totals, by status, by priority, by project. Returns: str: JSON summary object. ''' tasks = _store().load() summary = { "total": len(tasks), "active": sum(1 for t in tasks if t.get("status") == "active"), "done": sum(1 for t in tasks if t.get("status") == "done"), "by_priority": {p: sum(1 for t in tasks if t.get("priority") == p) for p in PRIORITIES}, "projects": sorted({t.get("project") for t in tasks if t.get("project")}), } return json.dumps(summary, indent=2) def _cli() -> None: """Entrypoint: run the stdio MCP server.""" mcp.run() if __name__ == "__main__": _cli()