things-api 0.8.0 → 0.10.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 (238) hide show
  1. package/README.md +31 -11
  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/commands/area.d.ts +34 -2
  8. package/dist/cli/commands/area.js +140 -28
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +23 -1
  16. package/dist/cli/commands/project.js +82 -15
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.d.ts +6 -125
  19. package/dist/cli/commands/reads.js +649 -657
  20. package/dist/cli/commands/reads.js.map +1 -1
  21. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  22. package/dist/cli/commands/repeat-flags.js +76 -0
  23. package/dist/cli/commands/repeat-flags.js.map +1 -0
  24. package/dist/cli/commands/setup.js +2 -3
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.d.ts +1 -1
  27. package/dist/cli/commands/show.js +169 -18
  28. package/dist/cli/commands/show.js.map +1 -1
  29. package/dist/cli/commands/snapshot.js +1 -1
  30. package/dist/cli/commands/snapshot.js.map +1 -1
  31. package/dist/cli/commands/todo.d.ts +1 -1
  32. package/dist/cli/commands/todo.js +21 -9
  33. package/dist/cli/commands/todo.js.map +1 -1
  34. package/dist/cli/commands/writes.js +378 -148
  35. package/dist/cli/commands/writes.js.map +1 -1
  36. package/dist/cli/did-you-mean.d.ts +29 -0
  37. package/dist/cli/did-you-mean.js +53 -0
  38. package/dist/cli/did-you-mean.js.map +1 -0
  39. package/dist/cli/excess-args.d.ts +15 -0
  40. package/dist/cli/excess-args.js +51 -0
  41. package/dist/cli/excess-args.js.map +1 -0
  42. package/dist/cli/glyphs.d.ts +67 -7
  43. package/dist/cli/glyphs.js +202 -19
  44. package/dist/cli/glyphs.js.map +1 -1
  45. package/dist/cli/help.d.ts +54 -0
  46. package/dist/cli/help.js +355 -0
  47. package/dist/cli/help.js.map +1 -0
  48. package/dist/cli/main.js +36 -21
  49. package/dist/cli/main.js.map +1 -1
  50. package/dist/cli/period.d.ts +39 -0
  51. package/dist/cli/period.js +141 -0
  52. package/dist/cli/period.js.map +1 -0
  53. package/dist/cli/read-driver.d.ts +83 -0
  54. package/dist/cli/read-driver.js +215 -0
  55. package/dist/cli/read-driver.js.map +1 -0
  56. package/dist/cli/render.d.ts +246 -0
  57. package/dist/cli/render.js +806 -0
  58. package/dist/cli/render.js.map +1 -0
  59. package/dist/cli/resolve-invocation.d.ts +116 -0
  60. package/dist/cli/resolve-invocation.js +261 -0
  61. package/dist/cli/resolve-invocation.js.map +1 -0
  62. package/dist/cli/shell-quote.d.ts +9 -0
  63. package/dist/cli/shell-quote.js +12 -0
  64. package/dist/cli/shell-quote.js.map +1 -0
  65. package/dist/cli/tag-filters.d.ts +59 -0
  66. package/dist/cli/tag-filters.js +57 -0
  67. package/dist/cli/tag-filters.js.map +1 -0
  68. package/dist/cli/verb-hint.d.ts +25 -0
  69. package/dist/cli/verb-hint.js +138 -0
  70. package/dist/cli/verb-hint.js.map +1 -0
  71. package/dist/cli/width.d.ts +135 -0
  72. package/dist/cli/width.js +313 -0
  73. package/dist/cli/width.js.map +1 -0
  74. package/dist/client.d.ts +146 -24
  75. package/dist/client.js +106 -16
  76. package/dist/client.js.map +1 -1
  77. package/dist/config.d.ts +11 -0
  78. package/dist/config.js +3 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/contracts.d.ts +136 -1
  81. package/dist/contracts.js +44 -1
  82. package/dist/contracts.js.map +1 -1
  83. package/dist/db/fingerprint.d.ts +12 -0
  84. package/dist/db/fingerprint.js +15 -1
  85. package/dist/db/fingerprint.js.map +1 -1
  86. package/dist/db/locate.js +1 -1
  87. package/dist/db/locate.js.map +1 -1
  88. package/dist/diagnose.d.ts +63 -0
  89. package/dist/diagnose.js +38 -1
  90. package/dist/diagnose.js.map +1 -1
  91. package/dist/index.d.ts +30 -4
  92. package/dist/index.js +35 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/mcp/server.d.ts +9 -1
  95. package/dist/mcp/server.js +850 -128
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/entities.d.ts +40 -7
  98. package/dist/model/entities.js.map +1 -1
  99. package/dist/model/mappers.d.ts +7 -0
  100. package/dist/model/mappers.js +20 -3
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/occurrences.d.ts +6 -1
  103. package/dist/model/occurrences.js +9 -5
  104. package/dist/model/occurrences.js.map +1 -1
  105. package/dist/model/recurrence.d.ts +17 -8
  106. package/dist/model/recurrence.js.map +1 -1
  107. package/dist/model/serialize.d.ts +28 -0
  108. package/dist/model/serialize.js +80 -0
  109. package/dist/model/serialize.js.map +1 -0
  110. package/dist/model/when-sugar.d.ts +47 -0
  111. package/dist/model/when-sugar.js +45 -0
  112. package/dist/model/when-sugar.js.map +1 -0
  113. package/dist/read/area-view.d.ts +8 -1
  114. package/dist/read/area-view.js +49 -13
  115. package/dist/read/area-view.js.map +1 -1
  116. package/dist/read/caps.d.ts +14 -0
  117. package/dist/read/caps.js +21 -0
  118. package/dist/read/caps.js.map +1 -0
  119. package/dist/read/filter-contract.d.ts +117 -0
  120. package/dist/read/filter-contract.js +78 -0
  121. package/dist/read/filter-contract.js.map +1 -0
  122. package/dist/read/log-boundary.d.ts +9 -4
  123. package/dist/read/log-boundary.js.map +1 -1
  124. package/dist/read/predicates.d.ts +61 -0
  125. package/dist/read/predicates.js +78 -0
  126. package/dist/read/predicates.js.map +1 -0
  127. package/dist/read/project-view.d.ts +12 -1
  128. package/dist/read/project-view.js +39 -8
  129. package/dist/read/project-view.js.map +1 -1
  130. package/dist/read/queries.d.ts +156 -8
  131. package/dist/read/queries.js +261 -38
  132. package/dist/read/queries.js.map +1 -1
  133. package/dist/read/search-rank.d.ts +36 -0
  134. package/dist/read/search-rank.js +31 -0
  135. package/dist/read/search-rank.js.map +1 -0
  136. package/dist/read/sections.d.ts +51 -0
  137. package/dist/read/sections.js +37 -0
  138. package/dist/read/sections.js.map +1 -0
  139. package/dist/read/show-target.d.ts +13 -4
  140. package/dist/read/show-target.js +11 -2
  141. package/dist/read/show-target.js.map +1 -1
  142. package/dist/read/sidebar-order.d.ts +19 -0
  143. package/dist/read/sidebar-order.js +93 -0
  144. package/dist/read/sidebar-order.js.map +1 -0
  145. package/dist/read/tags.d.ts +27 -3
  146. package/dist/read/tags.js +83 -12
  147. package/dist/read/tags.js.map +1 -1
  148. package/dist/read/truncation.d.ts +71 -0
  149. package/dist/read/truncation.js +217 -0
  150. package/dist/read/truncation.js.map +1 -0
  151. package/dist/read/views.d.ts +177 -19
  152. package/dist/read/views.js +390 -213
  153. package/dist/read/views.js.map +1 -1
  154. package/dist/surface-copy.d.ts +42 -0
  155. package/dist/surface-copy.js +55 -0
  156. package/dist/surface-copy.js.map +1 -1
  157. package/dist/sync-health.d.ts +78 -0
  158. package/dist/sync-health.js +312 -0
  159. package/dist/sync-health.js.map +1 -0
  160. package/dist/write/accessibility-probe.d.ts +12 -0
  161. package/dist/write/accessibility-probe.js +63 -0
  162. package/dist/write/accessibility-probe.js.map +1 -0
  163. package/dist/write/automation-probe.d.ts +7 -0
  164. package/dist/write/automation-probe.js +8 -1
  165. package/dist/write/automation-probe.js.map +1 -1
  166. package/dist/write/batch.js +4 -2
  167. package/dist/write/batch.js.map +1 -1
  168. package/dist/write/capabilities.d.ts +8 -0
  169. package/dist/write/capabilities.js +13 -5
  170. package/dist/write/capabilities.js.map +1 -1
  171. package/dist/write/commands.d.ts +2 -0
  172. package/dist/write/commands.js +562 -48
  173. package/dist/write/commands.js.map +1 -1
  174. package/dist/write/edit-checklist.js +3 -2
  175. package/dist/write/edit-checklist.js.map +1 -1
  176. package/dist/write/guards.d.ts +2 -7
  177. package/dist/write/guards.js +64 -6
  178. package/dist/write/guards.js.map +1 -1
  179. package/dist/write/heading.js +2 -0
  180. package/dist/write/heading.js.map +1 -1
  181. package/dist/write/lock.d.ts +40 -2
  182. package/dist/write/lock.js +91 -14
  183. package/dist/write/lock.js.map +1 -1
  184. package/dist/write/make-repeating-project.d.ts +4 -0
  185. package/dist/write/make-repeating-project.js +253 -0
  186. package/dist/write/make-repeating-project.js.map +1 -0
  187. package/dist/write/operations.d.ts +145 -1
  188. package/dist/write/operations.js +48 -0
  189. package/dist/write/operations.js.map +1 -1
  190. package/dist/write/pipeline.d.ts +19 -0
  191. package/dist/write/pipeline.js +109 -26
  192. package/dist/write/pipeline.js.map +1 -1
  193. package/dist/write/pre-state.d.ts +68 -3
  194. package/dist/write/pre-state.js +102 -3
  195. package/dist/write/pre-state.js.map +1 -1
  196. package/dist/write/reopen.js +3 -2
  197. package/dist/write/reopen.js.map +1 -1
  198. package/dist/write/reorder.js +27 -17
  199. package/dist/write/reorder.js.map +1 -1
  200. package/dist/write/repeat-rule.d.ts +22 -0
  201. package/dist/write/repeat-rule.js +258 -0
  202. package/dist/write/repeat-rule.js.map +1 -0
  203. package/dist/write/reversibility.js +49 -0
  204. package/dist/write/reversibility.js.map +1 -1
  205. package/dist/write/tag-refs.d.ts +47 -0
  206. package/dist/write/tag-refs.js +126 -0
  207. package/dist/write/tag-refs.js.map +1 -0
  208. package/dist/write/undo.d.ts +74 -5
  209. package/dist/write/undo.js +494 -80
  210. package/dist/write/undo.js.map +1 -1
  211. package/dist/write/vectors/registry.d.ts +17 -1
  212. package/dist/write/vectors/registry.js +18 -2
  213. package/dist/write/vectors/registry.js.map +1 -1
  214. package/dist/write/vectors/types.d.ts +119 -3
  215. package/dist/write/vectors/ui-certification.d.ts +48 -0
  216. package/dist/write/vectors/ui-certification.js +46 -0
  217. package/dist/write/vectors/ui-certification.js.map +1 -0
  218. package/dist/write/vectors/ui-drag.d.ts +188 -0
  219. package/dist/write/vectors/ui-drag.js +1095 -0
  220. package/dist/write/vectors/ui-drag.js.map +1 -0
  221. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  222. package/dist/write/vectors/ui-recipes.js +558 -0
  223. package/dist/write/vectors/ui-recipes.js.map +1 -0
  224. package/dist/write/vectors/ui.d.ts +137 -0
  225. package/dist/write/vectors/ui.js +656 -0
  226. package/dist/write/vectors/ui.js.map +1 -0
  227. package/dist/write/verify/delta.d.ts +31 -3
  228. package/dist/write/verify/delta.js +22 -6
  229. package/dist/write/verify/delta.js.map +1 -1
  230. package/dist/write/verify/poller.js +1 -0
  231. package/dist/write/verify/poller.js.map +1 -1
  232. package/package.json +1 -1
  233. package/dist/cli/exit-codes.d.ts +0 -26
  234. package/dist/cli/exit-codes.js +0 -26
  235. package/dist/cli/exit-codes.js.map +0 -1
  236. package/dist/cli/output.d.ts +0 -42
  237. package/dist/cli/output.js +0 -16
  238. package/dist/cli/output.js.map +0 -1
@@ -13,16 +13,74 @@
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 { DATE_FORMAT, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES } from "../surface-copy.js";
20
- import { capabilitiesTable } from "../write/capabilities.js";
21
- import { OPERATION_KINDS } from "../write/operations.js";
22
- 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, 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";
23
17
  function jsonResult(data) {
24
18
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
25
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
+ }
29
+ /**
30
+ * A read result carrying truncation metadata: the data (already limited) in
31
+ * the first content block, and a second block with the {@link Truncation}
32
+ * numbers plus a one-line note the agent can read when rows were dropped.
33
+ */
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`
37
+ : undefined;
38
+ return {
39
+ content: [
40
+ { type: "text", text: JSON.stringify(omitEmpty(data)) },
41
+ { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
42
+ ],
43
+ };
44
+ }
45
+ /**
46
+ * Grouped read result (anytime/someday): the per-block-truncated sections plus
47
+ * a second block carrying the {@link GroupedTruncation} counts and, when
48
+ * anything was hidden, a one-line note the agent can read.
49
+ */
50
+ function groupedResult(data, grouped) {
51
+ const note = grouped.truncated
52
+ ? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
53
+ : undefined;
54
+ return {
55
+ content: [
56
+ { type: "text", text: JSON.stringify(omitEmpty(data)) },
57
+ { type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
58
+ ],
59
+ };
60
+ }
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
+ /** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
76
+ function resolveLimit(args) {
77
+ return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
78
+ }
79
+ /** Shared limit/all input schema fragment for the flat read tools. */
80
+ const limitShape = {
81
+ limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
82
+ all: z.boolean().optional().describe(ALL_DESC),
83
+ };
26
84
  function errorResult(error) {
27
85
  return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
28
86
  }
@@ -37,14 +95,14 @@ function mutationResult(result) {
37
95
  return jsonResult(result);
38
96
  case "blocked":
39
97
  return errorResult({
40
- code: `blocked:${result.hazard ?? result.reason}`,
98
+ code: blockedCode(result),
41
99
  message: result.detail,
42
100
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
43
101
  remediation: result.remediation,
44
102
  });
45
103
  case "verify-failed":
46
104
  return errorResult({
47
- code: `verify-failed:${result.reason}`,
105
+ code: verifyFailedCode(result),
48
106
  message: result.detail,
49
107
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
50
108
  ...(result.hint !== undefined && { remediation: result.hint }),
@@ -66,19 +124,73 @@ function mutationResult(result) {
66
124
  const READ_ONLY = { readOnlyHint: true };
67
125
  const NON_DESTRUCTIVE = { destructiveHint: false };
68
126
  const DESTRUCTIVE = { destructiveHint: true };
69
- const tagFilterShape = {
127
+ /**
128
+ * The three tag-filter inputs shared by every tag-accepting tool. `tag` is an
129
+ * ARRAY — repeat a tag to AND several together (both keep hierarchy-descendant
130
+ * expansion, which `exact_tag` disables). In FLAT views (read_view, search, and
131
+ * the projects list of list_collections) `tag` honors container inheritance; in
132
+ * the SINGLE-CONTAINER tools (get_project, get_area) it matches a tag carried
133
+ * DIRECTLY on the item — that container's own inherited tags are ignored (every
134
+ * child inherits them, so an inheritance-inclusive match would be vacuous).
135
+ */
136
+ const tagOnlyShape = {
70
137
  tag: z
71
- .string()
138
+ .array(z.string())
139
+ .optional()
140
+ .describe(`Filter by tag (${REF_FORMAT}); repeat to AND several tags. Matches a direct, ` +
141
+ "container-inherited, or descendant tag (in a container tool the container's own " +
142
+ "inherited tags are ignored — the tag must be on the item itself)"),
143
+ exact_tag: z
144
+ .boolean()
145
+ .optional()
146
+ .describe("Match only the named tag(s), not their nested children"),
147
+ untagged: z
148
+ .boolean()
149
+ .optional()
150
+ .describe("Only items with no tag (direct or inherited); not combinable with tag/exact_tag " +
151
+ "(in a container tool: no tag on the item itself, ignoring inherited tags)"),
152
+ };
153
+ const tagFilterShape = {
154
+ ...tagOnlyShape,
155
+ overdue: z
156
+ .boolean()
72
157
  .optional()
73
- .describe(`Filter by tag (${REF_FORMAT}); includes items carrying any nested child tag`),
74
- exact_tag: z.boolean().optional().describe("Match only the named tag, not its nested children"),
158
+ .describe("Only open items past their deadline (due today is not overdue)"),
75
159
  };
160
+ /**
161
+ * Map the MCP tool's snake_case tag inputs onto the shared {@link TagPresence}
162
+ * shape (canonical CLI-flag spelling) so the one set of contract predicates —
163
+ * {@link hasTagPresence}, {@link tagFlagConflict}, {@link tagFilterFields},
164
+ * {@link validateViewArgs} — serves both surfaces. The only difference is
165
+ * `exact_tag` → `exactTag`.
166
+ */
167
+ function tagPresence(args) {
168
+ return {
169
+ ...(args.tag !== undefined && { tag: args.tag }),
170
+ ...(args.exact_tag !== undefined && { exactTag: args.exact_tag }),
171
+ ...(args.untagged !== undefined && { untagged: args.untagged }),
172
+ };
173
+ }
174
+ /** The MCP-voiced usage copy for the tag-filter mutual-exclusivity conflict. */
175
+ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
76
176
  const dryRunShape = {
77
177
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
78
178
  };
179
+ /** How a tag value may be expressed on any tag-accepting tool. */
180
+ const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
181
+ /** create_tags param, shared by every tag-accepting write tool. */
182
+ const createTagsShape = {
183
+ create_tags: z
184
+ .boolean()
185
+ .optional()
186
+ .describe("Create any named tag that does not exist yet (nesting parent/child) before applying, " +
187
+ "instead of stopping on an unknown tag"),
188
+ };
79
189
  const containerRef = (ref) => ({ uuid: ref, title: ref });
80
190
  /** Cap on project titles inlined into the server instructions. */
81
191
  const INSTRUCTIONS_MAX_PROJECTS = 100;
192
+ /** A tag's display label: nested tags show `parent > child`. */
193
+ const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`;
82
194
  /**
83
195
  * Live-inventory preamble: conventions plus the user's actual areas, tags,
84
196
  * and open projects, read once at server start so models can reference real
@@ -103,13 +215,13 @@ function buildInstructions(getClient) {
103
215
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
104
216
  "Operations with cascading or permanent effects require the explicit confirmation " +
105
217
  "parameter named in their description; refused calls return an error saying what to pass.",
218
+ `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
106
219
  ];
107
220
  try {
108
221
  const c = getClient();
109
222
  const areas = c.read.areas();
110
223
  const tags = c.read.tags();
111
224
  const projects = c.read.projects();
112
- const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
113
225
  const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
114
226
  const overflow = projects.length - shown.length;
115
227
  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"}` +
@@ -126,6 +238,24 @@ function buildInstructions(getClient) {
126
238
  function checklistTarget(args) {
127
239
  return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
128
240
  }
241
+ /**
242
+ * Derive the audit author for a client's writes from the MCP initialize
243
+ * handshake's clientInfo.name: lowercased, every run of non-alphanumerics
244
+ * collapsed to a single "-", leading/trailing dashes trimmed, capped at 32
245
+ * characters. An absent (or empty-after-sanitizing) client name falls back to
246
+ * the bare "mcp". The result is never caller-settable — it is the connecting
247
+ * client's own identity, so each client's writes are attributed to it.
248
+ */
249
+ const MCP_ACTOR_PREFIX = "mcp";
250
+ function deriveMcpActor(clientName) {
251
+ const slug = (clientName ?? "")
252
+ .toLowerCase()
253
+ .replaceAll(/[^a-z0-9]+/g, "-")
254
+ .replaceAll(/^-+|-+$/g, "")
255
+ .slice(0, 32)
256
+ .replaceAll(/-+$/g, "");
257
+ return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
258
+ }
129
259
  export function createThingsMcpServer(options = {}) {
130
260
  // One lazily-opened client for the server's lifetime; SQLite read
131
261
  // snapshots are per-statement, so fresh reads see external commits.
@@ -138,26 +268,70 @@ export function createThingsMcpServer(options = {}) {
138
268
  return client;
139
269
  };
140
270
  const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
271
+ // The audit author for every write on this connection, derived once from the
272
+ // client's handshake identity (clientInfo.name). Read per call: clientInfo is
273
+ // populated when the initialize handshake completes, before any tool can run.
274
+ // Not caller-settable — no tool argument overrides it.
275
+ const mcpActor = () => deriveMcpActor(server.server.getClientVersion()?.name);
276
+ /** Translate the shared MCP write-tool args into pipeline WriteOptions. */
277
+ const writeOptions = (args) => ({
278
+ actor: mcpActor(),
279
+ ...(options.maxDisruption !== undefined && { maxDisruption: options.maxDisruption }),
280
+ ...(args.dry_run === true && { dryRun: true }),
281
+ ...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
282
+ ...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
283
+ ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
284
+ ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
285
+ ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
286
+ ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
287
+ ...(args.create_tags === true && { createTags: true }),
288
+ });
141
289
  /** Run a handler, mapping environment/usage throws to tool errors. */
142
290
  const guard = async (fn) => {
143
291
  try {
144
292
  return await fn();
145
293
  }
146
294
  catch (err) {
295
+ // An unresolved reference (ambiguous or not-found uuid/partial-uuid/name)
296
+ // carries machine-readable candidates so a consumer can disambiguate as
297
+ // data, not by re-parsing the prose message.
298
+ if (err instanceof ReferenceResolutionError) {
299
+ return errorResult({
300
+ code: err.code,
301
+ message: err.message,
302
+ details: { candidates: err.candidates },
303
+ });
304
+ }
147
305
  const message = err instanceof Error ? err.message : String(err);
148
306
  const code = err instanceof RangeError ? "usage" : "environment";
149
307
  return errorResult({ code, message });
150
308
  }
151
309
  };
152
- const writeOptions = (args) => ({
153
- actor: "mcp",
154
- ...(args.dry_run === true && { dryRun: true }),
155
- ...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
156
- ...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
157
- ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
158
- ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
159
- ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
160
- });
310
+ /**
311
+ * A read handler wrapper: run through {@link guard}, then — on a successful
312
+ * result append a meta block carrying any non-blocking schema warning (the
313
+ * same note the CLI prints), so a consumer sees when the Things database no
314
+ * longer matches the validated schema and its data may be incomplete. No
315
+ * block is added when the schema checks out or the read itself errored.
316
+ */
317
+ const readGuard = async (fn) => {
318
+ const result = await guard(fn);
319
+ if (result.isError === true)
320
+ return result;
321
+ let warnings = [];
322
+ try {
323
+ warnings = schemaWarnings(getClient().schemaStatus());
324
+ }
325
+ catch {
326
+ warnings = [];
327
+ }
328
+ if (warnings.length === 0)
329
+ return result;
330
+ return {
331
+ ...result,
332
+ content: [...result.content, { type: "text", text: JSON.stringify({ meta: { warnings } }) }],
333
+ };
334
+ };
161
335
  /** Resolve a uuid to to-do/project for the type-generic item tools. */
162
336
  const itemType = (uuid) => {
163
337
  const item = getClient().read.byUuid(uuid);
@@ -173,16 +347,29 @@ export function createThingsMcpServer(options = {}) {
173
347
  description: "Read a Things list as the app presents it: today (split into Today and This " +
174
348
  "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
175
349
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
176
- "anytime/someday return sidebar-ordered sections (area + items; null area = the " +
350
+ "anytime/someday return sections in canonical order (area + items; null area = the " +
177
351
  "top-level block); children of someday/future-scheduled projects are excluded " +
178
- "from anytime — the project row represents them.",
352
+ "from anytime — the project row represents them; someday lists each group's " +
353
+ "project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
354
+ `return at most ${DEFAULT_LIST_LIMIT} items by default (raise with limit); ` +
355
+ "anytime/someday always return every group and cap per block instead — " +
356
+ `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
357
+ `project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
358
+ "all: true lifts every cap; the result's second block reports the counts. " +
359
+ OMIT_EMPTY_NOTE,
179
360
  inputSchema: {
180
361
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
181
362
  ...tagFilterShape,
363
+ evening: z.boolean().optional().describe("today only: show only the This Evening section"),
364
+ show_active_project_items: z
365
+ .union([z.boolean(), z.number().int().min(1)])
366
+ .optional()
367
+ .describe("someday only: include someday to-dos inside active projects, clustered per " +
368
+ "project after each group; a number caps each project's list (true: every item)"),
182
369
  active_project_items: z
183
- .boolean()
370
+ .union([z.boolean(), z.number().int().min(1)])
184
371
  .optional()
185
- .describe("someday only: also list someday to-dos inside active projects"),
372
+ .describe("compatibility alias for show_active_project_items"),
186
373
  horizon: z
187
374
  .number()
188
375
  .int()
@@ -190,42 +377,128 @@ export function createThingsMcpServer(options = {}) {
190
377
  .max(10)
191
378
  .optional()
192
379
  .describe("upcoming only: occurrences shown per repeating item (default 1)"),
193
- limit: z.number().int().min(1).optional().describe("logbook/trash only (default 50)"),
380
+ limit: z
381
+ .number()
382
+ .int()
383
+ .min(1)
384
+ .optional()
385
+ .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
386
+ area_limit: z
387
+ .number()
388
+ .int()
389
+ .min(1)
390
+ .optional()
391
+ .describe(`anytime/someday only: ${AREA_LIMIT_DESC}`),
392
+ project_limit: z
393
+ .number()
394
+ .int()
395
+ .min(1)
396
+ .optional()
397
+ .describe(`anytime only: ${PROJECT_LIMIT_DESC}`),
398
+ all: z
399
+ .boolean()
400
+ .optional()
401
+ .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
194
402
  },
195
403
  annotations: READ_ONLY,
196
- }, async (args) => guard(() => {
404
+ }, async (args) => readGuard(() => {
405
+ // Tag-conflict AND overdue-applicability both derive from the shared
406
+ // contract: read_view honors overdue only on today/inbox/anytime/someday
407
+ // (the current-work views), matching FILTER_CONTRACT.
408
+ const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
409
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
410
+ overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
411
+ overdueStatusWiden: "",
412
+ });
413
+ if (!validated.ok)
414
+ return usage(validated.message);
415
+ // show_active_project_items is the preferred name; active_project_items
416
+ // stays accepted as a compatibility alias.
417
+ const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
418
+ const isGrouped = args.view === "anytime" || args.view === "someday";
419
+ if (isGrouped && args.limit !== undefined) {
420
+ return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
421
+ `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
422
+ }
423
+ if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
424
+ return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
425
+ }
426
+ if (args.view !== "someday" && showActiveProjectItems !== undefined) {
427
+ return usage("show_active_project_items applies only to someday");
428
+ }
429
+ if (args.view !== "today" && args.evening === true) {
430
+ return usage(`evening applies only to today, not ${args.view}`);
431
+ }
432
+ if (args.view === "someday" && args.project_limit !== undefined) {
433
+ return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
434
+ "to cap that section's project lists");
435
+ }
436
+ const limit = resolveLimit(args);
437
+ if (limit === "conflict")
438
+ return usage("pass at most one of limit / all");
439
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
440
+ const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
441
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
442
+ return usage("pass at most one of area_limit/project_limit / all");
443
+ }
197
444
  const c = getClient();
198
- const filter = {
199
- ...(args.tag !== undefined && { tag: args.tag }),
200
- ...(args.exact_tag === true && { exactTag: true }),
201
- };
445
+ const filter = validated.filter;
202
446
  switch (args.view) {
203
- case "today":
204
- return jsonResult(c.read.today(filter));
205
- case "inbox":
206
- return jsonResult(c.read.inbox(filter));
207
- case "anytime":
208
- return jsonResult(c.read.anytime(filter));
209
- case "upcoming":
210
- return jsonResult(c.read.upcoming({
447
+ case "today": {
448
+ const { view, truncation } = c.read.today({
449
+ ...filter,
450
+ ...(args.evening === true && { eveningOnly: true }),
451
+ limit,
452
+ });
453
+ return truncatedResult(view, truncation);
454
+ }
455
+ case "inbox": {
456
+ const { items, truncation } = c.read.inbox({ ...filter, limit });
457
+ return truncatedResult(items, truncation);
458
+ }
459
+ case "anytime": {
460
+ const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
461
+ return groupedResult(view, grouped);
462
+ }
463
+ case "upcoming": {
464
+ const { items, truncation } = c.read.upcoming({
211
465
  ...filter,
212
466
  ...(args.horizon !== undefined && { horizon: args.horizon }),
213
- }));
214
- case "someday":
215
- return jsonResult(c.read.someday({
467
+ limit,
468
+ });
469
+ return truncatedResult(items, truncation);
470
+ }
471
+ case "someday": {
472
+ const active = showActiveProjectItems;
473
+ if (typeof active === "number" && args.all === true) {
474
+ return usage("pass at most one of a numeric show_active_project_items / all");
475
+ }
476
+ const { view, grouped } = c.read.someday({
216
477
  ...filter,
217
- ...(args.active_project_items === true && { activeProjectItems: true }),
218
- }));
219
- case "logbook":
220
- return jsonResult(c.read.logbook({ ...filter, ...(args.limit !== undefined && { limit: args.limit }) }));
221
- case "trash":
222
- return jsonResult(c.read.trash(args.limit !== undefined ? { limit: args.limit } : {}));
478
+ ...((active === true || typeof active === "number") && {
479
+ activeProjectItems: true,
480
+ }),
481
+ areaLimit,
482
+ // true = every item per project; a number caps each list.
483
+ projectLimit: typeof active === "number" ? active : null,
484
+ });
485
+ return groupedResult(view, grouped);
486
+ }
487
+ case "logbook": {
488
+ const { items, truncation } = c.read.logbook({ ...filter, limit });
489
+ return truncatedResult(items, truncation);
490
+ }
491
+ case "trash": {
492
+ const { items, truncation } = c.read.trash({ limit });
493
+ return truncatedResult(items, truncation);
494
+ }
223
495
  }
224
496
  }));
225
497
  server.registerTool("search", {
226
498
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
227
499
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
228
- "references must name existing items.",
500
+ "references must name existing items. " +
501
+ OMIT_EMPTY_NOTE,
229
502
  inputSchema: {
230
503
  query: z.string(),
231
504
  ...tagFilterShape,
@@ -240,72 +513,186 @@ export function createThingsMcpServer(options = {}) {
240
513
  type: z.enum(["to-do", "project"]).optional(),
241
514
  logged: z.boolean().optional().describe("Also include completed/canceled items"),
242
515
  trashed: z.boolean().optional().describe("Also include trashed items"),
243
- all: z.boolean().optional().describe("Everything: open + logged + trashed"),
244
- limit: z.number().int().min(1).optional().describe("Default 50"),
516
+ all: z
517
+ .boolean()
518
+ .optional()
519
+ .describe("Everything, unbounded: open + logged + trashed, no row limit"),
520
+ limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
245
521
  },
246
522
  annotations: READ_ONLY,
247
- }, async (args) => guard(() => jsonResult(getClient().read.search(args.query, {
248
- ...(args.tag !== undefined && { tag: args.tag }),
249
- ...(args.exact_tag === true && { exactTag: true }),
250
- ...(args.project !== undefined && { project: args.project }),
251
- ...(args.area !== undefined && { area: args.area }),
252
- ...(args.type !== undefined && { type: args.type }),
253
- ...(args.logged === true && { logged: true }),
254
- ...(args.trashed === true && { trashed: true }),
255
- ...(args.all === true && { all: true }),
256
- ...(args.limit !== undefined && { limit: args.limit }),
257
- }))));
523
+ }, async (args) => readGuard(() => {
524
+ // Tag-conflict AND the overdue/status-widening incompatibility both
525
+ // derive from the shared contract (search: statusWidening = true).
526
+ const validated = validateViewArgs("search", {
527
+ ...tagPresence(args),
528
+ overdue: args.overdue,
529
+ logged: args.logged,
530
+ trashed: args.trashed,
531
+ all: args.all,
532
+ }, {
533
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
534
+ overdueRejected: "overdue does not apply to search",
535
+ overdueStatusWiden: "overdue lists open items; it does not combine with logged/trashed/all",
536
+ });
537
+ if (!validated.ok)
538
+ return usage(validated.message);
539
+ const limit = resolveLimit(args);
540
+ if (limit === "conflict")
541
+ return usage("pass at most one of limit / all");
542
+ const { items, truncation } = getClient().read.search(args.query, {
543
+ limit,
544
+ ...validated.filter,
545
+ ...(args.project !== undefined && { project: args.project }),
546
+ ...(args.area !== undefined && { area: args.area }),
547
+ ...(args.type !== undefined && { type: args.type }),
548
+ ...(args.logged === true && { logged: true }),
549
+ ...(args.trashed === true && { trashed: true }),
550
+ ...(args.all === true && { all: true }),
551
+ });
552
+ return truncatedResult(items, truncation);
553
+ }));
258
554
  server.registerTool("changes_since", {
259
555
  description: "List items created or modified since a moment — including trashed, logged, and " +
260
556
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
261
- "areas, and checklist items do not mark the containing item as modified.",
557
+ "areas, and checklist items do not mark the containing item as modified. " +
558
+ OMIT_EMPTY_NOTE,
262
559
  inputSchema: {
263
560
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
264
- limit: z.number().int().min(1).optional().describe("Default 200"),
561
+ ...limitShape,
265
562
  },
266
563
  annotations: READ_ONLY,
267
- }, async (args) => guard(() => {
564
+ }, async (args) => readGuard(() => {
565
+ const limit = resolveLimit(args);
566
+ if (limit === "conflict")
567
+ return usage("pass at most one of limit / all");
268
568
  const since = new Date(args.since);
269
569
  if (Number.isNaN(since.getTime())) {
270
570
  return usage(`since is not a parseable date: ${args.since}`);
271
571
  }
272
- return jsonResult(getClient().read.changes({
273
- since,
274
- ...(args.limit !== undefined && { limit: args.limit }),
275
- }));
572
+ const { items, truncation } = getClient().read.changes({ since, limit });
573
+ return truncatedResult(items, truncation);
276
574
  }));
277
575
  server.registerTool("get_item", {
278
576
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
279
577
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
280
- "project/area/heading.",
578
+ "project/area/heading. " +
579
+ OMIT_EMPTY_NOTE,
281
580
  inputSchema: { uuid: z.string() },
282
581
  annotations: READ_ONLY,
283
- }, async (args) => guard(() => {
582
+ }, async (args) => readGuard(() => {
284
583
  const item = getClient().read.byUuid(args.uuid);
285
584
  return item === null
286
- ? errorResult({ code: "not-found", message: `no record with uuid ${args.uuid}` })
287
- : jsonResult(item);
585
+ ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
586
+ : readResult(item);
288
587
  }));
289
588
  server.registerTool("get_project", {
290
- description: "One project's full contents: metadata plus its to-dos grouped under their headings.",
291
- inputSchema: { uuid: z.string().describe("Project uuid or unique name") },
589
+ description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
590
+ "The tag filters keep only the child to-dos matching by their own tags (a heading left " +
591
+ "with none is dropped). " +
592
+ OMIT_EMPTY_NOTE,
593
+ inputSchema: {
594
+ uuid: z.string().describe("Project uuid or unique name"),
595
+ ...tagOnlyShape,
596
+ overdue: z
597
+ .boolean()
598
+ .optional()
599
+ .describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
600
+ },
292
601
  annotations: READ_ONLY,
293
- }, async (args) => guard(() => jsonResult(getClient().read.projectView(args.uuid))));
602
+ }, async (args) => readGuard(() => {
603
+ if (tagFlagConflict(tagPresence(args)))
604
+ return usage(MCP_UNTAGGED_CONFLICT);
605
+ return readResult(getClient().read.projectView(args.uuid, {
606
+ overdue: args.overdue === true,
607
+ ...tagFilterFields(tagPresence(args)),
608
+ }));
609
+ }));
294
610
  server.registerTool("get_area", {
295
- description: "One area's full contents: metadata plus its direct to-dos (active first), its " +
296
- "projects in sidebar order, later (scheduled/repeating/someday), and logged items.",
297
- inputSchema: { ref: z.string().describe("Area uuid or unique name") },
611
+ description: "One area's contents: metadata plus its direct to-dos (active first), its " +
612
+ "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
613
+ `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
614
+ "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
615
+ "second result block reports the counts. " +
616
+ OMIT_EMPTY_NOTE,
617
+ inputSchema: {
618
+ ref: z.string().describe("Area uuid or unique name"),
619
+ ...tagOnlyShape,
620
+ area_limit: z
621
+ .number()
622
+ .int()
623
+ .min(1)
624
+ .optional()
625
+ .describe(`maximum direct to-dos to return (default ${AREA_PREVIEW_LIMIT})`),
626
+ project_limit: z
627
+ .number()
628
+ .int()
629
+ .min(1)
630
+ .optional()
631
+ .describe(`maximum project rows to return (default ${AREA_PREVIEW_LIMIT})`),
632
+ overdue: z
633
+ .boolean()
634
+ .optional()
635
+ .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"),
636
+ all: z.boolean().optional().describe("return both sections in full (no caps)"),
637
+ },
298
638
  annotations: READ_ONLY,
299
- }, async (args) => guard(() => jsonResult(getClient().read.areaView(args.ref))));
639
+ }, async (args) => readGuard(() => {
640
+ if (tagFlagConflict(tagPresence(args)))
641
+ return usage(MCP_UNTAGGED_CONFLICT);
642
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
643
+ const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
644
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
645
+ return usage("pass at most one of area_limit/project_limit / all");
646
+ }
647
+ const { view, grouped } = getClient().read.areaView(args.ref, {
648
+ overdue: args.overdue === true,
649
+ ...tagFilterFields(tagPresence(args)),
650
+ areaLimit,
651
+ projectLimit,
652
+ });
653
+ return groupedResult(view, grouped);
654
+ }));
300
655
  server.registerTool("list_collections", {
301
656
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
302
- "refresh the inventory summarized in the server instructions.",
303
- inputSchema: { kind: z.enum(["projects", "areas", "tags"]) },
657
+ "refresh the inventory summarized in the server instructions. The tag filters scope " +
658
+ "the projects list by each project's own tags (areas/tags reject them). " +
659
+ OMIT_EMPTY_NOTE,
660
+ inputSchema: {
661
+ kind: z.enum(["projects", "areas", "tags"]),
662
+ ...tagOnlyShape,
663
+ overdue: z
664
+ .boolean()
665
+ .optional()
666
+ .describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
667
+ },
304
668
  annotations: READ_ONLY,
305
- }, async (args) => guard(() => {
669
+ }, async (args) => readGuard(() => {
306
670
  const c = getClient();
307
- return jsonResult(args.kind === "projects"
308
- ? c.read.projects()
671
+ // areas/tags are not dated entities and have no per-row tag list to
672
+ // filter — overdue and the tag filters are vacuous there, rejected
673
+ // fail-closed (the same style read_view uses for the wrong views). The
674
+ // decision derives from the contract: only the `projects` list carries
675
+ // a deadline (overdue) and per-row (inheritance-inclusive) tags; the
676
+ // `areas` list rejects both, and `tags` has no contract row.
677
+ const kindSpec = args.kind === "projects"
678
+ ? FILTER_CONTRACT.projects
679
+ : args.kind === "areas"
680
+ ? FILTER_CONTRACT.areas
681
+ : null;
682
+ if (args.overdue === true && (kindSpec === null || !kindSpec.overdue)) {
683
+ return usage(`overdue applies only to projects, not ${args.kind}`);
684
+ }
685
+ if ((kindSpec === null || kindSpec.tag === "rejected") &&
686
+ (hasTagPresence(tagPresence(args)) || args.untagged === true)) {
687
+ return usage(`the tag filters apply only to projects, not ${args.kind}`);
688
+ }
689
+ if (tagFlagConflict(tagPresence(args)))
690
+ return usage(MCP_UNTAGGED_CONFLICT);
691
+ return readResult(args.kind === "projects"
692
+ ? c.read.projects({
693
+ overdue: args.overdue === true,
694
+ ...tagFilterFields(tagPresence(args)),
695
+ })
309
696
  : args.kind === "areas"
310
697
  ? c.read.areas()
311
698
  : c.read.tags());
@@ -315,7 +702,7 @@ export function createThingsMcpServer(options = {}) {
315
702
  server.registerTool("add_todo", {
316
703
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
317
704
  "deadline, tag it, give it a checklist, and place it in a project or area " +
318
- "(optionally under an existing heading). Tags must name existing tags. A reminder " +
705
+ "(optionally under an existing heading). A reminder " +
319
706
  "requires when = today, evening, or a date. Adding into a completed or canceled " +
320
707
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
321
708
  inputSchema: {
@@ -324,7 +711,7 @@ export function createThingsMcpServer(options = {}) {
324
711
  when: whenSchema,
325
712
  reminder: z.string().optional().describe(REMINDER_FORMAT),
326
713
  deadline: z.string().optional().describe(DATE_FORMAT),
327
- tags: z.array(z.string()).optional().describe("Existing tag names"),
714
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
328
715
  checklist_items: z.array(z.string()).optional(),
329
716
  project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
330
717
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
@@ -333,21 +720,29 @@ export function createThingsMcpServer(options = {}) {
333
720
  .boolean()
334
721
  .optional()
335
722
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
723
+ ...createTagsShape,
336
724
  ...dryRunShape,
337
725
  },
338
726
  annotations: NON_DESTRUCTIVE,
339
- }, async (args) => guard(async () => mutationResult(await getClient().write.addTodo({
340
- title: args.title,
341
- ...(args.notes !== undefined && { notes: args.notes }),
342
- ...(args.when !== undefined && { when: args.when }),
343
- ...(args.reminder !== undefined && { reminder: args.reminder }),
344
- ...(args.deadline !== undefined && { deadline: args.deadline }),
345
- ...(args.tags !== undefined && { tags: args.tags }),
346
- ...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
347
- ...(args.project !== undefined && { project: containerRef(args.project) }),
348
- ...(args.area !== undefined && { area: containerRef(args.area) }),
349
- ...(args.heading !== undefined && { heading: args.heading }),
350
- }, writeOptions(args)))));
727
+ }, async (args) => guard(async () => {
728
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
729
+ if (sugar.kind === "error")
730
+ return usage(sugar.message);
731
+ const when = sugar.kind === "split" ? sugar.when : args.when;
732
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
733
+ return mutationResult(await getClient().write.addTodo({
734
+ title: args.title,
735
+ ...(args.notes !== undefined && { notes: args.notes }),
736
+ ...(when !== undefined && { when: when }),
737
+ ...(reminder !== undefined && { reminder }),
738
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
739
+ ...(args.tags !== undefined && { tags: args.tags }),
740
+ ...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
741
+ ...(args.project !== undefined && { project: containerRef(args.project) }),
742
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
743
+ ...(args.heading !== undefined && { heading: args.heading }),
744
+ }, writeOptions(args)));
745
+ }));
351
746
  server.registerTool("update_todo", {
352
747
  description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
353
748
  "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
@@ -381,13 +776,18 @@ export function createThingsMcpServer(options = {}) {
381
776
  if (args.deadline !== undefined && args.clear_deadline === true) {
382
777
  return usage("pass at most one of deadline / clear_deadline");
383
778
  }
779
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
780
+ if (sugar.kind === "error")
781
+ return usage(sugar.message);
782
+ const when = sugar.kind === "split" ? sugar.when : args.when;
783
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
384
784
  return mutationResult(await getClient().write.updateTodo(args.uuid, {
385
785
  ...(args.title !== undefined && { title: args.title }),
386
786
  ...(args.notes !== undefined && { notes: args.notes }),
387
787
  ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
388
788
  ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
389
- ...(args.when !== undefined && { when: args.when }),
390
- ...(args.reminder !== undefined && { reminder: args.reminder }),
789
+ ...(when !== undefined && { when: when }),
790
+ ...(reminder !== undefined && { reminder }),
391
791
  ...(args.clear_reminder === true && { reminder: null }),
392
792
  ...(args.deadline !== undefined && { deadline: args.deadline }),
393
793
  ...(args.clear_deadline === true && { deadline: null }),
@@ -454,11 +854,12 @@ export function createThingsMcpServer(options = {}) {
454
854
  server.registerTool("set_tags", {
455
855
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
456
856
  "the given list — an empty list removes all tags; mode 'add' merges with the current " +
457
- "tags. Tags must name existing tags (create them first with add_tag).",
857
+ "tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
458
858
  inputSchema: {
459
859
  uuid: z.string(),
460
- tags: z.array(z.string()).describe("Existing tag names"),
860
+ tags: z.array(z.string()).describe(`Tags ${TAG_REF_FORMAT}`),
461
861
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
862
+ ...createTagsShape,
462
863
  ...dryRunShape,
463
864
  },
464
865
  annotations: NON_DESTRUCTIVE,
@@ -683,6 +1084,265 @@ export function createThingsMcpServer(options = {}) {
683
1084
  ? jsonResult(r)
684
1085
  : mutationResult(r.heading);
685
1086
  }));
1087
+ // -------------------------------------------- GUI-driven (Accessibility)
1088
+ const driveGuiShape = {
1089
+ dangerously_drive_gui: z
1090
+ .boolean()
1091
+ .optional()
1092
+ .describe("Required: this drives the local Things app through its accessibility interface to " +
1093
+ "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
1094
+ "on the machine running this server, and must be turned on first with `things config " +
1095
+ "set ui-enabled true`. Intended for a dedicated always-on Mac."),
1096
+ };
1097
+ const WEEKDAY_ENUM = z.enum([
1098
+ "sunday",
1099
+ "monday",
1100
+ "tuesday",
1101
+ "wednesday",
1102
+ "thursday",
1103
+ "friday",
1104
+ "saturday",
1105
+ ]);
1106
+ // The base rule (also used by create_repeating_project, which stays minimal —
1107
+ // its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
1108
+ const baseRepeatShape = {
1109
+ frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
1110
+ interval: z.number().int().min(1).max(99).describe("Every N units (1–99)"),
1111
+ };
1112
+ const repeatRuleShape = {
1113
+ ...baseRepeatShape,
1114
+ after_completion: z
1115
+ .boolean()
1116
+ .optional()
1117
+ .describe("Repeat N units AFTER each occurrence is completed, instead of on a fixed schedule"),
1118
+ weekdays: z.array(WEEKDAY_ENUM).optional().describe("Weekly only: the weekdays it repeats on"),
1119
+ monthly_day: z
1120
+ .union([z.number().int(), z.literal("last")])
1121
+ .optional()
1122
+ .describe('Monthly/yearly only: a day of the month (1–31, or "last")'),
1123
+ monthly_weekday: WEEKDAY_ENUM.optional().describe("Monthly/yearly only: a weekday for an nth-weekday rule (with monthly_ordinal)"),
1124
+ monthly_ordinal: z
1125
+ .union([z.number().int(), z.literal("last")])
1126
+ .optional()
1127
+ .describe('Monthly/yearly only: which weekday (1–5, or "last") with monthly_weekday'),
1128
+ yearly_month: z
1129
+ .number()
1130
+ .int()
1131
+ .min(1)
1132
+ .max(12)
1133
+ .optional()
1134
+ .describe("Yearly only: the month (1–12)"),
1135
+ ends_after: z.number().int().optional().describe("Stop after N occurrences"),
1136
+ ends_on: z.string().optional().describe("YYYY-MM-DD — stop after this date"),
1137
+ reminder: z.string().optional().describe("HH:mm — a reminder time on each occurrence"),
1138
+ deadline: z.boolean().optional().describe("Give each occurrence a deadline"),
1139
+ start_days_earlier: z
1140
+ .number()
1141
+ .int()
1142
+ .optional()
1143
+ .describe("With deadline: start each occurrence N days before its deadline"),
1144
+ };
1145
+ // oxlint-disable-next-line consistent-function-scoping -- kept beside repeatRuleShape it mirrors
1146
+ const repeatExtras = (a, frequency) => {
1147
+ const fields = {};
1148
+ if (a.after_completion === true)
1149
+ fields.afterCompletion = true;
1150
+ if (a.weekdays !== undefined)
1151
+ fields.weekdays = a.weekdays;
1152
+ const anchor = a.monthly_day !== undefined
1153
+ ? { day: a.monthly_day }
1154
+ : a.monthly_weekday !== undefined || a.monthly_ordinal !== undefined
1155
+ ? { weekday: a.monthly_weekday, ordinal: a.monthly_ordinal }
1156
+ : undefined;
1157
+ if (frequency === "monthly" && anchor !== undefined)
1158
+ fields.monthly = anchor;
1159
+ if (frequency === "yearly" && (a.yearly_month !== undefined || anchor !== undefined)) {
1160
+ fields.yearly = { month: a.yearly_month, ...anchor };
1161
+ }
1162
+ if (a.ends_after !== undefined)
1163
+ fields.ends = { kind: "after", count: a.ends_after };
1164
+ else if (a.ends_on !== undefined)
1165
+ fields.ends = { kind: "on-date", date: a.ends_on };
1166
+ if (a.reminder !== undefined)
1167
+ fields.reminder = a.reminder;
1168
+ if (a.deadline === true)
1169
+ fields.deadline = true;
1170
+ if (a.start_days_earlier !== undefined)
1171
+ fields.startDaysEarlier = a.start_days_earlier;
1172
+ return fields;
1173
+ };
1174
+ server.registerTool("make_repeating", {
1175
+ description: "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new recurring " +
1176
+ "series — the original disappears and a fresh repeating item takes its place, so it " +
1177
+ "cannot be undone. Set the frequency and interval, and optionally the weekday set, " +
1178
+ "monthly/yearly day, end bound, reminders, or deadline. Returns the new item's uuid.",
1179
+ inputSchema: {
1180
+ uuid: z.string().describe("The to-do to make repeating"),
1181
+ ...repeatRuleShape,
1182
+ ...driveGuiShape,
1183
+ ...dryRunShape,
1184
+ },
1185
+ annotations: DESTRUCTIVE,
1186
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.make-repeating", {
1187
+ uuid: args.uuid,
1188
+ frequency: args.frequency,
1189
+ interval: args.interval,
1190
+ ...repeatExtras(args, args.frequency),
1191
+ }, writeOptions(args)))));
1192
+ server.registerTool("reschedule_repeat", {
1193
+ description: "Change a repeating to-do's rule in place, keeping the same item. Set the frequency and " +
1194
+ "interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, or " +
1195
+ "deadline. This can be undone — it restores the previous rule.",
1196
+ inputSchema: {
1197
+ uuid: z.string().describe("The repeating to-do to reschedule"),
1198
+ ...repeatRuleShape,
1199
+ ...driveGuiShape,
1200
+ ...dryRunShape,
1201
+ },
1202
+ annotations: NON_DESTRUCTIVE,
1203
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.reschedule-repeat", {
1204
+ uuid: args.uuid,
1205
+ frequency: args.frequency,
1206
+ interval: args.interval,
1207
+ ...repeatExtras(args, args.frequency),
1208
+ }, writeOptions(args)))));
1209
+ server.registerTool("set_repeat_state", {
1210
+ description: "Pause or resume a repeating to-do. 'pause' stops it spawning new occurrences but keeps " +
1211
+ "its rule; 'resume' starts it again. The two are inverses of each other.",
1212
+ inputSchema: {
1213
+ uuid: z.string().describe("The repeating to-do"),
1214
+ state: z.enum(["pause", "resume"]),
1215
+ ...driveGuiShape,
1216
+ ...dryRunShape,
1217
+ },
1218
+ annotations: NON_DESTRUCTIVE,
1219
+ }, async (args) => guard(async () => {
1220
+ const op = args.state === "pause" ? "todo.pause-repeat" : "todo.resume-repeat";
1221
+ return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1222
+ }));
1223
+ server.registerTool("reschedule_project_repeat", {
1224
+ description: "Change a repeating project's rule in place, keeping the same project. Set the frequency " +
1225
+ "and interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, " +
1226
+ "or deadline. This can be undone — it restores the previous rule.",
1227
+ inputSchema: {
1228
+ uuid: z.string().describe(`The repeating project to reschedule (${REF_FORMAT})`),
1229
+ ...repeatRuleShape,
1230
+ ...driveGuiShape,
1231
+ ...dryRunShape,
1232
+ },
1233
+ annotations: NON_DESTRUCTIVE,
1234
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("project.reschedule-repeat", {
1235
+ uuid: args.uuid,
1236
+ frequency: args.frequency,
1237
+ interval: args.interval,
1238
+ ...repeatExtras(args, args.frequency),
1239
+ }, writeOptions(args)))));
1240
+ server.registerTool("set_project_repeat_state", {
1241
+ description: "Pause or resume a repeating project. 'pause' stops it spawning new occurrences but keeps " +
1242
+ "its rule; 'resume' starts it again. The two are inverses of each other.",
1243
+ inputSchema: {
1244
+ uuid: z.string().describe(`The repeating project (${REF_FORMAT})`),
1245
+ state: z.enum(["pause", "resume"]),
1246
+ ...driveGuiShape,
1247
+ ...dryRunShape,
1248
+ },
1249
+ annotations: NON_DESTRUCTIVE,
1250
+ }, async (args) => guard(async () => {
1251
+ const op = args.state === "pause" ? "project.pause-repeat" : "project.resume-repeat";
1252
+ return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1253
+ }));
1254
+ server.registerTool("reorder_area", {
1255
+ description: "Move an area to a new position in the area order. Give the area plus exactly one " +
1256
+ "destination: before/after another area, or position first/last. The move is made by " +
1257
+ "driving the Things window with the pointer — the app comes to the front and the " +
1258
+ "sidebar may scroll while the area is dragged; the area's projects and to-dos are " +
1259
+ "untouched. Area references are a uuid or a unique name.",
1260
+ inputSchema: {
1261
+ target: z.string().describe("The area to move (uuid or unique name)"),
1262
+ before: z
1263
+ .string()
1264
+ .optional()
1265
+ .describe("Place it immediately above this area (uuid or unique name)"),
1266
+ after: z
1267
+ .string()
1268
+ .optional()
1269
+ .describe("Place it immediately below this area (uuid or unique name)"),
1270
+ position: z
1271
+ .enum(["first", "last"])
1272
+ .optional()
1273
+ .describe("Move it to the top or bottom of the area list"),
1274
+ ...driveGuiShape,
1275
+ ...dryRunShape,
1276
+ },
1277
+ annotations: NON_DESTRUCTIVE,
1278
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("area.reorder", {
1279
+ target: args.target,
1280
+ ...(args.before !== undefined && { before: args.before }),
1281
+ ...(args.after !== undefined && { after: args.after }),
1282
+ ...(args.position !== undefined && { position: args.position }),
1283
+ }, writeOptions(args)))));
1284
+ server.registerTool("make_project_repeating", {
1285
+ description: "Turn an existing project into a repeating one. This REPLACES the project with a new " +
1286
+ "recurring series — the original disappears and a fresh repeating project takes its place " +
1287
+ "(its area is kept), so it cannot be undone. An area-less project scheduled for Anytime is " +
1288
+ "moved to Someday first (a cleanup-free intermediate step, shown by dry_run). Set the " +
1289
+ "frequency and interval, and optionally the weekday set, monthly/yearly day, end bound, " +
1290
+ "reminders, or deadline. Returns the new project's uuid.",
1291
+ inputSchema: {
1292
+ uuid: z.string().describe(`The project to make repeating (${REF_FORMAT})`),
1293
+ ...repeatRuleShape,
1294
+ ...driveGuiShape,
1295
+ ...dryRunShape,
1296
+ },
1297
+ annotations: DESTRUCTIVE,
1298
+ }, async (args) => guard(async () => mutationResult(await getClient().write.makeRepeatingProject(args.uuid, {
1299
+ frequency: args.frequency,
1300
+ interval: args.interval,
1301
+ ...repeatExtras(args, args.frequency),
1302
+ }, writeOptions(args)))));
1303
+ server.registerTool("create_repeating_project", {
1304
+ description: "Create a project and make it repeating in one call. TWO operations: the project is " +
1305
+ "created first and PERSISTS even if the make-repeating step refuses; then it is promoted " +
1306
+ "(which drives the GUI). Give an area to place it, or omit it to create in Someday. Only a " +
1307
+ "frequency and an interval are supported. Returns the new repeating project's uuid.",
1308
+ inputSchema: {
1309
+ title: z.string(),
1310
+ notes: z.string().optional(),
1311
+ area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1312
+ deadline: z.string().optional().describe(DATE_FORMAT),
1313
+ todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1314
+ ...baseRepeatShape,
1315
+ ...driveGuiShape,
1316
+ ...dryRunShape,
1317
+ },
1318
+ annotations: DESTRUCTIVE,
1319
+ }, async (args) => guard(async () => mutationResult(await getClient().write.createRepeatingProject({
1320
+ title: args.title,
1321
+ ...(args.notes !== undefined && { notes: args.notes }),
1322
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1323
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
1324
+ ...(args.todos !== undefined && { todos: args.todos }),
1325
+ frequency: args.frequency,
1326
+ interval: args.interval,
1327
+ }, writeOptions(args)))));
1328
+ server.registerTool("convert_to_project", {
1329
+ description: "Convert a to-do or a heading into a project. This REPLACES the original with a new " +
1330
+ "project (a converted to-do keeps its notes; a converted heading is promoted alongside " +
1331
+ "its project and its to-dos move under the new project). The original is gone and this " +
1332
+ "cannot be undone. Returns the new project's uuid.",
1333
+ inputSchema: {
1334
+ uuid: z.string().describe("The to-do or heading to convert"),
1335
+ ...driveGuiShape,
1336
+ ...dryRunShape,
1337
+ },
1338
+ annotations: DESTRUCTIVE,
1339
+ }, async (args) => guard(async () => {
1340
+ const item = getClient().read.byUuid(args.uuid);
1341
+ if (item === null)
1342
+ throw new RangeError(`no item with uuid ${args.uuid}`);
1343
+ const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
1344
+ return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1345
+ }));
686
1346
  server.registerTool("duplicate_item", {
687
1347
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
688
1348
  "includes its children. Not available for repeating items.",
@@ -723,7 +1383,7 @@ export function createThingsMcpServer(options = {}) {
723
1383
  "clear_reminder works while the project is scheduled for today or this evening; a " +
724
1384
  "reminder on a future date can only be changed, not cleared.",
725
1385
  inputSchema: {
726
- uuid: z.string(),
1386
+ uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
727
1387
  title: z.string().optional(),
728
1388
  notes: z.string().optional().describe("Replaces the whole notes body"),
729
1389
  append_notes: z.string().optional(),
@@ -767,7 +1427,7 @@ export function createThingsMcpServer(options = {}) {
767
1427
  "canceled project; its children stay completed/canceled unless restore_children " +
768
1428
  "also reopens the ones that were resolved together with the project.",
769
1429
  inputSchema: {
770
- uuid: z.string().describe("Project uuid"),
1430
+ uuid: z.string().describe(`The project (${REF_FORMAT})`),
771
1431
  status: z.enum(["completed", "canceled", "open"]),
772
1432
  children: z
773
1433
  .enum(["require-resolved", "auto-complete", "auto-cancel"])
@@ -812,7 +1472,7 @@ export function createThingsMcpServer(options = {}) {
812
1472
  description: "Move a project into an area, or detach it from its current area. Pass exactly " +
813
1473
  "one of area / detach. The project's status and schedule are unaffected.",
814
1474
  inputSchema: {
815
- uuid: z.string().describe("Project uuid"),
1475
+ uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
816
1476
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
817
1477
  detach: z.boolean().optional().describe("Remove the current area assignment"),
818
1478
  ...dryRunShape,
@@ -829,20 +1489,26 @@ export function createThingsMcpServer(options = {}) {
829
1489
  }));
830
1490
  // ----------------------------------------------------------------- areas
831
1491
  server.registerTool("add_area", {
832
- description: "Create an area. Tags, when given, must name existing tags.",
1492
+ description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
833
1493
  inputSchema: {
834
1494
  title: z.string(),
835
- tags: z.array(z.string()).optional().describe("Existing tag names"),
1495
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
1496
+ ...createTagsShape,
836
1497
  ...dryRunShape,
837
1498
  },
838
1499
  annotations: NON_DESTRUCTIVE,
839
1500
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
840
1501
  server.registerTool("update_area", {
841
- description: "Rename an area and/or replace its tags (the full set; tags must name existing tags).",
1502
+ description: "Rename an area and/or replace its tags (the full set). Tags must exist unless " +
1503
+ "create_tags is set.",
842
1504
  inputSchema: {
843
1505
  target: z.string().describe(`Area to update (${REF_FORMAT})`),
844
1506
  title: z.string().optional().describe("New name"),
845
- tags: z.array(z.string()).optional().describe("Existing tag names (full replacement)"),
1507
+ tags: z
1508
+ .array(z.string())
1509
+ .optional()
1510
+ .describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
1511
+ ...createTagsShape,
846
1512
  ...dryRunShape,
847
1513
  },
848
1514
  annotations: NON_DESTRUCTIVE,
@@ -969,10 +1635,10 @@ export function createThingsMcpServer(options = {}) {
969
1635
  params: z.record(z.string(), z.unknown()),
970
1636
  options: z
971
1637
  .object({
972
- acknowledgeChecklistReset: z.boolean().optional(),
973
- acknowledgeProjectReopen: z.boolean().optional(),
974
- dangerouslyPermanent: z.boolean().optional(),
975
- acknowledgeTagSubtree: z.boolean().optional(),
1638
+ acknowledge_checklist_reset: z.boolean().optional(),
1639
+ acknowledge_project_reopen: z.boolean().optional(),
1640
+ dangerously_permanent: z.boolean().optional(),
1641
+ acknowledge_tag_subtree: z.boolean().optional(),
976
1642
  })
977
1643
  .optional(),
978
1644
  }))
@@ -982,10 +1648,29 @@ export function createThingsMcpServer(options = {}) {
982
1648
  },
983
1649
  annotations: DESTRUCTIVE,
984
1650
  }, async (args) => guard(async () => {
985
- const results = await getClient().write.batch(args.ops, {
1651
+ // Map each op's snake_case acknowledgements into the batch engine's
1652
+ // option names, and apply the process-wide disruption ceiling — batch
1653
+ // takes it per-op, and MCP exposes no per-op override, so it is uniform.
1654
+ const ceiling = options.maxDisruption;
1655
+ const ops = args.ops.map((op) => {
1656
+ const o = op.options;
1657
+ const opts = {
1658
+ ...(o?.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
1659
+ ...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
1660
+ ...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
1661
+ ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
1662
+ ...(ceiling !== undefined && { maxDisruption: ceiling }),
1663
+ };
1664
+ return {
1665
+ op: op.op,
1666
+ params: op.params,
1667
+ ...(Object.keys(opts).length > 0 && { options: opts }),
1668
+ };
1669
+ });
1670
+ const results = await getClient().write.batch(ops, {
986
1671
  ...(args.dry_run === true && { dryRun: true }),
987
1672
  ...(args.fail_fast === true && { failFast: true }),
988
- actor: "mcp",
1673
+ actor: mcpActor(),
989
1674
  });
990
1675
  return jsonResult(results);
991
1676
  }));
@@ -993,7 +1678,7 @@ export function createThingsMcpServer(options = {}) {
993
1678
  description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
994
1679
  "area-less someday projects — one kind per call), a " +
995
1680
  "project's to-dos, a project's headings (scope=headings — children move with " +
996
- "their heading), an area, or the top-level sidebar projects (scope=projects — " +
1681
+ "their heading), an area, or the top-level projects (scope=projects — " +
997
1682
  "each project takes a brief someday/anytime round-trip) — the given uuids move " +
998
1683
  "to the TOP in the given order; unlisted items keep their relative order below. " +
999
1684
  "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
@@ -1027,24 +1712,59 @@ export function createThingsMcpServer(options = {}) {
1027
1712
  ...(args.strategy !== undefined && { strategy: args.strategy }),
1028
1713
  }, writeOptions(args)))));
1029
1714
  server.registerTool("undo", {
1030
- description: "Undo the last N changes made through this interface, newest first (changes made " +
1031
- "directly in the Things app cannot be undone here). Some changes cannot be " +
1032
- "reversedpermanent deletions, or changes whose prior state is unknown and are " +
1033
- "reported as irreversible; a to-do brought back from an undone delete returns to " +
1034
- "the Inbox without its schedule. Undoing the creation of an area or tag deletes it " +
1035
- "permanentlyrequires dangerously_permanent.",
1715
+ description: "Undo the last N changes, newest first (changes made directly in the Things app " +
1716
+ "cannot be undone here). By default this undoes only changes made through THIS " +
1717
+ "connectionthis client's own writes; it will not touch the user's own edits, or " +
1718
+ 'another client\'s, unless you pass by="*" (all authors) or a specific author name; ' +
1719
+ "pass a txn token to undo one exact change. Some changes cannot be reversed " +
1720
+ "permanent deletions, or changes whose prior state is unknown and are reported as " +
1721
+ "irreversible; a to-do brought back from an undone delete returns to the Inbox " +
1722
+ "without its schedule. Undoing the creation of an area or tag deletes it " +
1723
+ "permanently — requires dangerously_permanent. An undo is refused when the item " +
1724
+ "changed outside this interface since (its list or project, status, schedule, " +
1725
+ "trashed state, or a field like the title moved) — pass " +
1726
+ "acknowledge_out_of_band_changes to overwrite it anyway.",
1036
1727
  inputSchema: {
1037
1728
  last: z.number().int().min(1).optional().describe("How many to unwind (default 1)"),
1729
+ by: z
1730
+ .string()
1731
+ .optional()
1732
+ .describe('Whose changes to undo: an exact author name, or "*" for everyone. Defaults to ' +
1733
+ "this client's own writes (only changes made through this connection). Matches " +
1734
+ "exactly. Selects WHICH changes to undo, and never a change already undone. Not " +
1735
+ "combinable with txn."),
1736
+ txn: z
1737
+ .string()
1738
+ .optional()
1739
+ .describe("Undo exactly the one change with this undo token (the undoToken field returned " +
1740
+ "by the mutation); immune to interleaving. Not combinable with last/by."),
1038
1741
  dangerously_permanent: z.boolean().optional(),
1742
+ acknowledge_out_of_band_changes: z
1743
+ .boolean()
1744
+ .optional()
1745
+ .describe("Proceed even when the item changed outside this interface since (in the Things app " +
1746
+ "or by another tool) — overwrites whatever the out-of-band change left, instead of " +
1747
+ "refusing."),
1039
1748
  ...dryRunShape,
1040
1749
  },
1041
1750
  annotations: DESTRUCTIVE,
1042
1751
  }, async (args) => guard(async () => {
1752
+ if (args.txn !== undefined && (args.last !== undefined || args.by !== undefined)) {
1753
+ return usage("txn cannot be combined with last or by");
1754
+ }
1043
1755
  const items = await getClient().write.undo({
1044
1756
  ...(args.last !== undefined && { last: args.last }),
1757
+ ...(args.txn !== undefined && { txn: args.txn }),
1758
+ // Asymmetric default: agents must not clobber the user's own edits (or
1759
+ // another client's) without explicitly opting in via by:"*". Scoped to
1760
+ // this client's own handshake identity, so each session undoes its own.
1761
+ ...(args.txn === undefined && { by: args.by ?? mcpActor() }),
1045
1762
  ...(args.dry_run === true && { dryRun: true }),
1046
1763
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
1047
- actor: "mcp",
1764
+ ...(args.acknowledge_out_of_band_changes === true && {
1765
+ acknowledgeOutOfBandChanges: true,
1766
+ }),
1767
+ actor: mcpActor(),
1048
1768
  });
1049
1769
  return jsonResult(items);
1050
1770
  }));
@@ -1062,8 +1782,10 @@ export function createThingsMcpServer(options = {}) {
1062
1782
  server.registerTool("doctor", {
1063
1783
  description: "Check the environment: whether the Things app and its database are reachable, " +
1064
1784
  "whether changes can be made, any one-time setup still needed (macOS permissions, " +
1065
- "the app's 'Enable Things URLs' setting), and whether the environment changed since " +
1066
- "the last successful write, with steps to fix.",
1785
+ "the app's 'Enable Things URLs' setting), whether the environment changed since " +
1786
+ "the last successful write, and a sync-health summary (whether the app is running, how " +
1787
+ "recently the data changed, and — when a Things Cloud account is attached — the last " +
1788
+ "sync attempt), with steps to fix.",
1067
1789
  inputSchema: {
1068
1790
  probe_automation: z
1069
1791
  .boolean()