diff --git a/src/things_mcp/server.py b/src/things_mcp/server.py index d08e53c..30b4e11 100644 --- a/src/things_mcp/server.py +++ b/src/things_mcp/server.py @@ -109,6 +109,50 @@ def filter_someday_project_tasks(todos): return [todo for todo in todos if not _is_in_someday_project(todo, someday_project_ids, heading_to_project)] +# Helper function to drop top-level duplicates of nested project children +def dedupe_nested_project_children(todos): + """Drop top-level entries whose parent project is also in the result. + + things.today() and things.anytime() return matching projects and those + projects' child tasks as separate top-level rows. With include_items=True + the children are already nested in the project row's 'items', so each + child would be rendered, counted, and paginated twice. Keep the nested + copy and skip the top-level duplicate. Tasks under a heading carry a + 'heading' reference instead of 'project', so headings are resolved to + their parent project the same way the Someday filtering does. + + Args: + todos: List of todo dictionaries + + Returns: + List with top-level duplicates of nested project children removed + """ + project_ids = {t['uuid'] for t in todos if t.get('type') == 'project'} + if not project_ids: + return todos + + # Resolve heading -> project only when some entry needs it. + heading_to_project = {} + if any(not t.get('project') and t.get('heading') for t in todos): + for proj_id in project_ids: + try: + headings = things.tasks(type='heading', project=proj_id) + except Exception: + continue + for h in (headings or []): + heading_to_project[h['uuid']] = proj_id + + deduped = [] + for todo in todos: + parent = todo.get('project') + if not parent and todo.get('heading'): + parent = heading_to_project.get(todo['heading']) + if parent in project_ids: + continue + deduped.append(todo) + return deduped + + # Pagination helpers def _validate_pagination(limit, offset): """Return an error string if limit/offset are invalid, else None.""" @@ -214,8 +258,10 @@ async def get_today(limit: int = None, offset: int = 0) -> ToolResult: todos = things.today(include_items=True) except TypeError: todos = _today_fallback() - # Filter out tasks from Someday projects, then paginate + # Filter out tasks from Someday projects, drop top-level duplicates of + # nested project children, then paginate todos = filter_someday_project_tasks(todos or []) + todos = dedupe_nested_project_children(todos) return _paginate_result(todos, format_todo, limit, offset, "No items found") @mcp.tool @@ -246,8 +292,10 @@ async def get_anytime(limit: int = None, offset: int = 0) -> ToolResult: if err: return _error_result(err) todos = things.anytime(include_items=True) - # Filter out tasks from Someday projects, then paginate + # Filter out tasks from Someday projects, drop top-level duplicates of + # nested project children, then paginate todos = filter_someday_project_tasks(todos or []) + todos = dedupe_nested_project_children(todos) return _paginate_result(todos, format_todo, limit, offset, "No items found") @mcp.tool diff --git a/tests/test_mcp_server_filtering.py b/tests/test_mcp_server_filtering.py index ec15e05..71dbd0b 100644 --- a/tests/test_mcp_server_filtering.py +++ b/tests/test_mcp_server_filtering.py @@ -149,3 +149,121 @@ async def test_get_someday_no_duplicates(self, mock_projects, mock_someday, mock # task-1 should appear only once assert result.count('Already someday') == 1 assert 'Another someday proj task' in result + + +class TestTodayAnytimeDedupe: + """Test that get_today/get_anytime drop top-level duplicates of nested project children.""" + + @pytest.mark.asyncio + @patch('things_mcp.server.things.today') + @patch('things_mcp.server.things.projects') + async def test_get_today_drops_top_level_project_children(self, mock_projects, mock_today): + """Children of a project in the result appear once, nested in the project's items.""" + child_1 = {'uuid': 'child-1', 'title': 'First child', 'project': 'proj-1', 'type': 'to-do'} + child_2 = {'uuid': 'child-2', 'title': 'Second child', 'project': 'proj-1', 'type': 'to-do'} + project = {'uuid': 'proj-1', 'title': 'Started project', 'type': 'project', + 'items': [child_1, child_2]} + standalone_1 = {'uuid': 'solo-1', 'title': 'Standalone one', 'type': 'to-do'} + standalone_2 = {'uuid': 'solo-2', 'title': 'Standalone two', 'type': 'to-do'} + mock_today.return_value = [project, child_1, child_2, standalone_1, standalone_2] + mock_projects.return_value = [] + + result = await things_server.get_today() + + sc = result.structured_content + assert [item['uuid'] for item in sc['items']] == ['proj-1', 'solo-1', 'solo-2'] + assert sc['count'] == 3 + assert sc['total'] == 3 + # Children stay nested in the project entry, exactly once + assert [item['uuid'] for item in sc['items'][0]['items']] == ['child-1', 'child-2'] + text = tool_text(result) + assert text.count('Title:') == 3 + assert 'Standalone one' in text + assert 'Standalone two' in text + assert 'First child' not in text + + @pytest.mark.asyncio + @patch('things_mcp.server.things.anytime') + @patch('things_mcp.server.things.projects') + async def test_get_anytime_drops_top_level_project_children(self, mock_projects, mock_anytime): + """Children of a project in the result appear once, nested in the project's items.""" + child_1 = {'uuid': 'child-1', 'title': 'First child', 'project': 'proj-1', 'type': 'to-do'} + child_2 = {'uuid': 'child-2', 'title': 'Second child', 'project': 'proj-1', 'type': 'to-do'} + project = {'uuid': 'proj-1', 'title': 'Started project', 'type': 'project', + 'items': [child_1, child_2]} + standalone = {'uuid': 'solo-1', 'title': 'Standalone todo', 'type': 'to-do'} + mock_anytime.return_value = [project, child_1, child_2, standalone] + mock_projects.return_value = [] + + result = await things_server.get_anytime() + + sc = result.structured_content + assert [item['uuid'] for item in sc['items']] == ['proj-1', 'solo-1'] + assert sc['count'] == 2 + assert sc['total'] == 2 + assert [item['uuid'] for item in sc['items'][0]['items']] == ['child-1', 'child-2'] + text = tool_text(result) + assert text.count('Title:') == 2 + assert 'Standalone todo' in text + assert 'First child' not in text + + @pytest.mark.asyncio + @patch('things_mcp.server.things.tasks') + @patch('things_mcp.server.things.today') + @patch('things_mcp.server.things.projects') + async def test_get_today_drops_top_level_heading_children(self, mock_projects, mock_today, mock_tasks): + """Tasks under a heading resolve to the heading's project and are deduplicated.""" + heading_child = {'uuid': 'hchild-1', 'title': 'Heading child', 'heading': 'heading-1', 'type': 'to-do'} + heading = {'uuid': 'heading-1', 'title': 'Phase 1', 'type': 'heading', 'items': [heading_child]} + project = {'uuid': 'proj-1', 'title': 'Started project', 'type': 'project', 'items': [heading]} + standalone = {'uuid': 'solo-1', 'title': 'Standalone todo', 'type': 'to-do'} + mock_today.return_value = [project, heading_child, standalone] + mock_projects.return_value = [] + mock_tasks.return_value = [{'uuid': 'heading-1', 'project': 'proj-1'}] + + result = await things_server.get_today() + + sc = result.structured_content + assert [item['uuid'] for item in sc['items']] == ['proj-1', 'solo-1'] + assert sc['count'] == 2 + assert sc['total'] == 2 + mock_tasks.assert_called_once_with(type='heading', project='proj-1') + + @pytest.mark.asyncio + @patch('things_mcp.server.things.anytime') + @patch('things_mcp.server.things.projects') + async def test_get_anytime_keeps_children_of_projects_not_in_result(self, mock_projects, mock_anytime): + """A task whose parent project is not in the result stays top-level.""" + mock_anytime.return_value = [ + {'uuid': 'proj-1', 'title': 'Visible project', 'type': 'project', 'items': []}, + {'uuid': 'task-1', 'title': 'Child of absent project', 'project': 'other-proj', 'type': 'to-do'}, + ] + mock_projects.return_value = [] + + result = await things_server.get_anytime() + + sc = result.structured_content + assert [item['uuid'] for item in sc['items']] == ['proj-1', 'task-1'] + assert sc['count'] == 2 + assert sc['total'] == 2 + + @pytest.mark.asyncio + @patch('things_mcp.server.things.today') + @patch('things_mcp.server.things.projects') + async def test_get_today_paginates_deduplicated_list(self, mock_projects, mock_today): + """limit/offset and the Showing header operate on the deduplicated list.""" + child = {'uuid': 'child-1', 'title': 'Only child', 'project': 'proj-1', 'type': 'to-do'} + project = {'uuid': 'proj-1', 'title': 'Started project', 'type': 'project', 'items': [child]} + standalone_1 = {'uuid': 'solo-1', 'title': 'Standalone one', 'type': 'to-do'} + standalone_2 = {'uuid': 'solo-2', 'title': 'Standalone two', 'type': 'to-do'} + mock_today.return_value = [project, child, standalone_1, standalone_2] + mock_projects.return_value = [] + + result = await things_server.get_today(limit=2) + + text = tool_text(result) + assert 'Showing 1-2 of 3 items' in text + sc = result.structured_content + assert sc['total'] == 3 + assert sc['count'] == 2 + assert [item['uuid'] for item in sc['items']] == ['proj-1', 'solo-1']