Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/things_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_mcp_server_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']