Repository navigation
v1.7.0 - GitHub #9/#10 fixes, heading tools, hardened contracts
[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, wheretodoCountis the number of open to-dos directly under that heading. An unknownproject_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 viaadd_project's##lines - placing a to-do under a heading withadd_todo/update_todo'sheading=...requires an existing heading and creates nothing if it doesn't exist (an unknown heading name produces awarningsentry, 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 - usemove_record()to move a to-do out of a heading. If the named heading doesn't exist in the target project, awarningsentry is added rather than erroring, since Things itself ignores an unknown heading silently.add_project'stodospayload now supports real##Headinglines - atodosline like"##Phase 1"now creates a real Things heading (via the URL scheme's structuredjsonaction), with subsequent lines nesting under it; a payload with no##lines still uses the faster AppleScript path. The AppleScript path also now verifies and reportstodos_created/headings_createdcounts (with awarningsentry if fewer were created than requested) instead of trusting the request blindly.update_todocan move a to-do to a project or area via newlist_id/list_titleparameters, matchingadd_todo's existing project/area targeting. Combined withheading, this moves and places under a heading in one call.get_logbookgainsinclude_canceled(defaulttrue) 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). Itslimitcap is also raised from 100 to 500.get_due_in_daysgainsinclude_overdue(defaulttrue, preserving prior behavior); passfalseto restrict results totoday <= deadline <= target dateinstead of also including already-overdue todos.search_todosgains astatusparameter ('incomplete'default /'completed'/'canceled'/Nonefor all).get_logbook,search_todos, andsearch_advancedgainoffsetfor pagination past the first page of results, alongside the existinglimit.get_today/get_upcoming/get_anytime/get_someday/get_trashgaininclude_projects(defaultfalse) 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 unlessTHINGS_MCP_LIVE_TESTS=1is set and Things 3 is actually running), plus amake test-livetarget anddocs/TESTING.mddescribing the full testing policy. All oftests/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, andget_trashno 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 newinclude_projects=trueparameter to opt back into projects; headings are never returned by these tools.get_inboxis unaffected (the Inbox can never contain projects).- Todo/project dicts gained new fields;
checklistchanged from a bool-or-list to a dedicatedhasChecklistboolean.convert_todo/convert_projectwere rewritten against the real things.py key set: completed/canceled todos and projects now correctly reportcompletionDate/cancellationDate(previously always empty). New fields:type,start(Inbox/Anytime/Someday, distinct fromstartDate),projectTitle,heading/headingTitle,index,todayIndex,reminderTime; projects also gainedareaTitle/start/startDate/index/todayIndex.checklist(previously an ambiguous bool-or-list) is nowhasChecklist(bool);checklistonly appears as a real item list wheninclude_items=true(orget_todo_by_id) actually fetched it. The phantomareakey on todo dicts (things.py never actually populates it there) was removed. search_advancedwith nostatusfilter now searches all statuses instead of silently defaulting to incomplete-only; passstatus='incomplete'to restrict, as before.get_recentnow 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.totalin structured output is now the pre-limit count on every list tool. Previouslyget_inbox/get_today/get_upcoming/get_anytime/get_somedaycomputedtotalfrom the already-limit-truncated result, sototalalways equaledcount; they (andsearch_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 inerror(e.g."Invalid mode"), andget_project_headingsused a different shape entirely. Seedocs/UPGRADING.mdfor 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 rawstr(exception)directly inerror. AppleScript/exception text now lives in a separatedetailsfield. Seedocs/UPGRADING.mdfor the full code list. update_todo/update_project/update_area/bulk_update_todoscan now clearnotes,deadline, andtagsby passing''- previously an empty string was silently treated as "not provided" (a no-op), so there was no way to clear these fields.title=''andwhen=''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" onadd_todo/update_todo/bulk_update_todos(previously rejected by validation);add_project/update_projectexplicitly reject it, since Things has no "This Evening" concept for projects.deadlinerelative-keyword rejection ('today', etc.) is now consistent between create and update paths - deadlines must always beYYYY-MM-DD.update_todo/bulk_update_todos/update_projectshare onecompleted/canceledstatus-precedence rule, and the MCP boundary now strictly parsescompleted/canceledastrue/false(case-insensitive) - previously a typo likecompleted='yes'was silently treated asfalseand could unintentionally reopen a completed item; it now returns a structured validation error instead.get_projects/get_areasno longer droparea/areaTitle/tagsunderminimal/standardresponse 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 byget_today/get_upcoming/get_anytime/get_someday/get_trash/search_advanced(withinclude_projects=true), which previously lostarea/areaTitlebecause they were converted as if they were to-dos.delete_todocan 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 structurednot_deletableerror 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 fixesget_todos(project_uuid=..., status=...)silently ignoringstatuswhen 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_advancedno 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 structuredunknown_tagerror with title-based suggestions instead.validate_tag_listrejects 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_tagsno longer over-report the number of tags changed, andremove_tagsno 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_somedaywithmodeomitted now behave asmode='auto'instead of bypassing response-mode handling entirely. Previously an omittedmodeskipped the context manager altogether, returning raw, unfiltered rows withstructured_content.modeleft asNone; it's now treated the same asmode='auto'(AUTO sizing applies - a large list becomes a summary preview) and the resolved mode is reported. Passmode='standard'ormode='raw'explicitly if you relied on getting full, untruncated rows withmodeomitted.update_projectnow honourscanceledinstead of silently ignoring it andcompleted="true"set a completion date instead of the project's status.get_todosnow rejects an unrecognizedstatusvalue with a structuredinvalid_statuserror instead of silently falling through to the incomplete-only default (the MCP boundary already validatedstatus; this is a defence-in-depth check at the helper layer).
Fixed
add_project/update_project/add_todo/update_todono longer collapse newlines in notes (issue #10, bug 3) - the AppleScript string escaper collapsed\n/\rto 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_todovia 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, andlist_idis properly escaped/resolved -headingandlist_titlewere previously only forwarded when a checklist was also supplied, andlist_idwas interpolated into AppleScript unescaped.- Moving/adding under a completed heading or project no longer silently reopens it -
add_todo/update_todonow pre-check the target's status and reject the write with a structuredTARGET_COMPLETEDerror instead of visibly reopening pre-existing user data in Things. get_todo_by_idnow resolves projects, headings, trashed items, and areas (previously raisedValueError: Todo not foundfor 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 alwaysNone- things.py only carries these fields on the heading's own row, not its children). get_activating_in_daysno longer returns already-active todos, andget_due_in_days's overdue-inclusion behavior is now explicit viainclude_overdue(see Added).get_logbook/get_recentno 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
modeinstead of the literal string"auto"whenmodewas omitted or'auto'. - Whitespace-only
when(e.g.' ') is now rejected with the same structured error aswhen='', instead of silently becoming a no-op. - Checklist tools (
add_checklist_items/prepend_checklist_items/replace_checklist_items) now surface the auth-tokenhinton failure, matchingupdate_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: truewhile silently doing nothing.add_checklist_items/prepend_checklist_items/replace_checklist_items(and any other tool routed throughthings:///update, e.g.update_todo's heading/evening paths) previously invokedopenon the URL scheme even with no token configured; Things itself rejects the un-authenticated request, butopen -gstill exits 0, so the tool reported success though nothing happened. A newAUTH_REQUIRING_ACTIONSgate now checks for a configured token before ever invokingopen, returning a structured error with an actionablehintinstead. - Removed 4 stale tests asserting
add_todoaccepts undocumentedurl/statuskwargs -urlandstatuswere never real parameters exposed by the MCP tool (only reachable via directThingsToolscalls, always a silent no-op); no production code changed, only the deadxfailtests 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 inerror(e.g. an unknownlist_id/list_titlenow reportsNOT_FOUND, an ambiguouslist_titlereportsAMBIGUOUS_TARGETwith the matching ids in a newidsfield, an unconfirmed URL-scheme create reportsCREATE_UNCONFIRMED,when='evening'on a project reportsUNSUPPORTED_FOR_PROJECTS, and an emptyheadingonupdate_todoreportsINVALID_HEADING); the Things URL-scheme auth-gate (AppleScriptManager.execute_url_scheme) now reports a stableAUTH_TOKEN_NOT_CONFIGUREDcode with the previous literal error text preserved verbatim inmessage, 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 theget_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 viaadd_todo/update_todo'sheading=...requires the heading to already exist and creates nothing on its own (an unknown heading name produces awarningsentry, not a new heading). things.pyreads 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_COMPLETEDerror rather than reopening it - reopen the target manually in Things first, or choose another target.