Skip to content

Commit 0192fcd

Browse files
add added/changed task date support and release notes for 1.1.5
implement added/changed date filters and payload fields across python/typescript/rust, document the new capabilities, and bump rust package metadata to 1.1.5 with changelog updates. Made-with: Cursor
1 parent 5d423ba commit 0192fcd

16 files changed

Lines changed: 1840 additions & 165 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
## [1.1.5] - 2026-03-05
6+
7+
### Added
8+
- support task date filters for creation/last-modified dates:
9+
- `added_after`, `added_before`
10+
- `changed_after`, `changed_before` (`changed` maps to OmniFocus `modified`)
11+
- include `addedDate` and `changedDate` in task payloads returned by read tools
12+
13+
### Changed
14+
- updated documentation in root, Python, TypeScript, and Rust READMEs to describe
15+
the new date filters and task date fields
16+
- bumped Rust package metadata from `1.1.4` to `1.1.5`
17+
18+
## [1.1.4] - 2026-03-03
19+
20+
### Fixed
21+
- perspective enumeration now includes built-in, custom, and document perspectives
22+
so completed and custom perspectives are returned correctly
23+
24+
### Added
25+
- batch deletion tools for projects, tags, and folders with partial-success output:
26+
- `delete_projects_batch`
27+
- `delete_tags_batch`
28+
- `delete_folders_batch`

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ Full lifecycle management for OmniFocus tasks:
6868
| `dueBefore` / `dueAfter` | Due date range (ISO 8601) |
6969
| `deferBefore` / `deferAfter` | Defer date range (ISO 8601) |
7070
| `completedBefore` / `completedAfter` | Completion date range (ISO 8601) |
71+
| `addedBefore` / `addedAfter` | Creation date range (ISO 8601) |
72+
| `changedBefore` / `changedAfter` | Last-modified date range (ISO 8601, maps to OmniFocus `modified`) |
7173
| `plannedBefore` / `plannedAfter` | Planned date range (ISO 8601) |
7274
| `maxEstimatedMinutes` | Tasks with estimated duration up to N minutes |
7375

@@ -77,6 +79,7 @@ All list/search tools support `sortBy` and `sortOrder`:
7779

7880
- Sort by: `name`, `due`, `defer`, `added`, `modified`, `completed`, `estimated`, `planned`
7981
- Sort order: `asc` (default) or `desc`
82+
- Task payloads include `addedDate` and `changedDate` (ISO 8601 or `null`)
8083

8184
### Projects (11 tools)
8285

python/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,14 @@ Once connected from your MCP client, try:
9898

9999
- `ping` to verify server health
100100
- `get_inbox` to retrieve current inbox tasks
101-
- `list_tasks` with filters such as `status="due_soon"`
101+
- `list_tasks` with filters such as `status="due_soon"` or date ranges (`added_after`, `added_before`, `changed_after`, `changed_before`)
102102
- `create_task` to add an inbox or project task
103103
- `project_planning` prompt to generate a structured plan from project state
104104

105+
Task payloads returned by read tools include:
106+
- `addedDate` (task creation timestamp, ISO 8601 or `null`)
107+
- `changedDate` (task last-modified timestamp, ISO 8601 or `null`; maps to OmniFocus `modified`)
108+
105109
## Development Checks
106110

107111
Run the Python checks:

python/src/omnifocus_mcp/tools/tasks.py

Lines changed: 106 additions & 1 deletion
Large diffs are not rendered by default.

python/tests/test_tools_read.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ async def test_get_inbox_happy_path(
8484
"name": "Inbox Task",
8585
"note": "n",
8686
"flagged": False,
87+
"addedDate": "2026-02-01T09:00:00Z",
88+
"changedDate": "2026-02-03T10:30:00Z",
8789
"dueDate": None,
8890
"deferDate": None,
8991
"completionDate": None,
@@ -116,6 +118,13 @@ async def test_get_inbox_happy_path(
116118
"completionDate: task.completionDate ? task.completionDate.toISOString() : null,"
117119
in state["calls"][0]["script"]
118120
)
121+
assert "addedDate: task.added ? task.added.toISOString() : null," in state["calls"][0][
122+
"script"
123+
]
124+
assert (
125+
"changedDate: task.modified ? task.modified.toISOString() : null,"
126+
in state["calls"][0]["script"]
127+
)
119128
assert "hasChildren: task.hasChildren" in state["calls"][0]["script"]
120129
assert "taskStatus: (() => {" in state["calls"][0]["script"]
121130
assert 'if (s.includes("Dropped")) return "dropped";' in state["calls"][0]["script"]
@@ -278,6 +287,8 @@ async def test_list_tasks_happy_path(
278287
"name": "Task",
279288
"note": "note",
280289
"flagged": True,
290+
"addedDate": "2026-02-01T09:00:00Z",
291+
"changedDate": "2026-02-05T10:30:00Z",
281292
"dueDate": "2026-03-01T10:00:00Z",
282293
"deferDate": None,
283294
"completed": False,
@@ -309,6 +320,13 @@ async def test_list_tasks_happy_path(
309320
"plannedDate: plannedDate ? plannedDate.toISOString() : null,"
310321
in state["calls"][0]["script"]
311322
)
323+
assert "addedDate: task.added ? task.added.toISOString() : null," in state["calls"][0][
324+
"script"
325+
]
326+
assert (
327+
"changedDate: task.modified ? task.modified.toISOString() : null,"
328+
in state["calls"][0]["script"]
329+
)
312330
assert "hasChildren: task.hasChildren" in state["calls"][0]["script"]
313331
assert 'if (s.includes("Available")) return "available";' in state["calls"][0]["script"]
314332
assert ".slice(0, 7)" in state["calls"][0]["script"]
@@ -362,6 +380,46 @@ async def test_list_tasks_date_filters_are_included_in_script(
362380
assert ".slice(0, 9)" in script
363381

364382

383+
@pytest.mark.asyncio
384+
async def test_list_tasks_added_changed_date_filters_are_included_in_script(
385+
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
386+
) -> None:
387+
configured = mock_server_run_omnijs([])
388+
state = configured["state"]
389+
server = configured["server"]
390+
391+
await server.list_tasks(
392+
added_after="2026-02-01T00:00:00Z",
393+
added_before="2026-02-28T23:59:59Z",
394+
changed_after="2026-03-01T00:00:00Z",
395+
changed_before="2026-03-31T23:59:59Z",
396+
limit=5,
397+
)
398+
399+
script = state["calls"][0]["script"]
400+
assert 'const addedAfterRaw = "2026-02-01T00:00:00Z";' in script
401+
assert 'const addedBeforeRaw = "2026-02-28T23:59:59Z";' in script
402+
assert 'const changedAfterRaw = "2026-03-01T00:00:00Z";' in script
403+
assert 'const changedBeforeRaw = "2026-03-31T23:59:59Z";' in script
404+
assert (
405+
"if (addedBefore !== null && !(task.added !== null && task.added <= addedBefore)) return false;"
406+
in script
407+
)
408+
assert (
409+
"if (addedAfter !== null && !(task.added !== null && task.added >= addedAfter)) return false;"
410+
in script
411+
)
412+
assert (
413+
"if (changedBefore !== null && !(task.modified !== null && task.modified <= changedBefore)) return false;"
414+
in script
415+
)
416+
assert (
417+
"if (changedAfter !== null && !(task.modified !== null && task.modified >= changedAfter)) return false;"
418+
in script
419+
)
420+
assert ".slice(0, 5)" in script
421+
422+
365423
@pytest.mark.asyncio
366424
async def test_list_tasks_completed_date_filter_auto_includes_completed_logic(
367425
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
@@ -606,6 +664,44 @@ async def test_get_task_counts_includes_filters_and_aggregate_counters_in_script
606664
assert json.loads(result)["total"] == 4
607665

608666

667+
@pytest.mark.asyncio
668+
async def test_get_task_counts_added_changed_date_filters_are_included_in_script(
669+
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
670+
) -> None:
671+
configured = mock_server_run_omnijs({"total": 0})
672+
state = configured["state"]
673+
server = configured["server"]
674+
675+
await server.get_task_counts(
676+
added_after="2026-02-01T00:00:00Z",
677+
added_before="2026-02-28T23:59:59Z",
678+
changed_after="2026-03-01T00:00:00Z",
679+
changed_before="2026-03-31T23:59:59Z",
680+
)
681+
682+
script = state["calls"][0]["script"]
683+
assert 'const addedAfterRaw = "2026-02-01T00:00:00Z";' in script
684+
assert 'const addedBeforeRaw = "2026-02-28T23:59:59Z";' in script
685+
assert 'const changedAfterRaw = "2026-03-01T00:00:00Z";' in script
686+
assert 'const changedBeforeRaw = "2026-03-31T23:59:59Z";' in script
687+
assert (
688+
"if (addedBefore !== null && !(task.added !== null && task.added <= addedBefore)) continue;"
689+
in script
690+
)
691+
assert (
692+
"if (addedAfter !== null && !(task.added !== null && task.added >= addedAfter)) continue;"
693+
in script
694+
)
695+
assert (
696+
"if (changedBefore !== null && !(task.modified !== null && task.modified <= changedBefore)) continue;"
697+
in script
698+
)
699+
assert (
700+
"if (changedAfter !== null && !(task.modified !== null && task.modified >= changedAfter)) continue;"
701+
in script
702+
)
703+
704+
609705
@pytest.mark.asyncio
610706
async def test_get_task_happy_path(
611707
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
@@ -615,6 +711,8 @@ async def test_get_task_happy_path(
615711
"name": "Task 3",
616712
"note": "",
617713
"flagged": False,
714+
"addedDate": "2026-02-01T09:00:00Z",
715+
"changedDate": "2026-02-06T11:00:00Z",
618716
"dueDate": None,
619717
"deferDate": None,
620718
"effectiveDueDate": None,
@@ -646,6 +744,13 @@ async def test_get_task_happy_path(
646744
assert "effectiveDueDate: task.effectiveDueDate ? task.effectiveDueDate.toISOString() : null," in state["calls"][0]["script"]
647745
assert "effectiveDeferDate: task.effectiveDeferDate ? task.effectiveDeferDate.toISOString() : null," in state["calls"][0]["script"]
648746
assert "effectiveFlagged: task.effectiveFlagged," in state["calls"][0]["script"]
747+
assert "addedDate: task.added ? task.added.toISOString() : null," in state["calls"][0][
748+
"script"
749+
]
750+
assert (
751+
"changedDate: task.modified ? task.modified.toISOString() : null,"
752+
in state["calls"][0]["script"]
753+
)
649754
assert "modified: task.modified ? task.modified.toISOString() : null," in state["calls"][0]["script"]
650755
assert "plannedDate: plannedDate ? plannedDate.toISOString() : null," in state["calls"][0]["script"]
651756
assert "effectivePlannedDate: effectivePlannedDate ? effectivePlannedDate.toISOString() : null," in state["calls"][0]["script"]
@@ -899,6 +1004,8 @@ async def test_search_tasks_happy_path(
8991004
"name": "Buy milk",
9001005
"note": "fridge",
9011006
"flagged": False,
1007+
"addedDate": "2026-02-01T09:00:00Z",
1008+
"changedDate": "2026-02-08T12:00:00Z",
9021009
"dueDate": None,
9031010
"deferDate": None,
9041011
"completed": False,
@@ -928,6 +1035,13 @@ async def test_search_tasks_happy_path(
9281035
"plannedDate: plannedDate ? plannedDate.toISOString() : null,"
9291036
in state["calls"][0]["script"]
9301037
)
1038+
assert "addedDate: task.added ? task.added.toISOString() : null," in state["calls"][0][
1039+
"script"
1040+
]
1041+
assert (
1042+
"changedDate: task.modified ? task.modified.toISOString() : null,"
1043+
in state["calls"][0]["script"]
1044+
)
9311045
assert "hasChildren: task.hasChildren" in state["calls"][0]["script"]
9321046
assert "taskStatus: (() => {" in state["calls"][0]["script"]
9331047
assert 'if (s.includes("DueSoon")) return "due_soon";' in state["calls"][0]["script"]
@@ -954,6 +1068,46 @@ async def test_search_tasks_with_project_filter_uses_combined_filters(
9541068
)
9551069

9561070

1071+
@pytest.mark.asyncio
1072+
async def test_search_tasks_added_changed_date_filters_are_included_in_script(
1073+
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
1074+
) -> None:
1075+
configured = mock_server_run_omnijs([{"id": "t-changed", "name": "task"}])
1076+
state = configured["state"]
1077+
server = configured["server"]
1078+
1079+
await server.search_tasks(
1080+
query="shape",
1081+
added_after="2026-02-01T00:00:00Z",
1082+
added_before="2026-02-28T23:59:59Z",
1083+
changed_after="2026-03-01T00:00:00Z",
1084+
changed_before="2026-03-31T23:59:59Z",
1085+
limit=4,
1086+
)
1087+
script = state["calls"][0]["script"]
1088+
assert 'const addedAfterRaw = "2026-02-01T00:00:00Z";' in script
1089+
assert 'const addedBeforeRaw = "2026-02-28T23:59:59Z";' in script
1090+
assert 'const changedAfterRaw = "2026-03-01T00:00:00Z";' in script
1091+
assert 'const changedBeforeRaw = "2026-03-31T23:59:59Z";' in script
1092+
assert (
1093+
"if (addedBefore !== null && !(task.added !== null && task.added <= addedBefore)) return false;"
1094+
in script
1095+
)
1096+
assert (
1097+
"if (addedAfter !== null && !(task.added !== null && task.added >= addedAfter)) return false;"
1098+
in script
1099+
)
1100+
assert (
1101+
"if (changedBefore !== null && !(task.modified !== null && task.modified <= changedBefore)) return false;"
1102+
in script
1103+
)
1104+
assert (
1105+
"if (changedAfter !== null && !(task.modified !== null && task.modified >= changedAfter)) return false;"
1106+
in script
1107+
)
1108+
assert ".slice(0, 4)" in script
1109+
1110+
9571111
@pytest.mark.asyncio
9581112
async def test_search_tasks_with_completed_after_auto_includes_completed_and_auto_sort(
9591113
mock_server_run_omnijs: Callable[[Any], dict[str, Any]],
@@ -1452,6 +1606,63 @@ async def fake_run_omnijs(script: str, timeout_seconds: float = 30.0) -> Any:
14521606
await server_module.get_task_counts(dueBefore="bad-date")
14531607

14541608

1609+
@pytest.mark.asyncio
1610+
@pytest.mark.parametrize(
1611+
"field_name",
1612+
["added_after", "added_before", "changed_after", "changed_before"],
1613+
)
1614+
async def test_list_tasks_new_date_filters_invalid_date_error_bubbles_up(
1615+
server_module: Any, monkeypatch: pytest.MonkeyPatch, field_name: str
1616+
) -> None:
1617+
async def fake_run_omnijs(script: str, timeout_seconds: float = 30.0) -> Any:
1618+
raise RuntimeError(f"{field_name} must be a valid ISO 8601 date string.")
1619+
1620+
_patch_run_omnijs(monkeypatch, server_module, fake_run_omnijs)
1621+
1622+
with pytest.raises(
1623+
RuntimeError, match=rf"{field_name} must be a valid ISO 8601 date string."
1624+
):
1625+
await server_module.list_tasks(**{field_name: "bad-date"})
1626+
1627+
1628+
@pytest.mark.asyncio
1629+
@pytest.mark.parametrize(
1630+
"field_name",
1631+
["added_after", "added_before", "changed_after", "changed_before"],
1632+
)
1633+
async def test_search_tasks_new_date_filters_invalid_date_error_bubbles_up(
1634+
server_module: Any, monkeypatch: pytest.MonkeyPatch, field_name: str
1635+
) -> None:
1636+
async def fake_run_omnijs(script: str, timeout_seconds: float = 30.0) -> Any:
1637+
raise RuntimeError(f"{field_name} must be a valid ISO 8601 date string.")
1638+
1639+
_patch_run_omnijs(monkeypatch, server_module, fake_run_omnijs)
1640+
1641+
with pytest.raises(
1642+
RuntimeError, match=rf"{field_name} must be a valid ISO 8601 date string."
1643+
):
1644+
await server_module.search_tasks(query="shape", **{field_name: "bad-date"})
1645+
1646+
1647+
@pytest.mark.asyncio
1648+
@pytest.mark.parametrize(
1649+
"field_name",
1650+
["added_after", "added_before", "changed_after", "changed_before"],
1651+
)
1652+
async def test_get_task_counts_new_date_filters_invalid_date_error_bubbles_up(
1653+
server_module: Any, monkeypatch: pytest.MonkeyPatch, field_name: str
1654+
) -> None:
1655+
async def fake_run_omnijs(script: str, timeout_seconds: float = 30.0) -> Any:
1656+
raise RuntimeError(f"{field_name} must be a valid ISO 8601 date string.")
1657+
1658+
_patch_run_omnijs(monkeypatch, server_module, fake_run_omnijs)
1659+
1660+
with pytest.raises(
1661+
RuntimeError, match=rf"{field_name} must be a valid ISO 8601 date string."
1662+
):
1663+
await server_module.get_task_counts(**{field_name: "bad-date"})
1664+
1665+
14551666
@pytest.mark.asyncio
14561667
async def test_list_subtasks_empty_task_id_validation_error(server_module: Any) -> None:
14571668
with pytest.raises(ValueError, match="task_id must not be empty"):

rust/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
[package]
1515
name = "omnifocus-mcp"
16-
version = "1.1.4"
16+
version = "1.1.5"
1717
edition = "2021"
1818
license = "MIT"
1919

rust/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ compiled binary (`omnifocus-mcp`) distributable via Homebrew.
77

88
- 40 tools, 3 resources, 4 prompts — full API parity with Python and TypeScript
99
- Advanced read-side filtering and sorting on tasks/projects (date ranges, multi-tag modes, stalled detection)
10+
- Added/changed task date filtering (`added_*`, `changed_*`) for list/search/count read tools
1011
- Aggregate count tools (`get_task_counts`, `get_project_counts`) for fast "how many" queries
1112
- Single binary, zero runtime dependencies
1213
- ~5 MB release build
1314
- Homebrew-installable (`brew install omnifocus-mcp`)
1415

16+
Task payloads returned by read tools include:
17+
- `addedDate` (task creation timestamp, ISO 8601 or `null`)
18+
- `changedDate` (task last-modified timestamp, ISO 8601 or `null`; maps to OmniFocus `modified`)
19+
1520
## Prerequisites
1621

1722
- macOS (required — uses `osascript` for OmniFocus communication)

0 commit comments

Comments
 (0)