things-api 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +57 -12
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.js.map +0 -1
@@ -13,37 +13,38 @@
13
13
  */
14
14
  import { z } from "zod";
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
- import { openThings } from "../client.js";
17
- import { PKG_VERSION } from "../contracts.js";
18
- import { diagnose } from "../diagnose.js";
19
- import { AREA_PREVIEW_LIMIT, DEFAULT_LIST_LIMIT, PROJECT_PREVIEW_LIMIT, capAreaSections, paginateList, paginateToday, previewSections, previewSomedaySections, } from "../read/pagination.js";
20
- import { resolveCap } from "../read/caps.js";
21
- import { ALL_DESC, AREA_LIMIT_DESC, DATE_FORMAT, LIMIT_DESC, PROJECT_LIMIT_DESC, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES, } from "../surface-copy.js";
22
- import { capabilitiesTable } from "../write/capabilities.js";
23
- import { OPERATION_KINDS } from "../write/operations.js";
24
- import { BOUNCE_MAX_ITEMS } from "../write/reorder.js";
16
+ import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, isValidTimeZone, LIMIT_DESC, MCP_WHEN_LABELS, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
25
17
  function jsonResult(data) {
26
18
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
27
19
  }
20
+ /**
21
+ * A read result: like {@link jsonResult}, but the entity payload passes through
22
+ * the omit-empty transform (docs/design/contracts.md) so an empty optional
23
+ * field is absent, not null/[]. Mutation results keep their own shape and use
24
+ * jsonResult directly.
25
+ */
26
+ function readResult(data) {
27
+ return jsonResult(omitEmpty(data));
28
+ }
28
29
  /**
29
30
  * A read result carrying truncation metadata: the data (already limited) in
30
- * the first content block, and a second block with the {@link Pagination}
31
+ * the first content block, and a second block with the {@link Truncation}
31
32
  * numbers plus a one-line note the agent can read when rows were dropped.
32
33
  */
33
- function paginatedResult(data, pagination) {
34
- const note = pagination.truncated
35
- ? `showing ${pagination.shown} of ${pagination.total} items — pass limit (or all: true) to see more`
34
+ function truncatedResult(data, truncation) {
35
+ const note = truncation.truncated
36
+ ? `showing ${truncation.shown} of ${truncation.total} items — pass limit (or all: true) to see more`
36
37
  : undefined;
37
38
  return {
38
39
  content: [
39
- { type: "text", text: JSON.stringify(data) },
40
- { type: "text", text: JSON.stringify({ pagination, ...(note !== undefined && { note }) }) },
40
+ { type: "text", text: JSON.stringify(omitEmpty(data)) },
41
+ { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
41
42
  ],
42
43
  };
43
44
  }
44
45
  /**
45
46
  * Grouped read result (anytime/someday): the per-block-truncated sections plus
46
- * a second block carrying the {@link GroupedPagination} counts and, when
47
+ * a second block carrying the {@link GroupedTruncation} counts and, when
47
48
  * anything was hidden, a one-line note the agent can read.
48
49
  */
49
50
  function groupedResult(data, grouped) {
@@ -52,20 +53,59 @@ function groupedResult(data, grouped) {
52
53
  : undefined;
53
54
  return {
54
55
  content: [
55
- { type: "text", text: JSON.stringify(data) },
56
+ { type: "text", text: JSON.stringify(omitEmpty(data)) },
56
57
  { type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
57
58
  ],
58
59
  };
59
60
  }
60
- /** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
61
+ /**
62
+ * Resolve one MCP cap (limit / area_limit / project_limit) against `all` into a
63
+ * row cap for the client: `null` = every row, `"conflict"` when an explicit
64
+ * value is combined with `all: true` (the tool returns a usage error), else the
65
+ * value or the default. The client re-applies the same semantics on what it
66
+ * receives; this copy owns the conflict detection the MCP surface reports.
67
+ */
68
+ function resolveCap(value, all, defaultLimit) {
69
+ if (all === true && value !== undefined)
70
+ return "conflict";
71
+ if (all === true)
72
+ return null;
73
+ return value ?? defaultLimit;
74
+ }
75
+ /**
76
+ * Resolve MCP limit/all (flat read tools) into a row cap (null = every row).
77
+ * all:true wins: it lifts the cap and takes precedence over a limit passed
78
+ * alongside it — an explicit "everything" request resolves the contradiction
79
+ * rather than erroring — so the pair is never a usage error on the flat tools.
80
+ */
61
81
  function resolveLimit(args) {
62
- return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
82
+ if (args.all === true)
83
+ return null;
84
+ return args.limit ?? DEFAULT_LIST_LIMIT;
63
85
  }
86
+ /** Precedence notes appended to `limit`/`all` wherever a flat tool accepts both. */
87
+ const LIMIT_IGNORED_NOTE = "ignored when all is set";
88
+ const ALL_WINS_NOTE = "wins over limit if both are set";
64
89
  /** Shared limit/all input schema fragment for the flat read tools. */
65
90
  const limitShape = {
66
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
67
- all: z.boolean().optional().describe(ALL_DESC),
91
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
92
+ all: z.boolean().optional().describe(`${ALL_DESC}; ${ALL_WINS_NOTE}`),
68
93
  };
94
+ /**
95
+ * The per-call time-zone knob for date-sensitive tools: an IANA zone that
96
+ * evaluates every date boundary (today/evening/upcoming/logbook/overdue/…) for
97
+ * the consumer's calendar, overriding the server's THINGS_TZ for THIS call.
98
+ */
99
+ const TZ_DESC = "IANA time zone (e.g. Asia/Tokyo) to evaluate date boundaries in for this call — " +
100
+ "overrides the server default. Reminder times stay wall-clock and are never shifted.";
101
+ const tzShape = { tz: z.string().optional().describe(TZ_DESC) };
102
+ /** A usage result when `tz` is present but not a recognized IANA zone; null when it is valid/absent. */
103
+ function badTz(tz) {
104
+ if (tz !== undefined && !isValidTimeZone(tz)) {
105
+ return usage(`tz is not a valid IANA time zone: "${tz}" — expected e.g. "America/New_York" or "Asia/Tokyo"`);
106
+ }
107
+ return null;
108
+ }
69
109
  function errorResult(error) {
70
110
  return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
71
111
  }
@@ -80,14 +120,14 @@ function mutationResult(result) {
80
120
  return jsonResult(result);
81
121
  case "blocked":
82
122
  return errorResult({
83
- code: `blocked:${result.hazard ?? result.reason}`,
123
+ code: blockedCode(result),
84
124
  message: result.detail,
85
125
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
86
126
  remediation: result.remediation,
87
127
  });
88
128
  case "verify-failed":
89
129
  return errorResult({
90
- code: `verify-failed:${result.reason}`,
130
+ code: verifyFailedCode(result),
91
131
  message: result.detail,
92
132
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
93
133
  ...(result.hint !== undefined && { remediation: result.hint }),
@@ -109,19 +149,87 @@ function mutationResult(result) {
109
149
  const READ_ONLY = { readOnlyHint: true };
110
150
  const NON_DESTRUCTIVE = { destructiveHint: false };
111
151
  const DESTRUCTIVE = { destructiveHint: true };
112
- const tagFilterShape = {
152
+ /**
153
+ * The three tag-filter inputs shared by every tag-accepting tool. `tag` is an
154
+ * ARRAY — repeat a tag to AND several together (both keep hierarchy-descendant
155
+ * expansion, which `exact_tag` disables). In FLAT views (read_view, search, and
156
+ * the projects list of list_collections) `tag` honors container inheritance; in
157
+ * the SINGLE-CONTAINER tools (get_project, get_area) it matches a tag carried
158
+ * DIRECTLY on the item — that container's own inherited tags are ignored (every
159
+ * child inherits them, so an inheritance-inclusive match would be vacuous).
160
+ */
161
+ const tagOnlyShape = {
113
162
  tag: z
114
- .string()
163
+ .array(z.string())
115
164
  .optional()
116
- .describe(`Filter by tag (${REF_FORMAT}); includes items carrying any nested child tag`),
117
- exact_tag: z.boolean().optional().describe("Match only the named tag, not its nested children"),
165
+ .describe(`Filter by tag (${REF_FORMAT}); repeat to AND several tags. Matches a direct, ` +
166
+ "container-inherited, or descendant tag (in a container tool the container's own " +
167
+ "inherited tags are ignored — the tag must be on the item itself)"),
168
+ exact_tag: z
169
+ .boolean()
170
+ .optional()
171
+ .describe("Match only the named tag(s), not their nested children"),
172
+ untagged: z
173
+ .boolean()
174
+ .optional()
175
+ .describe("Only items with no tag (direct or inherited); not combinable with tag/exact_tag " +
176
+ "(in a container tool: no tag on the item itself, ignoring inherited tags)"),
118
177
  };
178
+ const tagFilterShape = {
179
+ ...tagOnlyShape,
180
+ overdue: z
181
+ .boolean()
182
+ .optional()
183
+ .describe("Only open items past their deadline (due today is not overdue)"),
184
+ };
185
+ /**
186
+ * Map the MCP tool's snake_case tag inputs onto the shared {@link TagPresence}
187
+ * shape (canonical CLI-flag spelling) so the one set of contract predicates —
188
+ * {@link hasTagPresence}, {@link tagFlagConflict}, {@link tagFilterFields},
189
+ * {@link validateViewArgs} — serves both surfaces. The only difference is
190
+ * `exact_tag` → `exactTag`.
191
+ */
192
+ function tagPresence(args) {
193
+ return {
194
+ ...(args.tag !== undefined && { tag: args.tag }),
195
+ ...(args.exact_tag !== undefined && { exactTag: args.exact_tag }),
196
+ ...(args.untagged !== undefined && { untagged: args.untagged }),
197
+ };
198
+ }
199
+ /** The MCP-voiced usage copy for the tag-filter mutual-exclusivity conflict. */
200
+ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
119
201
  const dryRunShape = {
120
202
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
121
203
  };
204
+ /**
205
+ * The per-call opt-in for the tools that reach a change only by driving the
206
+ * local Things app's accessibility interface. Shared by `repeat`, the
207
+ * `convert_to_project` heading action, and the `areas` reorder scope.
208
+ */
209
+ const driveGuiShape = {
210
+ dangerously_drive_gui: z
211
+ .boolean()
212
+ .optional()
213
+ .describe("Required: this drives the local Things app through its accessibility interface to " +
214
+ "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
215
+ "on the machine running this server, and must be turned on first with `things config " +
216
+ "set ui-enabled true`. Intended for a dedicated always-on Mac."),
217
+ };
218
+ /** How a tag value may be expressed on any tag-accepting tool. */
219
+ const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
220
+ /** create_tags param, shared by every tag-accepting write tool. */
221
+ const createTagsShape = {
222
+ create_tags: z
223
+ .boolean()
224
+ .optional()
225
+ .describe("Create any named tag that does not exist yet (nesting parent/child) before applying, " +
226
+ "instead of stopping on an unknown tag"),
227
+ };
122
228
  const containerRef = (ref) => ({ uuid: ref, title: ref });
123
229
  /** Cap on project titles inlined into the server instructions. */
124
230
  const INSTRUCTIONS_MAX_PROJECTS = 100;
231
+ /** A tag's display label: nested tags show `parent > child`. */
232
+ const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`;
125
233
  /**
126
234
  * Live-inventory preamble: conventions plus the user's actual areas, tags,
127
235
  * and open projects, read once at server start so models can reference real
@@ -142,20 +250,31 @@ function buildInstructions(getClient) {
142
250
  "rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
143
251
  "add_area, add_project).",
144
252
  `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
145
- `are ${REMINDER_FORMAT}.`,
253
+ `are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
254
+ `below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
146
255
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
147
- "Operations with cascading or permanent effects require the explicit confirmation " +
148
- "parameter named in their description; refused calls return an error saying what to pass.",
256
+ "A preview creates no state, so later calls cannot reference an item that only appeared in " +
257
+ "a dry-run result. Operations with cascading or permanent effects require the explicit " +
258
+ "confirmation parameter named in their description; refused calls return an error saying " +
259
+ "what to pass.",
260
+ "- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
261
+ "tags inherited from its containing project and area. todaySection appears only for an item " +
262
+ "in Today, naming its section there (today or evening); an unscheduled start=active item is " +
263
+ "in Anytime and omits the field. Completing an item makes it findable in Logbook.",
264
+ "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
265
+ `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
149
266
  ];
150
267
  try {
151
268
  const c = getClient();
152
269
  const areas = c.read.areas();
153
270
  const tags = c.read.tags();
154
271
  const projects = c.read.projects();
155
- const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
272
+ const clock = c.clockMeta();
156
273
  const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
157
274
  const overflow = projects.length - shown.length;
158
- lines.push("", "Current inventory (read at server start — refresh with list_collections):", `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
275
+ lines.push("", "Current inventory (read at server start — refresh with list_collections):", ...(clock !== undefined
276
+ ? [`- Calendar context at server start: ${JSON.stringify(clock)}`]
277
+ : []), `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
159
278
  (overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
160
279
  }
161
280
  catch {
@@ -169,6 +288,24 @@ function buildInstructions(getClient) {
169
288
  function checklistTarget(args) {
170
289
  return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
171
290
  }
291
+ /**
292
+ * Derive the audit author for a client's writes from the MCP initialize
293
+ * handshake's clientInfo.name: lowercased, every run of non-alphanumerics
294
+ * collapsed to a single "-", leading/trailing dashes trimmed, capped at 32
295
+ * characters. An absent (or empty-after-sanitizing) client name falls back to
296
+ * the bare "mcp". The result is never caller-settable — it is the connecting
297
+ * client's own identity, so each client's writes are attributed to it.
298
+ */
299
+ const MCP_ACTOR_PREFIX = "mcp";
300
+ function deriveMcpActor(clientName) {
301
+ const slug = (clientName ?? "")
302
+ .toLowerCase()
303
+ .replaceAll(/[^a-z0-9]+/g, "-")
304
+ .replaceAll(/^-+|-+$/g, "")
305
+ .slice(0, 32)
306
+ .replaceAll(/-+$/g, "");
307
+ return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
308
+ }
172
309
  export function createThingsMcpServer(options = {}) {
173
310
  // One lazily-opened client for the server's lifetime; SQLite read
174
311
  // snapshots are per-statement, so fresh reads see external commits.
@@ -181,26 +318,81 @@ export function createThingsMcpServer(options = {}) {
181
318
  return client;
182
319
  };
183
320
  const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
321
+ // The audit author for every write on this connection, derived once from the
322
+ // client's handshake identity (clientInfo.name). Read per call: clientInfo is
323
+ // populated when the initialize handshake completes, before any tool can run.
324
+ // Not caller-settable — no tool argument overrides it.
325
+ const mcpActor = () => deriveMcpActor(server.server.getClientVersion()?.name);
326
+ /** Translate the shared MCP write-tool args into pipeline WriteOptions. */
327
+ const writeOptions = (args) => ({
328
+ actor: mcpActor(),
329
+ ...(options.maxDisruption !== undefined && { maxDisruption: options.maxDisruption }),
330
+ ...(args.dry_run === true && { dryRun: true }),
331
+ ...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
332
+ ...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
333
+ ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
334
+ ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
335
+ ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
336
+ ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
337
+ ...(args.create_tags === true && { createTags: true }),
338
+ ...(args.tz !== undefined && { zone: args.tz }),
339
+ });
184
340
  /** Run a handler, mapping environment/usage throws to tool errors. */
185
341
  const guard = async (fn) => {
186
342
  try {
187
343
  return await fn();
188
344
  }
189
345
  catch (err) {
346
+ // An unresolved reference (ambiguous or not-found uuid/partial-uuid/name)
347
+ // carries machine-readable candidates so a consumer can disambiguate as
348
+ // data, not by re-parsing the prose message.
349
+ if (err instanceof ReferenceResolutionError) {
350
+ return errorResult({
351
+ code: err.code,
352
+ message: err.message,
353
+ details: { candidates: err.candidates },
354
+ });
355
+ }
190
356
  const message = err instanceof Error ? err.message : String(err);
191
357
  const code = err instanceof RangeError ? "usage" : "environment";
192
358
  return errorResult({ code, message });
193
359
  }
194
360
  };
195
- const writeOptions = (args) => ({
196
- actor: "mcp",
197
- ...(args.dry_run === true && { dryRun: true }),
198
- ...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
199
- ...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
200
- ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
201
- ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
202
- ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
203
- });
361
+ /**
362
+ * A read handler wrapper: run through {@link guard}, then — on a successful
363
+ * result append a meta block carrying any non-blocking schema warning (the
364
+ * same note the CLI prints), so a consumer sees when the Things database no
365
+ * longer matches the validated schema and its data may be incomplete. No
366
+ * block is added when the schema checks out or the read itself errored.
367
+ */
368
+ const readGuard = async (fn, tz) => {
369
+ const result = await guard(fn);
370
+ if (result.isError === true)
371
+ return result;
372
+ let warnings = [];
373
+ let clock;
374
+ try {
375
+ const c = getClient();
376
+ warnings = schemaWarnings(c.schemaStatus());
377
+ // The clock honesty field for this call's effective zone (the per-call
378
+ // tz over the server default) — present only when a consumer zone /
379
+ // pinned now is in effect.
380
+ clock = c.clockMeta(tz);
381
+ }
382
+ catch {
383
+ warnings = [];
384
+ }
385
+ const meta = {
386
+ ...(warnings.length > 0 && { warnings }),
387
+ ...(clock !== undefined && { clock }),
388
+ };
389
+ if (Object.keys(meta).length === 0)
390
+ return result;
391
+ return {
392
+ ...result,
393
+ content: [...result.content, { type: "text", text: JSON.stringify({ meta }) }],
394
+ };
395
+ };
204
396
  /** Resolve a uuid to to-do/project for the type-generic item tools. */
205
397
  const itemType = (uuid) => {
206
398
  const item = getClient().read.byUuid(uuid);
@@ -216,7 +408,7 @@ export function createThingsMcpServer(options = {}) {
216
408
  description: "Read a Things list as the app presents it: today (split into Today and This " +
217
409
  "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
218
410
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
219
- "anytime/someday return sidebar-ordered sections (area + items; null area = the " +
411
+ "anytime/someday return sections in canonical order (area + items; null area = the " +
220
412
  "top-level block); children of someday/future-scheduled projects are excluded " +
221
413
  "from anytime — the project row represents them; someday lists each group's " +
222
414
  "project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
@@ -224,10 +416,13 @@ export function createThingsMcpServer(options = {}) {
224
416
  "anytime/someday always return every group and cap per block instead — " +
225
417
  `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
226
418
  `project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
227
- "all: true lifts every cap; the result's second block reports the counts.",
419
+ "all: true lifts every cap; the result's second block reports the counts. " +
420
+ OMIT_EMPTY_NOTE,
228
421
  inputSchema: {
229
422
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
230
423
  ...tagFilterShape,
424
+ ...tzShape,
425
+ evening: z.boolean().optional().describe("today only: show only the This Evening section"),
231
426
  show_active_project_items: z
232
427
  .union([z.boolean(), z.number().int().min(1)])
233
428
  .optional()
@@ -249,7 +444,7 @@ export function createThingsMcpServer(options = {}) {
249
444
  .int()
250
445
  .min(1)
251
446
  .optional()
252
- .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
447
+ .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
253
448
  area_limit: z
254
449
  .number()
255
450
  .int()
@@ -265,10 +460,24 @@ export function createThingsMcpServer(options = {}) {
265
460
  all: z
266
461
  .boolean()
267
462
  .optional()
268
- .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
463
+ .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
464
+ ALL_WINS_NOTE),
269
465
  },
270
466
  annotations: READ_ONLY,
271
- }, async (args) => guard(() => {
467
+ }, async (args) => readGuard(() => {
468
+ const badZone = badTz(args.tz);
469
+ if (badZone !== null)
470
+ return badZone;
471
+ // Tag-conflict AND overdue-applicability both derive from the shared
472
+ // contract: read_view honors overdue only on today/inbox/anytime/someday
473
+ // (the current-work views), matching FILTER_CONTRACT.
474
+ const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
475
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
476
+ overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
477
+ overdueStatusWiden: "",
478
+ });
479
+ if (!validated.ok)
480
+ return usage(validated.message);
272
481
  // show_active_project_items is the preferred name; active_project_items
273
482
  // stays accepted as a compatibility alias.
274
483
  const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
@@ -283,79 +492,90 @@ export function createThingsMcpServer(options = {}) {
283
492
  if (args.view !== "someday" && showActiveProjectItems !== undefined) {
284
493
  return usage("show_active_project_items applies only to someday");
285
494
  }
495
+ if (args.view !== "today" && args.evening === true) {
496
+ return usage(`evening applies only to today, not ${args.view}`);
497
+ }
286
498
  if (args.view === "someday" && args.project_limit !== undefined) {
287
499
  return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
288
500
  "to cap that section's project lists");
289
501
  }
290
502
  const limit = resolveLimit(args);
291
- if (limit === "conflict")
292
- return usage("pass at most one of limit / all");
293
503
  const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
294
504
  const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
295
505
  if (areaLimit === "conflict" || projectLimit === "conflict") {
296
506
  return usage("pass at most one of area_limit/project_limit / all");
297
507
  }
298
508
  const c = getClient();
299
- const filter = {
300
- ...(args.tag !== undefined && { tag: args.tag }),
301
- ...(args.exact_tag === true && { exactTag: true }),
302
- };
509
+ const filter = validated.filter;
510
+ const zone = args.tz !== undefined ? { zone: args.tz } : {};
303
511
  switch (args.view) {
304
512
  case "today": {
305
- const { data, pagination } = paginateToday(c.read.today(filter), limit);
306
- return paginatedResult(data, pagination);
513
+ const { view, truncation } = c.read.today({
514
+ ...filter,
515
+ ...zone,
516
+ ...(args.evening === true && { eveningOnly: true }),
517
+ limit,
518
+ });
519
+ return truncatedResult(view, truncation);
307
520
  }
308
521
  case "inbox": {
309
- const { data, pagination } = paginateList(c.read.inbox(filter), limit);
310
- return paginatedResult(data, pagination);
522
+ const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
523
+ return truncatedResult(items, truncation);
311
524
  }
312
525
  case "anytime": {
313
- const limits = { area: areaLimit, project: projectLimit };
314
- const { data, grouped } = previewSections(c.read.anytime(filter), limits);
315
- return groupedResult(data, grouped);
526
+ const { view, grouped } = c.read.anytime({
527
+ ...filter,
528
+ ...zone,
529
+ areaLimit,
530
+ projectLimit,
531
+ });
532
+ return groupedResult(view, grouped);
316
533
  }
317
534
  case "upcoming": {
318
- const { data, pagination } = paginateList(c.read.upcoming({
535
+ const { items, truncation } = c.read.upcoming({
319
536
  ...filter,
537
+ ...zone,
320
538
  ...(args.horizon !== undefined && { horizon: args.horizon }),
321
- }), limit);
322
- return paginatedResult(data, pagination);
539
+ limit,
540
+ });
541
+ return truncatedResult(items, truncation);
323
542
  }
324
543
  case "someday": {
325
544
  const active = showActiveProjectItems;
326
545
  if (typeof active === "number" && args.all === true) {
327
546
  return usage("pass at most one of a numeric show_active_project_items / all");
328
547
  }
329
- const limits = {
330
- area: areaLimit,
331
- // true = every item per project; a number caps each list.
332
- project: typeof active === "number" ? active : null,
333
- };
334
- const { data, grouped } = previewSomedaySections(c.read.someday({
548
+ const { view, grouped } = c.read.someday({
335
549
  ...filter,
550
+ ...zone,
336
551
  ...((active === true || typeof active === "number") && {
337
552
  activeProjectItems: true,
338
553
  }),
339
- }), limits);
340
- return groupedResult(data, grouped);
554
+ areaLimit,
555
+ // true = every item per project; a number caps each list.
556
+ projectLimit: typeof active === "number" ? active : null,
557
+ });
558
+ return groupedResult(view, grouped);
341
559
  }
342
560
  case "logbook": {
343
- const { data, pagination } = paginateList(c.read.logbook({ ...filter, limit: null }), limit);
344
- return paginatedResult(data, pagination);
561
+ const { items, truncation } = c.read.logbook({ ...filter, ...zone, limit });
562
+ return truncatedResult(items, truncation);
345
563
  }
346
564
  case "trash": {
347
- const { data, pagination } = paginateList(c.read.trash({ limit: null }), limit);
348
- return paginatedResult(data, pagination);
565
+ const { items, truncation } = c.read.trash({ ...zone, limit });
566
+ return truncatedResult(items, truncation);
349
567
  }
350
568
  }
351
- }));
569
+ }, args.tz));
352
570
  server.registerTool("search", {
353
571
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
354
572
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
355
- "references must name existing items.",
573
+ "references must name existing items. " +
574
+ OMIT_EMPTY_NOTE,
356
575
  inputSchema: {
357
576
  query: z.string(),
358
577
  ...tagFilterShape,
578
+ ...tzShape,
359
579
  project: z
360
580
  .string()
361
581
  .optional()
@@ -370,72 +590,121 @@ export function createThingsMcpServer(options = {}) {
370
590
  all: z
371
591
  .boolean()
372
592
  .optional()
373
- .describe("Everything, unbounded: open + logged + trashed, no row limit"),
374
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
593
+ .describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
594
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
375
595
  },
376
596
  annotations: READ_ONLY,
377
- }, async (args) => guard(() => {
597
+ }, async (args) => readGuard(() => {
598
+ const badZone = badTz(args.tz);
599
+ if (badZone !== null)
600
+ return badZone;
601
+ // Tag-conflict AND the overdue/status-widening incompatibility both
602
+ // derive from the shared contract (search: statusWidening = true).
603
+ const validated = validateViewArgs("search", {
604
+ ...tagPresence(args),
605
+ overdue: args.overdue,
606
+ logged: args.logged,
607
+ trashed: args.trashed,
608
+ all: args.all,
609
+ }, {
610
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
611
+ overdueRejected: "overdue does not apply to search",
612
+ overdueStatusWiden: "overdue lists open items; it does not combine with logged/trashed/all",
613
+ });
614
+ if (!validated.ok)
615
+ return usage(validated.message);
378
616
  const limit = resolveLimit(args);
379
- if (limit === "conflict")
380
- return usage("pass at most one of limit / all");
381
- const { data, pagination } = paginateList(getClient().read.search(args.query, {
382
- limit: null,
383
- ...(args.tag !== undefined && { tag: args.tag }),
384
- ...(args.exact_tag === true && { exactTag: true }),
617
+ const { items, truncation } = getClient().read.search(args.query, {
618
+ limit,
619
+ ...validated.filter,
620
+ ...(args.tz !== undefined && { zone: args.tz }),
385
621
  ...(args.project !== undefined && { project: args.project }),
386
622
  ...(args.area !== undefined && { area: args.area }),
387
623
  ...(args.type !== undefined && { type: args.type }),
388
624
  ...(args.logged === true && { logged: true }),
389
625
  ...(args.trashed === true && { trashed: true }),
390
626
  ...(args.all === true && { all: true }),
391
- }), limit);
392
- return paginatedResult(data, pagination);
393
- }));
627
+ });
628
+ return truncatedResult(items, truncation);
629
+ }, args.tz));
394
630
  server.registerTool("changes_since", {
395
631
  description: "List items created or modified since a moment — including trashed, logged, and " +
396
632
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
397
- "areas, and checklist items do not mark the containing item as modified.",
633
+ "areas, and checklist items do not mark the containing item as modified. " +
634
+ OMIT_EMPTY_NOTE,
398
635
  inputSchema: {
399
636
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
400
637
  ...limitShape,
638
+ ...tzShape,
401
639
  },
402
640
  annotations: READ_ONLY,
403
- }, async (args) => guard(() => {
641
+ }, async (args) => readGuard(() => {
642
+ const badZone = badTz(args.tz);
643
+ if (badZone !== null)
644
+ return badZone;
404
645
  const limit = resolveLimit(args);
405
- if (limit === "conflict")
406
- return usage("pass at most one of limit / all");
407
646
  const since = new Date(args.since);
408
647
  if (Number.isNaN(since.getTime())) {
409
648
  return usage(`since is not a parseable date: ${args.since}`);
410
649
  }
411
- const { data, pagination } = paginateList(getClient().read.changes({ since, limit: null }), limit);
412
- return paginatedResult(data, pagination);
413
- }));
650
+ const { items, truncation } = getClient().read.changes({
651
+ since,
652
+ limit,
653
+ ...(args.tz !== undefined && { zone: args.tz }),
654
+ });
655
+ return truncatedResult(items, truncation);
656
+ }, args.tz));
414
657
  server.registerTool("get_item", {
415
658
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
416
659
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
417
- "project/area/heading.",
660
+ "project/area/heading. " +
661
+ OMIT_EMPTY_NOTE,
418
662
  inputSchema: { uuid: z.string() },
419
663
  annotations: READ_ONLY,
420
- }, async (args) => guard(() => {
664
+ }, async (args) => readGuard(() => {
421
665
  const item = getClient().read.byUuid(args.uuid);
422
666
  return item === null
423
- ? errorResult({ code: "not-found", message: `no record with uuid ${args.uuid}` })
424
- : jsonResult(item);
667
+ ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
668
+ : readResult(item);
425
669
  }));
426
670
  server.registerTool("get_project", {
427
- description: "One project's full contents: metadata plus its to-dos grouped under their headings.",
428
- inputSchema: { uuid: z.string().describe("Project uuid or unique name") },
671
+ description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
672
+ "The tag filters keep only the child to-dos matching by their own tags (a heading left " +
673
+ "with none is dropped). " +
674
+ OMIT_EMPTY_NOTE,
675
+ inputSchema: {
676
+ uuid: z.string().describe("Project uuid or unique name"),
677
+ ...tagOnlyShape,
678
+ ...tzShape,
679
+ overdue: z
680
+ .boolean()
681
+ .optional()
682
+ .describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
683
+ },
429
684
  annotations: READ_ONLY,
430
- }, async (args) => guard(() => jsonResult(getClient().read.projectView(args.uuid))));
685
+ }, async (args) => readGuard(() => {
686
+ const badZone = badTz(args.tz);
687
+ if (badZone !== null)
688
+ return badZone;
689
+ if (tagFlagConflict(tagPresence(args)))
690
+ return usage(MCP_UNTAGGED_CONFLICT);
691
+ return readResult(getClient().read.projectView(args.uuid, {
692
+ overdue: args.overdue === true,
693
+ ...tagFilterFields(tagPresence(args)),
694
+ ...(args.tz !== undefined && { zone: args.tz }),
695
+ }));
696
+ }, args.tz));
431
697
  server.registerTool("get_area", {
432
698
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
433
- "projects in sidebar order, later (scheduled/repeating/someday), and logged items. " +
699
+ "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
434
700
  `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
435
701
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
436
- "second result block reports the counts.",
702
+ "second result block reports the counts. " +
703
+ OMIT_EMPTY_NOTE,
437
704
  inputSchema: {
438
705
  ref: z.string().describe("Area uuid or unique name"),
706
+ ...tagOnlyShape,
707
+ ...tzShape,
439
708
  area_limit: z
440
709
  .number()
441
710
  .int()
@@ -448,38 +717,89 @@ export function createThingsMcpServer(options = {}) {
448
717
  .min(1)
449
718
  .optional()
450
719
  .describe(`maximum project rows to return (default ${AREA_PREVIEW_LIMIT})`),
720
+ overdue: z
721
+ .boolean()
722
+ .optional()
723
+ .describe("Keep only rows (loose to-dos AND child projects) whose own deadline is past (due today is not overdue); no descent into project contents"),
451
724
  all: z.boolean().optional().describe("return both sections in full (no caps)"),
452
725
  },
453
726
  annotations: READ_ONLY,
454
- }, async (args) => guard(() => {
727
+ }, async (args) => readGuard(() => {
728
+ const badZone = badTz(args.tz);
729
+ if (badZone !== null)
730
+ return badZone;
731
+ if (tagFlagConflict(tagPresence(args)))
732
+ return usage(MCP_UNTAGGED_CONFLICT);
455
733
  const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
456
734
  const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
457
735
  if (areaLimit === "conflict" || projectLimit === "conflict") {
458
736
  return usage("pass at most one of area_limit/project_limit / all");
459
737
  }
460
- const limits = { area: areaLimit, project: projectLimit };
461
- const { data, grouped } = capAreaSections(getClient().read.areaView(args.ref), limits);
462
- return groupedResult(data, grouped);
463
- }));
738
+ const { view, grouped } = getClient().read.areaView(args.ref, {
739
+ overdue: args.overdue === true,
740
+ ...tagFilterFields(tagPresence(args)),
741
+ ...(args.tz !== undefined && { zone: args.tz }),
742
+ areaLimit,
743
+ projectLimit,
744
+ });
745
+ return groupedResult(view, grouped);
746
+ }, args.tz));
464
747
  server.registerTool("list_collections", {
465
748
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
466
- "refresh the inventory summarized in the server instructions.",
467
- inputSchema: { kind: z.enum(["projects", "areas", "tags"]) },
749
+ "refresh the inventory summarized in the server instructions. The tag filters scope " +
750
+ "the projects list by each project's own tags (areas/tags reject them). " +
751
+ OMIT_EMPTY_NOTE,
752
+ inputSchema: {
753
+ kind: z.enum(["projects", "areas", "tags"]),
754
+ ...tagOnlyShape,
755
+ ...tzShape,
756
+ overdue: z
757
+ .boolean()
758
+ .optional()
759
+ .describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
760
+ },
468
761
  annotations: READ_ONLY,
469
- }, async (args) => guard(() => {
762
+ }, async (args) => readGuard(() => {
763
+ const badZone = badTz(args.tz);
764
+ if (badZone !== null)
765
+ return badZone;
470
766
  const c = getClient();
471
- return jsonResult(args.kind === "projects"
472
- ? c.read.projects()
767
+ // areas/tags are not dated entities and have no per-row tag list to
768
+ // filter — overdue and the tag filters are vacuous there, rejected
769
+ // fail-closed (the same style read_view uses for the wrong views). The
770
+ // decision derives from the contract: only the `projects` list carries
771
+ // a deadline (overdue) and per-row (inheritance-inclusive) tags; the
772
+ // `areas` list rejects both, and `tags` has no contract row.
773
+ const kindSpec = args.kind === "projects"
774
+ ? FILTER_CONTRACT.projects
775
+ : args.kind === "areas"
776
+ ? FILTER_CONTRACT.areas
777
+ : null;
778
+ if (args.overdue === true && (kindSpec === null || !kindSpec.overdue)) {
779
+ return usage(`overdue applies only to projects, not ${args.kind}`);
780
+ }
781
+ if ((kindSpec === null || kindSpec.tag === "rejected") &&
782
+ (hasTagPresence(tagPresence(args)) || args.untagged === true)) {
783
+ return usage(`the tag filters apply only to projects, not ${args.kind}`);
784
+ }
785
+ if (tagFlagConflict(tagPresence(args)))
786
+ return usage(MCP_UNTAGGED_CONFLICT);
787
+ return readResult(args.kind === "projects"
788
+ ? c.read.projects({
789
+ overdue: args.overdue === true,
790
+ ...tagFilterFields(tagPresence(args)),
791
+ ...(args.tz !== undefined && { zone: args.tz }),
792
+ })
473
793
  : args.kind === "areas"
474
794
  ? c.read.areas()
475
795
  : c.read.tags());
476
- }));
796
+ }, args.tz));
477
797
  // ---------------------------------------------------------------- to-dos
478
798
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
479
799
  server.registerTool("add_todo", {
480
800
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
481
801
  "deadline, tag it, give it a checklist, and place it in a project or area " +
482
- "(optionally under an existing heading). Tags must name existing tags. A reminder " +
802
+ "(optionally under a heading within that project). A reminder " +
483
803
  "requires when = today, evening, or a date. Adding into a completed or canceled " +
484
804
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
485
805
  inputSchema: {
@@ -488,7 +808,7 @@ export function createThingsMcpServer(options = {}) {
488
808
  when: whenSchema,
489
809
  reminder: z.string().optional().describe(REMINDER_FORMAT),
490
810
  deadline: z.string().optional().describe(DATE_FORMAT),
491
- tags: z.array(z.string()).optional().describe("Existing tag names"),
811
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
492
812
  checklist_items: z.array(z.string()).optional(),
493
813
  project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
494
814
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
@@ -497,86 +817,216 @@ export function createThingsMcpServer(options = {}) {
497
817
  .boolean()
498
818
  .optional()
499
819
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
820
+ ...createTagsShape,
821
+ ...tzShape,
500
822
  ...dryRunShape,
501
823
  },
502
824
  annotations: NON_DESTRUCTIVE,
503
- }, async (args) => guard(async () => mutationResult(await getClient().write.addTodo({
504
- title: args.title,
505
- ...(args.notes !== undefined && { notes: args.notes }),
506
- ...(args.when !== undefined && { when: args.when }),
507
- ...(args.reminder !== undefined && { reminder: args.reminder }),
508
- ...(args.deadline !== undefined && { deadline: args.deadline }),
509
- ...(args.tags !== undefined && { tags: args.tags }),
510
- ...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
511
- ...(args.project !== undefined && { project: containerRef(args.project) }),
512
- ...(args.area !== undefined && { area: containerRef(args.area) }),
513
- ...(args.heading !== undefined && { heading: args.heading }),
514
- }, writeOptions(args)))));
515
- server.registerTool("update_todo", {
516
- description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
517
- "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
518
- "notes). Changing the schedule keeps an existing reminder unless the call sets a " +
519
- "new one. clear_reminder works while the to-do is scheduled for today or this " +
520
- "evening; a reminder on a future date can only be changed, not cleared " +
521
- "(re-schedule to today first). Schedule and deadline changes are not available " +
522
- "for repeating to-dos.",
825
+ }, async (args) => guard(async () => {
826
+ const badZone = badTz(args.tz);
827
+ if (badZone !== null)
828
+ return badZone;
829
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
830
+ if (sugar.kind === "error")
831
+ return usage(sugar.message);
832
+ const when = sugar.kind === "split" ? sugar.when : args.when;
833
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
834
+ return mutationResult(await getClient().write.addTodo({
835
+ title: args.title,
836
+ ...(args.notes !== undefined && { notes: args.notes }),
837
+ ...(when !== undefined && { when: when }),
838
+ ...(reminder !== undefined && { reminder }),
839
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
840
+ ...(args.tags !== undefined && { tags: args.tags }),
841
+ ...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
842
+ ...(args.project !== undefined && { project: containerRef(args.project) }),
843
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
844
+ ...(args.heading !== undefined && { heading: args.heading }),
845
+ }, writeOptions(args)));
846
+ }));
847
+ server.registerTool("update", {
848
+ description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
849
+ "kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
850
+ "existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
851
+ "deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
852
+ "one is set, schedule and deadline changes are unavailable for repeating items, and " +
853
+ "clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
854
+ "future date can only be changed, not cleared). kind area: title and/or tags (the full " +
855
+ "replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
856
+ "exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
857
+ "create_tags is set.",
523
858
  inputSchema: {
524
- uuid: z.string(),
525
- title: z.string().optional(),
526
- notes: z.string().optional().describe("Replaces the whole notes body"),
527
- append_notes: z.string().optional(),
528
- prepend_notes: z.string().optional(),
859
+ kind: z.enum(["todo", "project", "area", "tag"]),
860
+ uuid: z
861
+ .string()
862
+ .describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
863
+ "unique name"),
864
+ title: z.string().optional().describe("New title (any kind)"),
865
+ notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
866
+ append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
867
+ prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
529
868
  when: whenSchema,
530
- reminder: z.string().optional().describe(REMINDER_FORMAT),
531
- clear_reminder: z.boolean().optional(),
532
- deadline: z.string().optional().describe(DATE_FORMAT),
533
- clear_deadline: z.boolean().optional(),
869
+ reminder: z.string().optional().describe(`todo/project: ${REMINDER_FORMAT}`),
870
+ clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
871
+ deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
872
+ clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
873
+ tags: z
874
+ .array(z.string())
875
+ .optional()
876
+ .describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
877
+ parent: z.string().optional().describe("tag: existing tag to nest under"),
878
+ unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
879
+ shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
880
+ clear_shortcut: z.boolean().optional().describe("tag: remove the keyboard shortcut"),
881
+ ...createTagsShape,
882
+ ...tzShape,
534
883
  ...dryRunShape,
535
884
  },
536
885
  annotations: NON_DESTRUCTIVE,
537
886
  }, async (args) => guard(async () => {
538
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
539
- if (notesModes.length > 1) {
540
- return usage("notes, append_notes, prepend_notes are exclusive");
887
+ const badZone = badTz(args.tz);
888
+ if (badZone !== null)
889
+ return badZone;
890
+ const opts = writeOptions(args);
891
+ const c = getClient();
892
+ if (args.kind === "todo" || args.kind === "project") {
893
+ const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
894
+ if (notesModes.length > 1) {
895
+ return usage("notes, append_notes, prepend_notes are exclusive");
896
+ }
897
+ if (args.reminder !== undefined && args.clear_reminder === true) {
898
+ return usage("pass at most one of reminder / clear_reminder");
899
+ }
900
+ if (args.deadline !== undefined && args.clear_deadline === true) {
901
+ return usage("pass at most one of deadline / clear_deadline");
902
+ }
903
+ if (args.kind === "todo") {
904
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
905
+ if (sugar.kind === "error")
906
+ return usage(sugar.message);
907
+ const when = sugar.kind === "split" ? sugar.when : args.when;
908
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
909
+ return mutationResult(await c.write.updateTodo(args.uuid, {
910
+ ...(args.title !== undefined && { title: args.title }),
911
+ ...(args.notes !== undefined && { notes: args.notes }),
912
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
913
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
914
+ ...(when !== undefined && { when: when }),
915
+ ...(reminder !== undefined && { reminder }),
916
+ ...(args.clear_reminder === true && { reminder: null }),
917
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
918
+ ...(args.clear_deadline === true && { deadline: null }),
919
+ }, opts));
920
+ }
921
+ return mutationResult(await c.write.updateProject(args.uuid, {
922
+ ...(args.title !== undefined && { title: args.title }),
923
+ ...(args.notes !== undefined && { notes: args.notes }),
924
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
925
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
926
+ ...(args.when !== undefined && { when: args.when }),
927
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
928
+ ...(args.clear_reminder === true && { reminder: null }),
929
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
930
+ ...(args.clear_deadline === true && { deadline: null }),
931
+ }, opts));
541
932
  }
542
- if (args.reminder !== undefined && args.clear_reminder === true) {
543
- return usage("pass at most one of reminder / clear_reminder");
933
+ if (args.kind === "area") {
934
+ if (args.title === undefined && args.tags === undefined) {
935
+ return usage("kind area requires title and/or tags");
936
+ }
937
+ return mutationResult(await c.write.updateArea(args.uuid, {
938
+ ...(args.title !== undefined && { title: args.title }),
939
+ ...(args.tags !== undefined && { tags: args.tags }),
940
+ }, opts));
941
+ }
942
+ // kind tag
943
+ if (args.title === undefined &&
944
+ args.parent === undefined &&
945
+ args.unnest === undefined &&
946
+ args.shortcut === undefined &&
947
+ args.clear_shortcut === undefined) {
948
+ return usage("kind tag requires title, parent, unnest, shortcut, and/or clear_shortcut");
544
949
  }
545
- if (args.deadline !== undefined && args.clear_deadline === true) {
546
- return usage("pass at most one of deadline / clear_deadline");
950
+ if (args.parent !== undefined && args.unnest === true) {
951
+ return usage("parent and unnest are exclusive");
547
952
  }
548
- return mutationResult(await getClient().write.updateTodo(args.uuid, {
953
+ if (args.shortcut !== undefined && args.clear_shortcut === true) {
954
+ return usage("shortcut and clear_shortcut are exclusive");
955
+ }
956
+ return mutationResult(await c.write.updateTag(args.uuid, {
549
957
  ...(args.title !== undefined && { title: args.title }),
550
- ...(args.notes !== undefined && { notes: args.notes }),
551
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
552
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
553
- ...(args.when !== undefined && { when: args.when }),
554
- ...(args.reminder !== undefined && { reminder: args.reminder }),
555
- ...(args.clear_reminder === true && { reminder: null }),
556
- ...(args.deadline !== undefined && { deadline: args.deadline }),
557
- ...(args.clear_deadline === true && { deadline: null }),
558
- }, writeOptions(args)));
958
+ ...(args.parent !== undefined && { parent: args.parent }),
959
+ ...(args.unnest === true && { unnest: true }),
960
+ ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
961
+ ...(args.clear_shortcut === true && { clearShortcut: true }),
962
+ }, opts));
559
963
  }));
560
- server.registerTool("set_todo_status", {
561
- description: "Set a to-do's status: completed, canceled, or open (reopening a " +
562
- "completed/canceled to-do). Not available for repeating to-dos.",
964
+ server.registerTool("set_status", {
965
+ description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
966
+ "(reopening a completed/canceled item). Not available for repeating to-dos. " +
967
+ "scope project, completing or canceling requires a children policy: 'require-resolved' " +
968
+ "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
969
+ "the project (canceling never alters already-completed children). scope project, status " +
970
+ "open, restore_children also reopens the to-dos that were resolved with the project.",
563
971
  inputSchema: {
564
- uuid: z.string(),
972
+ scope: z.enum(["todo", "project"]),
973
+ uuid: z
974
+ .string()
975
+ .describe("The item — a to-do by uuid; a project also accepts a unique name"),
565
976
  status: z.enum(["completed", "canceled", "open"]),
977
+ children: z
978
+ .enum(["require-resolved", "auto-complete", "auto-cancel"])
979
+ .optional()
980
+ .describe("scope project, completed/canceled: what to do with the project's open to-dos"),
981
+ restore_children: z
982
+ .boolean()
983
+ .optional()
984
+ .describe("scope project, open only: also reopen the to-dos resolved with the project"),
566
985
  ...dryRunShape,
567
986
  },
568
987
  annotations: NON_DESTRUCTIVE,
569
988
  }, async (args) => guard(async () => {
570
989
  const c = getClient();
571
990
  const opts = writeOptions(args);
572
- return mutationResult(args.status === "completed"
573
- ? await c.write.completeTodo(args.uuid, opts)
574
- : args.status === "canceled"
575
- ? await c.write.cancelTodo(args.uuid, opts)
576
- : await c.write.reopenTodo(args.uuid, opts));
991
+ if (args.scope === "todo") {
992
+ if (args.children !== undefined || args.restore_children !== undefined) {
993
+ return usage("children/restore_children apply only to scope project");
994
+ }
995
+ return mutationResult(args.status === "completed"
996
+ ? await c.write.completeTodo(args.uuid, opts)
997
+ : args.status === "canceled"
998
+ ? await c.write.cancelTodo(args.uuid, opts)
999
+ : await c.write.reopenTodo(args.uuid, opts));
1000
+ }
1001
+ // scope project
1002
+ if (args.status !== "open" && args.restore_children !== undefined) {
1003
+ return usage("restore_children applies only to status 'open'");
1004
+ }
1005
+ if (args.status === "completed") {
1006
+ if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1007
+ return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1008
+ }
1009
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
1010
+ }
1011
+ if (args.status === "canceled") {
1012
+ if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1013
+ return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1014
+ }
1015
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
1016
+ }
1017
+ if (args.children !== undefined) {
1018
+ return usage("children applies only to status 'completed' or 'canceled'");
1019
+ }
1020
+ const outcome = await c.write.reopenProject(args.uuid, {
1021
+ ...opts,
1022
+ ...(args.restore_children === true && { restoreChildren: true }),
1023
+ });
1024
+ return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
1025
+ ? jsonResult(outcome)
1026
+ : mutationResult(outcome.project);
577
1027
  }));
578
1028
  server.registerTool("move_todo", {
579
- description: "Move a to-do. Pass exactly one destination: a project and/or area (optionally an " +
1029
+ description: "Move a to-do. Pass exactly one destination: a project or area (optionally an " +
580
1030
  "existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
581
1031
  "removes any schedule; detach removes the project/area/heading assignment while " +
582
1032
  "keeping the schedule. Moving into a completed or canceled project reopens that " +
@@ -618,11 +1068,12 @@ export function createThingsMcpServer(options = {}) {
618
1068
  server.registerTool("set_tags", {
619
1069
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
620
1070
  "the given list — an empty list removes all tags; mode 'add' merges with the current " +
621
- "tags. Tags must name existing tags (create them first with add_tag).",
1071
+ "tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
622
1072
  inputSchema: {
623
1073
  uuid: z.string(),
624
- tags: z.array(z.string()).describe("Existing tag names"),
1074
+ tags: z.array(z.string()).describe(`Tags ${TAG_REF_FORMAT}`),
625
1075
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
1076
+ ...createTagsShape,
626
1077
  ...dryRunShape,
627
1078
  },
628
1079
  annotations: NON_DESTRUCTIVE,
@@ -728,17 +1179,44 @@ export function createThingsMcpServer(options = {}) {
728
1179
  return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
729
1180
  }));
730
1181
  // ------------------------------------------------- to-dos AND projects
731
- server.registerTool("delete_item", {
732
- description: "Move a to-do or project to the Trash (recoverable via restore_item until the " +
733
- "Trash is emptied). Deleting a project sends its to-dos to the Trash with it. Not " +
734
- "available for repeating to-dos.",
735
- inputSchema: { uuid: z.string(), ...dryRunShape },
1182
+ server.registerTool("delete", {
1183
+ description: "Delete a to-do/project (kind item), an area, or a tag kind selects which. " +
1184
+ "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1185
+ "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1186
+ "repeating to-dos). kind area: PERMANENT — areas do not go to the Trash, so this cannot " +
1187
+ "be undone and requires dangerously_permanent; the area's to-dos move to the Trash and " +
1188
+ "its projects remain, no longer assigned to any area. kind tag: PERMANENT — requires " +
1189
+ "dangerously_permanent; the tag is removed from every item, and if it has nested child " +
1190
+ "tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
1191
+ inputSchema: {
1192
+ kind: z.enum(["item", "area", "tag"]),
1193
+ uuid: z
1194
+ .string()
1195
+ .describe("The target — an item by uuid; an area or tag also accepts a unique name"),
1196
+ dangerously_permanent: z
1197
+ .boolean()
1198
+ .optional()
1199
+ .describe("kind area/tag: confirm permanent, unrecoverable deletion"),
1200
+ acknowledge_tag_subtree: z
1201
+ .boolean()
1202
+ .optional()
1203
+ .describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
1204
+ ...dryRunShape,
1205
+ },
736
1206
  annotations: DESTRUCTIVE,
737
1207
  }, async (args) => guard(async () => {
738
1208
  const c = getClient();
739
- return mutationResult(itemType(args.uuid) === "to-do"
740
- ? await c.write.deleteTodo(args.uuid, writeOptions(args))
741
- : await c.write.deleteProject(args.uuid, writeOptions(args)));
1209
+ const opts = writeOptions(args);
1210
+ switch (args.kind) {
1211
+ case "item":
1212
+ return mutationResult(itemType(args.uuid) === "to-do"
1213
+ ? await c.write.deleteTodo(args.uuid, opts)
1214
+ : await c.write.deleteProject(args.uuid, opts));
1215
+ case "area":
1216
+ return mutationResult(await c.write.deleteArea(args.uuid, opts));
1217
+ case "tag":
1218
+ return mutationResult(await c.write.deleteTag(args.uuid, opts));
1219
+ }
742
1220
  }));
743
1221
  server.registerTool("restore_item", {
744
1222
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
@@ -785,17 +1263,80 @@ export function createThingsMcpServer(options = {}) {
785
1263
  ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
786
1264
  ...(args.notes !== undefined && { notes: args.notes }),
787
1265
  }, writeOptions(args)))));
788
- server.registerTool("create_heading", {
789
- description: "Create a heading inside an existing project; its uuid is returned. The project " +
790
- "must name an existing project. Uses the Things proxy shortcuts set them up once " +
791
- "with `things setup shortcuts`.",
1266
+ server.registerTool("heading", {
1267
+ description: "Manage a project heading action selects which. create: a new heading in an existing " +
1268
+ "project (project + title; returns its uuid; uses the Things proxy shortcuts, set up " +
1269
+ "once with `things setup shortcuts`). rename: rename in place (uuid + title; works on " +
1270
+ "archived headings). archive: retire a heading so it leaves the active project view " +
1271
+ "(reversible with action unarchive); with open children pass children — complete or " +
1272
+ "cancel resolve them with the heading in one cascade, reparent moves them to the project " +
1273
+ "root keeping them open. unarchive: bring an archived heading back; restore_children also " +
1274
+ "reopens the children the archive resolved with it. convert_to_project: promote a to-do " +
1275
+ "or heading into a new project — this REPLACES the original and cannot be undone (a " +
1276
+ "converted heading's to-dos move under the new project), and requires " +
1277
+ "dangerously_drive_gui.",
792
1278
  inputSchema: {
793
- project: z.string().describe(`Existing project (${REF_FORMAT})`),
794
- title: z.string(),
1279
+ action: z.enum(["create", "rename", "archive", "unarchive", "convert_to_project"]),
1280
+ project: z.string().optional().describe(`create: existing project (${REF_FORMAT})`),
1281
+ uuid: z
1282
+ .string()
1283
+ .optional()
1284
+ .describe("rename/archive/unarchive/convert_to_project: the target's uuid"),
1285
+ title: z.string().optional().describe("create: the new heading; rename: the new title"),
1286
+ children: z
1287
+ .enum(["complete", "cancel", "reparent"])
1288
+ .optional()
1289
+ .describe("archive: required when the heading has open children"),
1290
+ restore_children: z
1291
+ .boolean()
1292
+ .optional()
1293
+ .describe("unarchive: also reopen the children archived with the heading"),
1294
+ ...driveGuiShape,
795
1295
  ...dryRunShape,
796
1296
  },
797
- annotations: NON_DESTRUCTIVE,
798
- }, async (args) => guard(async () => mutationResult(await getClient().write.createHeading(containerRef(args.project), args.title, writeOptions(args)))));
1297
+ annotations: DESTRUCTIVE,
1298
+ }, async (args) => guard(async () => {
1299
+ const c = getClient();
1300
+ const opts = writeOptions(args);
1301
+ switch (args.action) {
1302
+ case "create":
1303
+ if (args.project === undefined || args.title === undefined) {
1304
+ return usage('action "create" requires project and title');
1305
+ }
1306
+ return mutationResult(await c.write.createHeading(containerRef(args.project), args.title, opts));
1307
+ case "rename":
1308
+ if (args.uuid === undefined || args.title === undefined) {
1309
+ return usage('action "rename" requires uuid and title');
1310
+ }
1311
+ return mutationResult(await c.write.renameHeading(args.uuid, args.title, opts));
1312
+ case "archive": {
1313
+ if (args.uuid === undefined)
1314
+ return usage('action "archive" requires uuid');
1315
+ const r = await c.write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
1316
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1317
+ ? jsonResult(r)
1318
+ : mutationResult(r.heading);
1319
+ }
1320
+ case "unarchive": {
1321
+ if (args.uuid === undefined)
1322
+ return usage('action "unarchive" requires uuid');
1323
+ const r = await c.write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
1324
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1325
+ ? jsonResult(r)
1326
+ : mutationResult(r.heading);
1327
+ }
1328
+ case "convert_to_project": {
1329
+ if (args.uuid === undefined) {
1330
+ return usage('action "convert_to_project" requires uuid');
1331
+ }
1332
+ const item = c.read.byUuid(args.uuid);
1333
+ if (item === null)
1334
+ throw new RangeError(`no item with uuid ${args.uuid}`);
1335
+ const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
1336
+ return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
1337
+ }
1338
+ }
1339
+ }));
799
1340
  server.registerTool("clear_reminder", {
800
1341
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
801
1342
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
@@ -804,48 +1345,190 @@ export function createThingsMcpServer(options = {}) {
804
1345
  inputSchema: { uuid: z.string(), ...dryRunShape },
805
1346
  annotations: NON_DESTRUCTIVE,
806
1347
  }, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
807
- server.registerTool("rename_heading", {
808
- description: "Rename a heading in place (works on archived headings too).",
809
- inputSchema: { uuid: z.string(), title: z.string(), ...dryRunShape },
810
- annotations: NON_DESTRUCTIVE,
811
- }, async (args) => guard(async () => mutationResult(await getClient().write.renameHeading(args.uuid, args.title, writeOptions(args)))));
812
- server.registerTool("archive_heading", {
813
- description: "Archive a heading — it leaves the active project view (reversible with " +
814
- "unarchive_heading). The preferred way to retire a heading: row deletion only " +
815
- "exists in the app's UI / Shortcuts behind a per-run consent dialog. With open " +
816
- "children the children policy is required: complete or cancel resolve them with " +
817
- "the heading in one cascade; reparent moves them to the project root first, " +
818
- "keeping them open (a compound sequence that undo reverses as one unit).",
1348
+ // -------------------------------------------- GUI-driven (Accessibility)
1349
+ const WEEKDAY_ENUM = z.enum([
1350
+ "sunday",
1351
+ "monday",
1352
+ "tuesday",
1353
+ "wednesday",
1354
+ "thursday",
1355
+ "friday",
1356
+ "saturday",
1357
+ ]);
1358
+ // The base rule (also used by create_repeating_project, which stays minimal
1359
+ // its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
1360
+ const baseRepeatShape = {
1361
+ frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
1362
+ interval: z.number().int().min(1).max(99).describe("Every N units (1–99)"),
1363
+ };
1364
+ const repeatRuleShape = {
1365
+ ...baseRepeatShape,
1366
+ after_completion: z
1367
+ .boolean()
1368
+ .optional()
1369
+ .describe("Repeat N units AFTER each occurrence is completed, instead of on a fixed schedule"),
1370
+ weekdays: z.array(WEEKDAY_ENUM).optional().describe("Weekly only: the weekdays it repeats on"),
1371
+ monthly_day: z
1372
+ .union([z.number().int(), z.literal("last")])
1373
+ .optional()
1374
+ .describe('Monthly/yearly only: a day of the month (1–31, or "last")'),
1375
+ monthly_weekday: WEEKDAY_ENUM.optional().describe("Monthly/yearly only: a weekday for an nth-weekday rule (with monthly_ordinal)"),
1376
+ monthly_ordinal: z
1377
+ .union([z.number().int(), z.literal("last")])
1378
+ .optional()
1379
+ .describe('Monthly/yearly only: which weekday (1–5, or "last") with monthly_weekday'),
1380
+ yearly_month: z
1381
+ .number()
1382
+ .int()
1383
+ .min(1)
1384
+ .max(12)
1385
+ .optional()
1386
+ .describe("Yearly only: the month (1–12)"),
1387
+ ends_after: z.number().int().optional().describe("Stop after N occurrences"),
1388
+ ends_on: z.string().optional().describe("YYYY-MM-DD — stop after this date"),
1389
+ reminder: z.string().optional().describe("HH:mm — a reminder time on each occurrence"),
1390
+ deadline: z.boolean().optional().describe("Give each occurrence a deadline"),
1391
+ start_days_earlier: z
1392
+ .number()
1393
+ .int()
1394
+ .optional()
1395
+ .describe("With deadline: start each occurrence N days before its deadline"),
1396
+ };
1397
+ // oxlint-disable-next-line consistent-function-scoping -- kept beside repeatRuleShape it mirrors
1398
+ const repeatExtras = (a, frequency) => {
1399
+ const fields = {};
1400
+ if (a.after_completion === true)
1401
+ fields.afterCompletion = true;
1402
+ if (a.weekdays !== undefined)
1403
+ fields.weekdays = a.weekdays;
1404
+ const anchor = a.monthly_day !== undefined
1405
+ ? { day: a.monthly_day }
1406
+ : a.monthly_weekday !== undefined || a.monthly_ordinal !== undefined
1407
+ ? { weekday: a.monthly_weekday, ordinal: a.monthly_ordinal }
1408
+ : undefined;
1409
+ if (frequency === "monthly" && anchor !== undefined)
1410
+ fields.monthly = anchor;
1411
+ if (frequency === "yearly" && (a.yearly_month !== undefined || anchor !== undefined)) {
1412
+ fields.yearly = { month: a.yearly_month, ...anchor };
1413
+ }
1414
+ if (a.ends_after !== undefined)
1415
+ fields.ends = { kind: "after", count: a.ends_after };
1416
+ else if (a.ends_on !== undefined)
1417
+ fields.ends = { kind: "on-date", date: a.ends_on };
1418
+ if (a.reminder !== undefined)
1419
+ fields.reminder = a.reminder;
1420
+ if (a.deadline === true)
1421
+ fields.deadline = true;
1422
+ if (a.start_days_earlier !== undefined)
1423
+ fields.startDaysEarlier = a.start_days_earlier;
1424
+ return fields;
1425
+ };
1426
+ server.registerTool("repeat", {
1427
+ description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
1428
+ "interface — every action needs dangerously_drive_gui. action start: turn a plain item " +
1429
+ "into a repeating one — this REPLACES it with a new series and cannot be undone (give " +
1430
+ "frequency + interval and optionally the weekday set, monthly/yearly day, end bound, " +
1431
+ "reminder, or per-occurrence deadline); returns a repeating block with instanceUuid (the " +
1432
+ "visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
1433
+ "change a repeating item's rule in place, keeping the same item (undoable — it restores " +
1434
+ "the previous rule). action pause/resume: stop or restart its new occurrences, keeping " +
1435
+ "the rule. action create (scope project only): create a project and make it repeating in " +
1436
+ "one call — the project is created first and PERSISTS even if the make-repeating step " +
1437
+ "refuses; give an area to place it or omit it to create in Someday (only frequency and " +
1438
+ "interval are supported); returns the new project's uuid.",
819
1439
  inputSchema: {
820
- uuid: z.string(),
821
- children: z
822
- .enum(["complete", "cancel", "reparent"])
1440
+ scope: z.enum(["todo", "project"]),
1441
+ action: z.enum(["start", "reschedule", "pause", "resume", "create"]),
1442
+ uuid: z
1443
+ .string()
823
1444
  .optional()
824
- .describe("Required when the heading has open children"),
825
- ...dryRunShape,
826
- },
827
- annotations: NON_DESTRUCTIVE,
828
- }, async (args) => guard(async () => {
829
- const r = await getClient().write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, writeOptions(args));
830
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
831
- ? jsonResult(r)
832
- : mutationResult(r.heading);
833
- }));
834
- server.registerTool("unarchive_heading", {
835
- description: "Un-archive a heading. restore_children also reopens the children the archive " +
836
- "cascade resolved with it (matching resolution timestamps; someday state " +
837
- "survives). Children resolved at other times are never touched.",
838
- inputSchema: {
839
- uuid: z.string(),
840
- restore_children: z.boolean().optional(),
1445
+ .describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
1446
+ title: z.string().optional().describe("create (project): the new project's title"),
1447
+ notes: z.string().optional().describe("create (project): notes"),
1448
+ area: z.string().optional().describe(`create (project): destination area (${REF_FORMAT})`),
1449
+ project_deadline: z
1450
+ .string()
1451
+ .optional()
1452
+ .describe(`create (project): the project's due date — ${DATE_FORMAT}`),
1453
+ todos: z
1454
+ .array(z.string())
1455
+ .optional()
1456
+ .describe("create (project): initial child to-do titles"),
1457
+ frequency: z
1458
+ .enum(["daily", "weekly", "monthly", "yearly"])
1459
+ .optional()
1460
+ .describe("start/reschedule/create: how often it repeats"),
1461
+ interval: z
1462
+ .number()
1463
+ .int()
1464
+ .min(1)
1465
+ .max(99)
1466
+ .optional()
1467
+ .describe("start/reschedule/create: every N units (1–99)"),
1468
+ after_completion: repeatRuleShape.after_completion,
1469
+ weekdays: repeatRuleShape.weekdays,
1470
+ monthly_day: repeatRuleShape.monthly_day,
1471
+ monthly_weekday: repeatRuleShape.monthly_weekday,
1472
+ monthly_ordinal: repeatRuleShape.monthly_ordinal,
1473
+ yearly_month: repeatRuleShape.yearly_month,
1474
+ ends_after: repeatRuleShape.ends_after,
1475
+ ends_on: repeatRuleShape.ends_on,
1476
+ reminder: repeatRuleShape.reminder,
1477
+ deadline: repeatRuleShape.deadline,
1478
+ start_days_earlier: repeatRuleShape.start_days_earlier,
1479
+ ...driveGuiShape,
841
1480
  ...dryRunShape,
842
1481
  },
843
- annotations: NON_DESTRUCTIVE,
1482
+ annotations: DESTRUCTIVE,
844
1483
  }, async (args) => guard(async () => {
845
- const r = await getClient().write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, writeOptions(args));
846
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
847
- ? jsonResult(r)
848
- : mutationResult(r.heading);
1484
+ const c = getClient();
1485
+ const opts = writeOptions(args);
1486
+ const { frequency, interval } = args;
1487
+ if (args.action === "create") {
1488
+ if (args.scope !== "project")
1489
+ return usage('action "create" requires scope "project"');
1490
+ if (args.title === undefined)
1491
+ return usage('action "create" requires title');
1492
+ if (frequency === undefined || interval === undefined) {
1493
+ return usage('action "create" requires frequency and interval');
1494
+ }
1495
+ return mutationResult(await c.write.createRepeatingProject({
1496
+ title: args.title,
1497
+ ...(args.notes !== undefined && { notes: args.notes }),
1498
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1499
+ ...(args.project_deadline !== undefined && { deadline: args.project_deadline }),
1500
+ ...(args.todos !== undefined && { todos: args.todos }),
1501
+ frequency,
1502
+ interval,
1503
+ }, opts));
1504
+ }
1505
+ if (args.uuid === undefined)
1506
+ return usage(`action "${args.action}" requires uuid`);
1507
+ if (args.action === "pause" || args.action === "resume") {
1508
+ const op = args.scope === "todo"
1509
+ ? args.action === "pause"
1510
+ ? "todo.pause-repeat"
1511
+ : "todo.resume-repeat"
1512
+ : args.action === "pause"
1513
+ ? "project.pause-repeat"
1514
+ : "project.resume-repeat";
1515
+ return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
1516
+ }
1517
+ // start | reschedule
1518
+ if (frequency === undefined || interval === undefined) {
1519
+ return usage(`action "${args.action}" requires frequency and interval`);
1520
+ }
1521
+ const extras = repeatExtras(args, frequency);
1522
+ if (args.scope === "todo") {
1523
+ const op = args.action === "start" ? "todo.make-repeating" : "todo.reschedule-repeat";
1524
+ return mutationResult(await c.write.run(op, { uuid: args.uuid, frequency, interval, ...extras }, opts));
1525
+ }
1526
+ // scope project
1527
+ if (args.action === "reschedule") {
1528
+ return mutationResult(await c.write.run("project.reschedule-repeat", { uuid: args.uuid, frequency, interval, ...extras }, opts));
1529
+ }
1530
+ // scope project, action start → the dedicated make-repeating method
1531
+ return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
849
1532
  }));
850
1533
  server.registerTool("duplicate_item", {
851
1534
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
@@ -869,114 +1552,28 @@ export function createThingsMcpServer(options = {}) {
869
1552
  when: whenSchema,
870
1553
  deadline: z.string().optional().describe(DATE_FORMAT),
871
1554
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
872
- ...dryRunShape,
873
- },
874
- annotations: NON_DESTRUCTIVE,
875
- }, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
876
- title: args.title,
877
- ...(args.notes !== undefined && { notes: args.notes }),
878
- ...(args.area !== undefined && { area: containerRef(args.area) }),
879
- ...(args.when !== undefined && { when: args.when }),
880
- ...(args.deadline !== undefined && { deadline: args.deadline }),
881
- ...(args.todos !== undefined && { todos: args.todos }),
882
- }, writeOptions(args)))));
883
- server.registerTool("update_project", {
884
- description: "Update a project's title, notes, schedule, reminder, or deadline. " +
885
- "append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
886
- "Changing the schedule keeps an existing reminder unless the call sets a new one. " +
887
- "clear_reminder works while the project is scheduled for today or this evening; a " +
888
- "reminder on a future date can only be changed, not cleared.",
889
- inputSchema: {
890
- uuid: z.string(),
891
- title: z.string().optional(),
892
- notes: z.string().optional().describe("Replaces the whole notes body"),
893
- append_notes: z.string().optional(),
894
- prepend_notes: z.string().optional(),
895
- when: whenSchema,
896
- reminder: z.string().optional().describe(REMINDER_FORMAT),
897
- clear_reminder: z.boolean().optional(),
898
- deadline: z.string().optional().describe(DATE_FORMAT),
899
- clear_deadline: z.boolean().optional(),
1555
+ ...tzShape,
900
1556
  ...dryRunShape,
901
1557
  },
902
1558
  annotations: NON_DESTRUCTIVE,
903
1559
  }, async (args) => guard(async () => {
904
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
905
- if (notesModes.length > 1) {
906
- return usage("notes, append_notes, prepend_notes are exclusive");
907
- }
908
- if (args.reminder !== undefined && args.clear_reminder === true) {
909
- return usage("pass at most one of reminder / clear_reminder");
910
- }
911
- if (args.deadline !== undefined && args.clear_deadline === true) {
912
- return usage("pass at most one of deadline / clear_deadline");
913
- }
914
- return mutationResult(await getClient().write.updateProject(args.uuid, {
915
- ...(args.title !== undefined && { title: args.title }),
1560
+ const badZone = badTz(args.tz);
1561
+ if (badZone !== null)
1562
+ return badZone;
1563
+ return mutationResult(await getClient().write.addProject({
1564
+ title: args.title,
916
1565
  ...(args.notes !== undefined && { notes: args.notes }),
917
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
918
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1566
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
919
1567
  ...(args.when !== undefined && { when: args.when }),
920
- ...(args.reminder !== undefined && { reminder: args.reminder }),
921
- ...(args.clear_reminder === true && { reminder: null }),
922
1568
  ...(args.deadline !== undefined && { deadline: args.deadline }),
923
- ...(args.clear_deadline === true && { deadline: null }),
1569
+ ...(args.todos !== undefined && { todos: args.todos }),
924
1570
  }, writeOptions(args)));
925
1571
  }));
926
- server.registerTool("set_project_status", {
927
- description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
928
- "children policy: 'require-resolved' errors if open to-dos remain; " +
929
- "'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
930
- "never alters already-completed children). status 'open' reopens a completed or " +
931
- "canceled project; its children stay completed/canceled unless restore_children " +
932
- "also reopens the ones that were resolved together with the project.",
933
- inputSchema: {
934
- uuid: z.string().describe("Project uuid"),
935
- status: z.enum(["completed", "canceled", "open"]),
936
- children: z
937
- .enum(["require-resolved", "auto-complete", "auto-cancel"])
938
- .optional()
939
- .describe("Required for completed/canceled: what to do with the project's open to-dos"),
940
- restore_children: z
941
- .boolean()
942
- .optional()
943
- .describe("open only: also reopen the to-dos that were resolved with the project"),
944
- ...dryRunShape,
945
- },
946
- annotations: NON_DESTRUCTIVE,
947
- }, async (args) => guard(async () => {
948
- const c = getClient();
949
- if (args.status !== "open" && args.restore_children !== undefined) {
950
- return usage("restore_children applies only to status 'open'");
951
- }
952
- if (args.status === "completed") {
953
- if (args.children !== "require-resolved" && args.children !== "auto-complete") {
954
- return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
955
- }
956
- return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
957
- }
958
- if (args.status === "canceled") {
959
- if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
960
- return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
961
- }
962
- return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
963
- }
964
- if (args.children !== undefined) {
965
- return usage("children applies only to status 'completed' or 'canceled'");
966
- }
967
- const outcome = await c.write.reopenProject(args.uuid, {
968
- ...writeOptions(args),
969
- ...(args.restore_children === true && { restoreChildren: true }),
970
- });
971
- return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
972
- ? jsonResult(outcome)
973
- : mutationResult(outcome.project);
974
- }));
975
1572
  server.registerTool("move_project", {
976
1573
  description: "Move a project into an area, or detach it from its current area. Pass exactly " +
977
1574
  "one of area / detach. The project's status and schedule are unaffected.",
978
1575
  inputSchema: {
979
- uuid: z.string().describe("Project uuid"),
1576
+ uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
980
1577
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
981
1578
  detach: z.boolean().optional().describe("Remove the current area assignment"),
982
1579
  ...dryRunShape,
@@ -993,46 +1590,15 @@ export function createThingsMcpServer(options = {}) {
993
1590
  }));
994
1591
  // ----------------------------------------------------------------- areas
995
1592
  server.registerTool("add_area", {
996
- description: "Create an area. Tags, when given, must name existing tags.",
1593
+ description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
997
1594
  inputSchema: {
998
1595
  title: z.string(),
999
- tags: z.array(z.string()).optional().describe("Existing tag names"),
1596
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
1597
+ ...createTagsShape,
1000
1598
  ...dryRunShape,
1001
1599
  },
1002
1600
  annotations: NON_DESTRUCTIVE,
1003
1601
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
1004
- server.registerTool("update_area", {
1005
- description: "Rename an area and/or replace its tags (the full set; tags must name existing tags).",
1006
- inputSchema: {
1007
- target: z.string().describe(`Area to update (${REF_FORMAT})`),
1008
- title: z.string().optional().describe("New name"),
1009
- tags: z.array(z.string()).optional().describe("Existing tag names (full replacement)"),
1010
- ...dryRunShape,
1011
- },
1012
- annotations: NON_DESTRUCTIVE,
1013
- }, async (args) => guard(async () => {
1014
- if (args.title === undefined && args.tags === undefined) {
1015
- return usage("pass title and/or tags");
1016
- }
1017
- return mutationResult(await getClient().write.updateArea(args.target, {
1018
- ...(args.title !== undefined && { title: args.title }),
1019
- ...(args.tags !== undefined && { tags: args.tags }),
1020
- }, writeOptions(args)));
1021
- }));
1022
- server.registerTool("delete_area", {
1023
- description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
1024
- "undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
1025
- "projects remain, no longer assigned to any area.",
1026
- inputSchema: {
1027
- target: z.string().describe(`Area to delete (${REF_FORMAT})`),
1028
- dangerously_permanent: z
1029
- .boolean()
1030
- .optional()
1031
- .describe("Confirm permanent, unrecoverable deletion"),
1032
- ...dryRunShape,
1033
- },
1034
- annotations: DESTRUCTIVE,
1035
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
1036
1602
  // ------------------------------------------------------------------ tags
1037
1603
  server.registerTool("add_tag", {
1038
1604
  description: "Create a tag, optionally nested under an existing parent tag.",
@@ -1043,61 +1609,6 @@ export function createThingsMcpServer(options = {}) {
1043
1609
  },
1044
1610
  annotations: NON_DESTRUCTIVE,
1045
1611
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
1046
- server.registerTool("update_tag", {
1047
- description: "Rename a tag (existing assignments follow the rename), nest it under another " +
1048
- "existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
1049
- "parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
1050
- inputSchema: {
1051
- target: z.string().describe(`Tag to update (${REF_FORMAT})`),
1052
- title: z.string().optional().describe("New name"),
1053
- parent: z.string().optional().describe("Existing tag to nest under"),
1054
- unnest: z.boolean().optional().describe("Move the tag to the top level"),
1055
- shortcut: z.string().optional().describe("Keyboard shortcut character"),
1056
- clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
1057
- ...dryRunShape,
1058
- },
1059
- annotations: NON_DESTRUCTIVE,
1060
- }, async (args) => guard(async () => {
1061
- if (args.title === undefined &&
1062
- args.parent === undefined &&
1063
- args.unnest === undefined &&
1064
- args.shortcut === undefined &&
1065
- args.clear_shortcut === undefined) {
1066
- return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
1067
- }
1068
- if (args.parent !== undefined && args.unnest === true) {
1069
- return usage("parent and unnest are exclusive");
1070
- }
1071
- if (args.shortcut !== undefined && args.clear_shortcut === true) {
1072
- return usage("shortcut and clear_shortcut are exclusive");
1073
- }
1074
- return mutationResult(await getClient().write.updateTag(args.target, {
1075
- ...(args.title !== undefined && { title: args.title }),
1076
- ...(args.parent !== undefined && { parent: args.parent }),
1077
- ...(args.unnest === true && { unnest: true }),
1078
- ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
1079
- ...(args.clear_shortcut === true && { clearShortcut: true }),
1080
- }, writeOptions(args)));
1081
- }));
1082
- server.registerTool("delete_tag", {
1083
- description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
1084
- "requires dangerously_permanent. The tag is removed from every item. If the tag " +
1085
- "has nested child tags they are ALL permanently deleted with it — pass " +
1086
- "acknowledge_tag_subtree to confirm.",
1087
- inputSchema: {
1088
- target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
1089
- dangerously_permanent: z
1090
- .boolean()
1091
- .optional()
1092
- .describe("Confirm permanent, unrecoverable deletion"),
1093
- acknowledge_tag_subtree: z
1094
- .boolean()
1095
- .optional()
1096
- .describe("Confirm permanent deletion of ALL nested child tags too"),
1097
- ...dryRunShape,
1098
- },
1099
- annotations: DESTRUCTIVE,
1100
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
1101
1612
  // -------------------------------------------------- generic + discovery
1102
1613
  server.registerTool("run_operation", {
1103
1614
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
@@ -1133,10 +1644,10 @@ export function createThingsMcpServer(options = {}) {
1133
1644
  params: z.record(z.string(), z.unknown()),
1134
1645
  options: z
1135
1646
  .object({
1136
- acknowledgeChecklistReset: z.boolean().optional(),
1137
- acknowledgeProjectReopen: z.boolean().optional(),
1138
- dangerouslyPermanent: z.boolean().optional(),
1139
- acknowledgeTagSubtree: z.boolean().optional(),
1647
+ acknowledge_checklist_reset: z.boolean().optional(),
1648
+ acknowledge_project_reopen: z.boolean().optional(),
1649
+ dangerously_permanent: z.boolean().optional(),
1650
+ acknowledge_tag_subtree: z.boolean().optional(),
1140
1651
  })
1141
1652
  .optional(),
1142
1653
  }))
@@ -1146,10 +1657,29 @@ export function createThingsMcpServer(options = {}) {
1146
1657
  },
1147
1658
  annotations: DESTRUCTIVE,
1148
1659
  }, async (args) => guard(async () => {
1149
- const results = await getClient().write.batch(args.ops, {
1660
+ // Map each op's snake_case acknowledgements into the batch engine's
1661
+ // option names, and apply the process-wide disruption ceiling — batch
1662
+ // takes it per-op, and MCP exposes no per-op override, so it is uniform.
1663
+ const ceiling = options.maxDisruption;
1664
+ const ops = args.ops.map((op) => {
1665
+ const o = op.options;
1666
+ const opts = {
1667
+ ...(o?.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
1668
+ ...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
1669
+ ...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
1670
+ ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
1671
+ ...(ceiling !== undefined && { maxDisruption: ceiling }),
1672
+ };
1673
+ return {
1674
+ op: op.op,
1675
+ params: op.params,
1676
+ ...(Object.keys(opts).length > 0 && { options: opts }),
1677
+ };
1678
+ });
1679
+ const results = await getClient().write.batch(ops, {
1150
1680
  ...(args.dry_run === true && { dryRun: true }),
1151
1681
  ...(args.fail_fast === true && { failFast: true }),
1152
- actor: "mcp",
1682
+ actor: mcpActor(),
1153
1683
  });
1154
1684
  return jsonResult(results);
1155
1685
  }));
@@ -1157,13 +1687,16 @@ export function createThingsMcpServer(options = {}) {
1157
1687
  description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1158
1688
  "area-less someday projects — one kind per call), a " +
1159
1689
  "project's to-dos, a project's headings (scope=headings — children move with " +
1160
- "their heading), an area, or the top-level sidebar projects (scope=projects — " +
1690
+ "their heading), an area, or the top-level projects (scope=projects — " +
1161
1691
  "each project takes a brief someday/anytime round-trip) — the given uuids move " +
1162
1692
  "to the TOP in the given order; unlisted items keep their relative order below. " +
1163
1693
  "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
1164
1694
  "via `things config set allow-experimental true`. This Evening and " +
1165
1695
  `scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
1166
- "to-dos and projects are ordered separately — one kind per call.",
1696
+ "to-dos and projects are ordered separately — one kind per call. " +
1697
+ "scope=areas instead moves the sidebar areas themselves: give target plus exactly " +
1698
+ "one of before/after/position, and pass dangerously_drive_gui (it drives the local " +
1699
+ "Things app). Every other scope takes uuids.",
1167
1700
  inputSchema: {
1168
1701
  scope: z.enum([
1169
1702
  "today",
@@ -1174,41 +1707,108 @@ export function createThingsMcpServer(options = {}) {
1174
1707
  "headings",
1175
1708
  "area",
1176
1709
  "projects",
1710
+ "areas",
1177
1711
  ]),
1178
1712
  container: z
1179
1713
  .string()
1180
1714
  .optional()
1181
1715
  .describe(`Project/area (${REF_FORMAT}) — required for those scopes`),
1182
- uuids: z.array(z.string()).describe("Desired order, top first (may be a subset)"),
1716
+ uuids: z
1717
+ .array(z.string())
1718
+ .optional()
1719
+ .describe("Desired order, top first (may be a subset) — required for every scope but areas"),
1183
1720
  strategy: z.enum(["native", "bounce"]).optional(),
1721
+ target: z.string().optional().describe(`scope areas: the area to move (${REF_FORMAT})`),
1722
+ before: z
1723
+ .string()
1724
+ .optional()
1725
+ .describe(`scope areas: place it immediately above this area (${REF_FORMAT})`),
1726
+ after: z
1727
+ .string()
1728
+ .optional()
1729
+ .describe(`scope areas: place it immediately below this area (${REF_FORMAT})`),
1730
+ position: z
1731
+ .enum(["first", "last"])
1732
+ .optional()
1733
+ .describe("scope areas: move it to the top or bottom of the area list"),
1734
+ ...driveGuiShape,
1184
1735
  ...dryRunShape,
1185
1736
  },
1186
1737
  annotations: NON_DESTRUCTIVE,
1187
- }, async (args) => guard(async () => mutationResult(await getClient().write.reorder({
1188
- scope: args.scope,
1189
- uuids: args.uuids,
1190
- ...(args.container !== undefined && { container: containerRef(args.container) }),
1191
- ...(args.strategy !== undefined && { strategy: args.strategy }),
1192
- }, writeOptions(args)))));
1738
+ }, async (args) => guard(async () => {
1739
+ if (args.scope === "areas") {
1740
+ if (args.target === undefined)
1741
+ return usage('scope "areas" requires target');
1742
+ return mutationResult(await getClient().write.run("area.reorder", {
1743
+ target: args.target,
1744
+ ...(args.before !== undefined && { before: args.before }),
1745
+ ...(args.after !== undefined && { after: args.after }),
1746
+ ...(args.position !== undefined && { position: args.position }),
1747
+ }, writeOptions(args)));
1748
+ }
1749
+ if (args.uuids === undefined)
1750
+ return usage(`scope "${args.scope}" requires uuids`);
1751
+ return mutationResult(await getClient().write.reorder({
1752
+ scope: args.scope,
1753
+ uuids: args.uuids,
1754
+ ...(args.container !== undefined && { container: containerRef(args.container) }),
1755
+ ...(args.strategy !== undefined && { strategy: args.strategy }),
1756
+ }, writeOptions(args)));
1757
+ }));
1193
1758
  server.registerTool("undo", {
1194
- description: "Undo the last N changes made through this interface, newest first (changes made " +
1195
- "directly in the Things app cannot be undone here). Some changes cannot be " +
1196
- "reversedpermanent deletions, or changes whose prior state is unknown and are " +
1197
- "reported as irreversible; a to-do brought back from an undone delete returns to " +
1198
- "the Inbox without its schedule. Undoing the creation of an area or tag deletes it " +
1199
- "permanentlyrequires dangerously_permanent.",
1759
+ description: "Undo the last N changes, newest first (changes made directly in the Things app " +
1760
+ "cannot be undone here). By default this undoes only changes made through THIS " +
1761
+ "connectionthis client's own writes; it will not touch the user's own edits, or " +
1762
+ 'another client\'s, unless you pass by="*" (all authors) or a specific author name; ' +
1763
+ "pass a txn token to undo one exact change. Some changes cannot be reversed " +
1764
+ "permanent deletions, or changes whose prior state is unknown and are reported as " +
1765
+ "irreversible; a to-do brought back from an undone delete returns to the Inbox " +
1766
+ "without its schedule. Undoing the creation of an area or tag deletes it " +
1767
+ "permanently — requires dangerously_permanent. An undo is refused when the item " +
1768
+ "changed outside this interface since (its list or project, status, schedule, " +
1769
+ "trashed state, or a field like the title moved) — pass " +
1770
+ "acknowledge_out_of_band_changes to overwrite it anyway.",
1200
1771
  inputSchema: {
1201
1772
  last: z.number().int().min(1).optional().describe("How many to unwind (default 1)"),
1773
+ by: z
1774
+ .string()
1775
+ .optional()
1776
+ .describe('Whose changes to undo: an exact author name, or "*" for everyone. Defaults to ' +
1777
+ "this client's own writes (only changes made through this connection). Matches " +
1778
+ "exactly. Selects WHICH changes to undo, and never a change already undone. Not " +
1779
+ "combinable with txn."),
1780
+ txn: z
1781
+ .string()
1782
+ .optional()
1783
+ .describe("Undo exactly the one change with this undo token (the undoToken field returned " +
1784
+ "by the mutation); immune to interleaving. Not combinable with last/by."),
1202
1785
  dangerously_permanent: z.boolean().optional(),
1786
+ acknowledge_out_of_band_changes: z
1787
+ .boolean()
1788
+ .optional()
1789
+ .describe("Proceed even when the item changed outside this interface since (in the Things app " +
1790
+ "or by another tool) — overwrites whatever the out-of-band change left, instead of " +
1791
+ "refusing."),
1203
1792
  ...dryRunShape,
1204
1793
  },
1205
1794
  annotations: DESTRUCTIVE,
1206
1795
  }, async (args) => guard(async () => {
1796
+ if (args.txn !== undefined && (args.last !== undefined || args.by !== undefined)) {
1797
+ return usage("txn cannot be combined with last or by");
1798
+ }
1207
1799
  const items = await getClient().write.undo({
1208
1800
  ...(args.last !== undefined && { last: args.last }),
1801
+ ...(args.txn !== undefined && { txn: args.txn }),
1802
+ // Asymmetric default: agents must not clobber the user's own edits (or
1803
+ // another client's) without explicitly opting in via by:"*". Scoped to
1804
+ // this client's own handshake identity, so each session undoes its own.
1805
+ ...(args.txn === undefined && { by: args.by ?? mcpActor() }),
1209
1806
  ...(args.dry_run === true && { dryRun: true }),
1210
1807
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
1211
- actor: "mcp",
1808
+ ...(args.acknowledge_out_of_band_changes === true && {
1809
+ acknowledgeOutOfBandChanges: true,
1810
+ }),
1811
+ actor: mcpActor(),
1212
1812
  });
1213
1813
  return jsonResult(items);
1214
1814
  }));
@@ -1226,8 +1826,10 @@ export function createThingsMcpServer(options = {}) {
1226
1826
  server.registerTool("doctor", {
1227
1827
  description: "Check the environment: whether the Things app and its database are reachable, " +
1228
1828
  "whether changes can be made, any one-time setup still needed (macOS permissions, " +
1229
- "the app's 'Enable Things URLs' setting), and whether the environment changed since " +
1230
- "the last successful write, with steps to fix.",
1829
+ "the app's 'Enable Things URLs' setting), whether the environment changed since " +
1830
+ "the last successful write, and a sync-health summary (whether the app is running, how " +
1831
+ "recently the data changed, and — when a Things Cloud account is attached — the last " +
1832
+ "sync attempt), with steps to fix.",
1231
1833
  inputSchema: {
1232
1834
  probe_automation: z
1233
1835
  .boolean()