mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 19:34:02 +01:00
New standalone Python package under mcp/ that exposes the todo app's exported BACKLOG.md (NotesMarkdown format) as read-only MCP tools, so Claude Code and its subagents can query the backlog. The app is Dart/Flutter with no Python and no CLI; this server reads BACKLOG.md and never touches the sqlite_crdt DB (CRDT writes would corrupt cross-device merge). - todo_mcp/_backlog.py: pure parse_backlog(text) -> list[Note]. - todo_mcp/_mcp.py: FastMCP stdio server. Read-only tools list_backlog, get_note, backlog_stats; every result carries the file mtime so a stale export is visible; missing file returns a graceful "export it" message. Logs to stderr; no writes; no secrets (backlog is note text). - .mcp.json registers it via a dedicated ~/.venvs/todo-mcp interpreter. - 100% coverage; verified end-to-end over stdio; parses the real BACKLOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQGQj8Ne6TriBExv91nG8v
97 lines
3.2 KiB
Python
97 lines
3.2 KiB
Python
"""Tests for the NotesMarkdown backlog parser in ``_backlog``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from todo_mcp._backlog import Note, parse_backlog
|
|
|
|
HEADER = "<!-- todo-backlog v1 -->\n\n"
|
|
|
|
|
|
def _marker(**attrs: str) -> str:
|
|
pairs = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
|
return f"<!-- @note {pairs} -->"
|
|
|
|
|
|
class TestParseBacklog:
|
|
def test_empty_document_has_no_notes(self) -> None:
|
|
assert parse_backlog("") == []
|
|
|
|
def test_header_only_has_no_notes(self) -> None:
|
|
assert parse_backlog(HEADER) == []
|
|
|
|
def test_parses_a_single_note_with_heading_title(self) -> None:
|
|
doc = (
|
|
HEADER
|
|
+ _marker(
|
|
id="abc",
|
|
priority="high",
|
|
status="inProgress",
|
|
)
|
|
+ "\n# Set up my own mail\n\nbody text here\n"
|
|
)
|
|
notes = parse_backlog(doc)
|
|
assert len(notes) == 1
|
|
note = notes[0]
|
|
assert note == Note(
|
|
id="abc",
|
|
title="Set up my own mail",
|
|
body="# Set up my own mail\n\nbody text here",
|
|
priority="high",
|
|
status="inProgress",
|
|
)
|
|
|
|
def test_plain_first_line_becomes_title_verbatim(self) -> None:
|
|
doc = _marker(id="x", priority="low", status="todo") + "\nInfakt api + mcp\n"
|
|
(note,) = parse_backlog(doc)
|
|
assert note.title == "Infakt api + mcp"
|
|
|
|
def test_multiple_notes_split_on_markers(self) -> None:
|
|
doc = (
|
|
HEADER
|
|
+ _marker(id="1", priority="low", status="todo")
|
|
+ "\nfirst\n\n"
|
|
+ _marker(id="2", priority="medium", status="done")
|
|
+ "\nsecond\n"
|
|
)
|
|
notes = parse_backlog(doc)
|
|
assert [n.id for n in notes] == ["1", "2"]
|
|
assert notes[0].body == "first"
|
|
assert notes[1].body == "second"
|
|
|
|
def test_missing_id_gets_fresh_uuid(self) -> None:
|
|
doc = _marker(priority="low", status="todo") + "\nno id here\n"
|
|
(note,) = parse_backlog(doc)
|
|
# A valid UUID string was minted for the absent id.
|
|
assert uuid.UUID(note.id)
|
|
|
|
def test_blank_id_gets_fresh_uuid(self) -> None:
|
|
doc = _marker(id="", priority="low", status="todo") + "\nblank id\n"
|
|
(note,) = parse_backlog(doc)
|
|
assert uuid.UUID(note.id)
|
|
|
|
def test_unknown_priority_and_status_fall_back_to_defaults(self) -> None:
|
|
doc = _marker(id="z", priority="urgent", status="waiting") + "\nx\n"
|
|
(note,) = parse_backlog(doc)
|
|
assert note.priority == "medium"
|
|
assert note.status == "todo"
|
|
|
|
def test_absent_priority_and_status_fall_back_to_defaults(self) -> None:
|
|
doc = _marker(id="z") + "\nx\n"
|
|
(note,) = parse_backlog(doc)
|
|
assert note.priority == "medium"
|
|
assert note.status == "todo"
|
|
|
|
def test_empty_body_yields_empty_title(self) -> None:
|
|
# Two adjacent markers => the first note's body is empty.
|
|
doc = (
|
|
_marker(id="1", priority="low", status="todo")
|
|
+ "\n"
|
|
+ _marker(id="2", priority="low", status="todo")
|
|
+ "\nsecond\n"
|
|
)
|
|
notes = parse_backlog(doc)
|
|
assert notes[0].body == ""
|
|
assert notes[0].title == ""
|