Skip to content

v1.7.0 - GitHub #9/#10 fixes, heading tools, hardened contracts

Choose a tag to compare

@ebowman ebowman released this 19 Aug 19:39
· 144 commits to main since this release

[1.7.0] - 2026-08-19

This release closes out two user-reported issues: #9
(list tools returning projects/headings instead of just tasks, and no way to read or
place tasks under a project's headings) and #10
(read-path title corruption on titles containing commas/quotes, and multi-line notes
losing their line breaks on write (the AppleScript string escaper collapsed them) -
the title corruption was on the AppleScript-based read path, which this release also
removes in favor of reading exclusively through things.py, see below). Alongside those, this release also folds
in a broad read/write correctness sweep - pagination totals, structured error shapes,
tag/date/status edge cases, and a large expansion of test coverage (unit suite: 1349
passed, 1 skipped).

Added

  • get_project_headings(project_id, mode?) - new read-only tool for a project's heading structure (issue #9). Returns {uuid, title, index, todoCount} per heading, in Things' own display order, where todoCount is the number of open to-dos directly under that heading. An unknown project_id, or one that resolves to something other than a project, returns a structured error instead of raising. Read-only by design: headings can only be created via add_project's ## lines - placing a to-do under a heading with add_todo/update_todo's heading=... requires an existing heading and creates nothing if it doesn't exist (an unknown heading name produces a warnings entry, not a new heading) - there is no public Things 3 API to rename or delete a heading.
  • update_todo(heading=..., list_id=..., list_title=...) - moves an existing to-do under a heading, optionally into a different project at the same time (issue #9). Requires the Things URL-scheme auth token (checked before any write, so a missing token never partially applies other fields in the same call). heading='' is rejected - use move_record() to move a to-do out of a heading. If the named heading doesn't exist in the target project, a warnings entry is added rather than erroring, since Things itself ignores an unknown heading silently.
  • add_project's todos payload now supports real ##Heading lines - a todos line like "##Phase 1" now creates a real Things heading (via the URL scheme's structured json action), with subsequent lines nesting under it; a payload with no ## lines still uses the faster AppleScript path. The AppleScript path also now verifies and reports todos_created/headings_created counts (with a warnings entry if fewer were created than requested) instead of trusting the request blindly.
  • update_todo can move a to-do to a project or area via new list_id/list_title parameters, matching add_todo's existing project/area targeting. Combined with heading, this moves and places under a heading in one call.
  • get_logbook gains include_canceled (default true) and now also returns canceled to-dos alongside completed ones, matching what the Things app's own Logbook view shows (previously only completed to-dos were returned). Its limit cap is also raised from 100 to 500.
  • get_due_in_days gains include_overdue (default true, preserving prior behavior); pass false to restrict results to today <= deadline <= target date instead of also including already-overdue todos.
  • search_todos gains a status parameter ('incomplete' default / 'completed' / 'canceled' / None for all).
  • get_logbook, search_todos, and search_advanced gain offset for pagination past the first page of results, alongside the existing limit.
  • get_today/get_upcoming/get_anytime/get_someday/get_trash gain include_projects (default false) to opt back into seeing projects in these lists (headings are never returned by any list tool, with or without this flag).
  • Opt-in live Things 3 smoke suite (tests/live/) - a new, self-cleaning test suite that exercises real reads/writes against a running Things 3 (skipped unless THINGS_MCP_LIVE_TESTS=1 is set and Things 3 is actually running), plus a make test-live target and docs/TESTING.md describing the full testing policy. All of tests/integration's real-Things-writing fixtures are now gated behind the same env var - previously several integration test files could write to (and delete from) a live Things 3 database with no opt-in at all.

Changed

  • get_today, get_upcoming, get_anytime, get_someday, and get_trash no longer return projects or headings by default (issue #9) - these list tools previously mixed projects and headings in with to-dos (e.g. get_anytime() on a real database returned 1175 items - 1106 to-dos, 39 projects, 30 headings - instead of the 1106 to-dos a caller would expect). Pass the new include_projects=true parameter to opt back into projects; headings are never returned by these tools. get_inbox is unaffected (the Inbox can never contain projects).
  • Todo/project dicts gained new fields; checklist changed from a bool-or-list to a dedicated hasChecklist boolean. convert_todo/convert_project were rewritten against the real things.py key set: completed/canceled todos and projects now correctly report completionDate/cancellationDate (previously always empty). New fields: type, start (Inbox/Anytime/Someday, distinct from startDate), projectTitle, heading/headingTitle, index, todayIndex, reminderTime; projects also gained areaTitle/start/startDate/index/todayIndex. checklist (previously an ambiguous bool-or-list) is now hasChecklist (bool); checklist only appears as a real item list when include_items=true (or get_todo_by_id) actually fetched it. The phantom area key on todo dicts (things.py never actually populates it there) was removed.
  • search_advanced with no status filter now searches all statuses instead of silently defaulting to incomplete-only; pass status='incomplete' to restrict, as before. get_recent now defaults to all statuses and both to-dos and projects (previously incomplete to-dos only); headings are still never included by default. search_todos('') (empty/whitespace query) is now rejected with a structured error instead of matching everything.
  • total in structured output is now the pre-limit count on every list tool. Previously get_inbox/get_today/get_upcoming/get_anytime/get_someday computed total from the already-limit-truncated result, so total always equaled count; they (and search_todos/search_advanced/get_logbook) now compute the true pre-limit/offset match count.
  • Read tools' structured errors now share one canonical shape: {"success": false, "error": "<snake_case_code>", "message": "..."}. Previously some tools put a human sentence directly in error (e.g. "Invalid mode"), and get_project_headings used a different shape entirely. See docs/UPGRADING.md for the full code list.
  • Write tools' structured errors now share one canonical UPPER_SNAKE_CASE shape: {"success": false, "error": "INVALID_WHEN", "field": "when", "message": "..."}. Previously several write tools put a human sentence or a raw str(exception) directly in error. AppleScript/exception text now lives in a separate details field. See docs/UPGRADING.md for the full code list.
  • update_todo/update_project/update_area/bulk_update_todos can now clear notes, deadline, and tags by passing '' - previously an empty string was silently treated as "not provided" (a no-op), so there was no way to clear these fields. title='' and when='' are now rejected with a structured error instead of being silently ignored (when='' - use 'anytime'/'someday' to unschedule).
  • when='evening' (and alias 'tonight') is now accepted and correctly schedules to-dos for "This Evening" on add_todo/update_todo/bulk_update_todos (previously rejected by validation); add_project/update_project explicitly reject it, since Things has no "This Evening" concept for projects. deadline relative-keyword rejection ('today', etc.) is now consistent between create and update paths - deadlines must always be YYYY-MM-DD.
  • update_todo/bulk_update_todos/update_project share one completed/canceled status-precedence rule, and the MCP boundary now strictly parses completed/canceled as true/false (case-insensitive) - previously a typo like completed='yes' was silently treated as false and could unintentionally reopen a completed item; it now returns a structured validation error instead.
  • get_projects/get_areas no longer drop area/areaTitle/tags under minimal/standard response modes - project and area rows now use their own field sets distinct from the to-do field set, so these fields survive filtering at every mode. This also fixes mixed-list project rows returned by get_today/get_upcoming/get_anytime/get_someday/get_trash/search_advanced (with include_projects=true), which previously lost area/areaTitle because they were converted as if they were to-dos.
  • delete_todo can now delete a project id (previously always errored with a generic AppleScript failure). Headings, areas, and tags still cannot be deleted via any public Things 3 API and now return a structured not_deletable error naming the manual UI workflow, instead of attempting a doomed AppleScript call.
  • get_todos(project_uuid=...) now reads via things.py instead of AppleScript - the AppleScript path existed to work around a suspected read-after-write lag that measurement showed does not meaningfully exist (~7-8ms observed). This also fixes get_todos(project_uuid=..., status=...) silently ignoring status when a project was given. The dead AppleScript read-parser stack (services/applescript/parser.py, services/applescript/queries.py, and related dead code) was removed as a result.
  • get_tagged_items/search_advanced no longer silently return an empty list for an unknown or wrong-case tag - Things tag matching is exact-case, and these tools now return a structured unknown_tag error with title-based suggestions instead.
  • validate_tag_list rejects a comma inside an individual tag name (list-input API callers only - the comma-separated string form every MCP tool actually uses is unaffected). remove_tags/add_tags no longer over-report the number of tags changed, and remove_tags no longer applies tag-creation policy (removing a tag a todo doesn't have is a no-op, not a creation/filtering decision).
  • get_inbox/get_today/get_upcoming/get_anytime/get_someday with mode omitted now behave as mode='auto' instead of bypassing response-mode handling entirely. Previously an omitted mode skipped the context manager altogether, returning raw, unfiltered rows with structured_content.mode left as None; it's now treated the same as mode='auto' (AUTO sizing applies - a large list becomes a summary preview) and the resolved mode is reported. Pass mode='standard' or mode='raw' explicitly if you relied on getting full, untruncated rows with mode omitted.
  • update_project now honours canceled instead of silently ignoring it and completed="true" set a completion date instead of the project's status.
  • get_todos now rejects an unrecognized status value with a structured invalid_status error instead of silently falling through to the incomplete-only default (the MCP boundary already validated status; this is a defence-in-depth check at the helper layer).

Fixed

  • add_project/update_project/add_todo/update_todo no longer collapse newlines in notes (issue #10, bug 3) - the AppleScript string escaper collapsed \n/\r to a single space before this release, so multi-line project/todo notes written through any of these tools lost their paragraph breaks. All AppleScript-string-building call sites now share one escaper that maps newlines/carriage returns/tabs to their AppleScript literal escape sequences, so they survive intact inside the generated script. A related bug where a value ending in a literal double quote (e.g. title='Say "hi"') could produce an unbalanced, syntactically-broken AppleScript string was fixed as part of the same consolidation.
  • add_todo via the Things URL scheme no longer risks returning the wrong id when two to-dos share a title - it previously identified the newly-created to-do by title and 1-second-granularity creation timestamp after a fixed sleep; it now snapshots existing ids before the create and polls for the new id, disambiguating (with a warning) when more than one appears and returning a clear failure instead of a false success when none appears within the poll window.
  • add_todo(heading=...) is now honoured on every path, and list_id is properly escaped/resolved - heading and list_title were previously only forwarded when a checklist was also supplied, and list_id was interpolated into AppleScript unescaped.
  • Moving/adding under a completed heading or project no longer silently reopens it - add_todo/update_todo now pre-check the target's status and reject the write with a structured TARGET_COMPLETED error instead of visibly reopening pre-existing user data in Things.
  • get_todo_by_id now resolves projects, headings, trashed items, and areas (previously raised ValueError: Todo not found for anything but a to-do); a tag uuid now returns a structured error instead of a misleading, mostly-empty to-do-shaped dict.
  • Todos filed under a heading now report project/projectTitle (previously always None - things.py only carries these fields on the heading's own row, not its children).
  • get_activating_in_days no longer returns already-active todos, and get_due_in_days's overdue-inclusion behavior is now explicit via include_overdue (see Added).
  • get_logbook/get_recent no longer silently drop rows with a timezone-aware completion/creation date in the (unlikely, but possible) case things.py emits one.
  • Empty list results now report a concrete effective mode instead of the literal string "auto" when mode was omitted or 'auto'.
  • Whitespace-only when (e.g. ' ') is now rejected with the same structured error as when='', instead of silently becoming a no-op.
  • Checklist tools (add_checklist_items/prepend_checklist_items/replace_checklist_items) now surface the auth-token hint on failure, matching update_todo's heading/evening paths.
  • URL-scheme-based write tools now fail fast with a structured error when the Things auth token is missing, instead of returning success: true while silently doing nothing. add_checklist_items/prepend_checklist_items/replace_checklist_items (and any other tool routed through things:///update, e.g. update_todo's heading/evening paths) previously invoked open on the URL scheme even with no token configured; Things itself rejects the un-authenticated request, but open -g still exits 0, so the tool reported success though nothing happened. A new AUTH_REQUIRING_ACTIONS gate now checks for a configured token before ever invoking open, returning a structured error with an actionable hint instead.
  • Removed 4 stale tests asserting add_todo accepts undocumented url/status kwargs - url and status were never real parameters exposed by the MCP tool (only reachable via direct ThingsTools calls, always a silent no-op); no production code changed, only the dead xfail tests were dropped.
  • scheduling/todo_operations.py (add_todo/update_todo/add_project/update_project/checklist scheduling) now returns the same structured UPPER_SNAKE_CASE write-tool error shape as the rest of the write layer, instead of human-string/str(exception) values in error (e.g. an unknown list_id/list_title now reports NOT_FOUND, an ambiguous list_title reports AMBIGUOUS_TARGET with the matching ids in a new ids field, an unconfirmed URL-scheme create reports CREATE_UNCONFIRMED, when='evening' on a project reports UNSUPPORTED_FOR_PROJECTS, and an empty heading on update_todo reports INVALID_HEADING); the Things URL-scheme auth-gate (AppleScriptManager.execute_url_scheme) now reports a stable AUTH_TOKEN_NOT_CONFIGURED code with the previous literal error text preserved verbatim in message, forwarded as-is by every consumer (add_checklist_items/prepend_checklist_items/replace_checklist_items/update_todo/bulk_update_todos).

Removed

  • Dead AppleScript read-parser stack (services/applescript/parser.py, services/applescript/queries.py, related manager methods and config flags) - reads have gone through things.py exclusively since the get_todos(project_uuid=...) fix above; nothing called this code anymore.
  • Dead ResponseOptimizer/response_optimizer.py - unused, and built against a stale pre-1.7.0 field schema that no longer matched real converter output.

Docs

  • README "Why this server?" section and docs/COMPARISON.md - a new README section answering "why this instead of hald/things-mcp?", pointing to a dated feature-matrix comparison doc.

Known limitations

  • Headings cannot be renamed or deleted via any public Things 3 API (no AppleScript heading class); they can only be created via add_project's ## lines - placing a to-do under a heading via add_todo/update_todo's heading=... requires the heading to already exist and creates nothing on its own (an unknown heading name produces a warnings entry, not a new heading).
  • things.py reads can lag a Things URL-scheme write (things:///add, things:///update - used for headings, checklists, when='evening') by roughly 1-2 seconds, since the write is processed asynchronously while things.py reads the local SQLite snapshot directly. Plain AppleScript writes do not have this lag.
  • Writing into (or moving a to-do into) a completed/canceled project or heading is refused with a structured TARGET_COMPLETED error rather than reopening it - reopen the target manually in Things first, or choose another target.