things-api 0.9.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 (214) hide show
  1. package/README.md +30 -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 +26 -3
  8. package/dist/cli/commands/area.js +125 -70
  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 +18 -1
  16. package/dist/cli/commands/project.js +69 -31
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +423 -157
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  21. package/dist/cli/commands/repeat-flags.js +76 -0
  22. package/dist/cli/commands/repeat-flags.js.map +1 -0
  23. package/dist/cli/commands/setup.js +2 -3
  24. package/dist/cli/commands/setup.js.map +1 -1
  25. package/dist/cli/commands/show.js +47 -24
  26. package/dist/cli/commands/show.js.map +1 -1
  27. package/dist/cli/commands/todo.d.ts +1 -1
  28. package/dist/cli/commands/todo.js +7 -6
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +378 -148
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +1 -1
  33. package/dist/cli/excess-args.d.ts +15 -0
  34. package/dist/cli/excess-args.js +51 -0
  35. package/dist/cli/excess-args.js.map +1 -0
  36. package/dist/cli/glyphs.d.ts +39 -2
  37. package/dist/cli/glyphs.js +91 -21
  38. package/dist/cli/glyphs.js.map +1 -1
  39. package/dist/cli/help.d.ts +54 -0
  40. package/dist/cli/help.js +355 -0
  41. package/dist/cli/help.js.map +1 -0
  42. package/dist/cli/main.js +32 -25
  43. package/dist/cli/main.js.map +1 -1
  44. package/dist/cli/period.d.ts +7 -0
  45. package/dist/cli/period.js +12 -0
  46. package/dist/cli/period.js.map +1 -1
  47. package/dist/cli/read-driver.d.ts +26 -19
  48. package/dist/cli/read-driver.js +67 -33
  49. package/dist/cli/read-driver.js.map +1 -1
  50. package/dist/cli/render.d.ts +92 -28
  51. package/dist/cli/render.js +318 -97
  52. package/dist/cli/render.js.map +1 -1
  53. package/dist/cli/resolve-invocation.d.ts +42 -4
  54. package/dist/cli/resolve-invocation.js +97 -14
  55. package/dist/cli/resolve-invocation.js.map +1 -1
  56. package/dist/cli/tag-filters.d.ts +59 -0
  57. package/dist/cli/tag-filters.js +57 -0
  58. package/dist/cli/tag-filters.js.map +1 -0
  59. package/dist/cli/verb-hint.d.ts +25 -0
  60. package/dist/cli/verb-hint.js +138 -0
  61. package/dist/cli/verb-hint.js.map +1 -0
  62. package/dist/cli/width.d.ts +135 -0
  63. package/dist/cli/width.js +313 -0
  64. package/dist/cli/width.js.map +1 -0
  65. package/dist/client.d.ts +137 -24
  66. package/dist/client.js +105 -16
  67. package/dist/client.js.map +1 -1
  68. package/dist/config.d.ts +11 -0
  69. package/dist/config.js +3 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/contracts.d.ts +87 -17
  72. package/dist/contracts.js +44 -1
  73. package/dist/contracts.js.map +1 -1
  74. package/dist/db/fingerprint.d.ts +12 -0
  75. package/dist/db/fingerprint.js +15 -1
  76. package/dist/db/fingerprint.js.map +1 -1
  77. package/dist/db/locate.js +1 -1
  78. package/dist/db/locate.js.map +1 -1
  79. package/dist/diagnose.d.ts +63 -0
  80. package/dist/diagnose.js +38 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +30 -4
  83. package/dist/index.js +35 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -1
  86. package/dist/mcp/server.js +691 -133
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +31 -7
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +7 -0
  91. package/dist/model/mappers.js +7 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/serialize.d.ts +28 -0
  94. package/dist/model/serialize.js +80 -0
  95. package/dist/model/serialize.js.map +1 -0
  96. package/dist/model/when-sugar.d.ts +47 -0
  97. package/dist/model/when-sugar.js +45 -0
  98. package/dist/model/when-sugar.js.map +1 -0
  99. package/dist/read/area-view.d.ts +8 -1
  100. package/dist/read/area-view.js +49 -13
  101. package/dist/read/area-view.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +117 -0
  103. package/dist/read/filter-contract.js +78 -0
  104. package/dist/read/filter-contract.js.map +1 -0
  105. package/dist/read/predicates.d.ts +30 -0
  106. package/dist/read/predicates.js +30 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +12 -1
  109. package/dist/read/project-view.js +39 -8
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/queries.d.ts +149 -6
  112. package/dist/read/queries.js +250 -31
  113. package/dist/read/queries.js.map +1 -1
  114. package/dist/read/sections.d.ts +51 -0
  115. package/dist/read/sections.js +37 -0
  116. package/dist/read/sections.js.map +1 -0
  117. package/dist/read/sidebar-order.js +2 -1
  118. package/dist/read/sidebar-order.js.map +1 -1
  119. package/dist/read/tags.d.ts +27 -3
  120. package/dist/read/tags.js +83 -12
  121. package/dist/read/tags.js.map +1 -1
  122. package/dist/read/truncation.d.ts +71 -0
  123. package/dist/read/{pagination.js → truncation.js} +103 -94
  124. package/dist/read/truncation.js.map +1 -0
  125. package/dist/read/views.d.ts +140 -16
  126. package/dist/read/views.js +264 -67
  127. package/dist/read/views.js.map +1 -1
  128. package/dist/surface-copy.d.ts +19 -0
  129. package/dist/surface-copy.js +32 -0
  130. package/dist/surface-copy.js.map +1 -1
  131. package/dist/sync-health.d.ts +78 -0
  132. package/dist/sync-health.js +312 -0
  133. package/dist/sync-health.js.map +1 -0
  134. package/dist/write/accessibility-probe.d.ts +12 -0
  135. package/dist/write/accessibility-probe.js +63 -0
  136. package/dist/write/accessibility-probe.js.map +1 -0
  137. package/dist/write/automation-probe.d.ts +7 -0
  138. package/dist/write/automation-probe.js +8 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/batch.js +4 -2
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/capabilities.d.ts +8 -0
  143. package/dist/write/capabilities.js +13 -6
  144. package/dist/write/capabilities.js.map +1 -1
  145. package/dist/write/commands.d.ts +2 -0
  146. package/dist/write/commands.js +562 -48
  147. package/dist/write/commands.js.map +1 -1
  148. package/dist/write/edit-checklist.js +3 -2
  149. package/dist/write/edit-checklist.js.map +1 -1
  150. package/dist/write/guards.d.ts +2 -7
  151. package/dist/write/guards.js +64 -6
  152. package/dist/write/guards.js.map +1 -1
  153. package/dist/write/heading.js +2 -0
  154. package/dist/write/heading.js.map +1 -1
  155. package/dist/write/lock.d.ts +40 -2
  156. package/dist/write/lock.js +91 -14
  157. package/dist/write/lock.js.map +1 -1
  158. package/dist/write/make-repeating-project.d.ts +4 -0
  159. package/dist/write/make-repeating-project.js +253 -0
  160. package/dist/write/make-repeating-project.js.map +1 -0
  161. package/dist/write/operations.d.ts +145 -1
  162. package/dist/write/operations.js +48 -0
  163. package/dist/write/operations.js.map +1 -1
  164. package/dist/write/pipeline.d.ts +19 -0
  165. package/dist/write/pipeline.js +109 -26
  166. package/dist/write/pipeline.js.map +1 -1
  167. package/dist/write/pre-state.d.ts +68 -3
  168. package/dist/write/pre-state.js +102 -3
  169. package/dist/write/pre-state.js.map +1 -1
  170. package/dist/write/reopen.js +3 -2
  171. package/dist/write/reopen.js.map +1 -1
  172. package/dist/write/reorder.js +27 -17
  173. package/dist/write/reorder.js.map +1 -1
  174. package/dist/write/repeat-rule.d.ts +22 -0
  175. package/dist/write/repeat-rule.js +258 -0
  176. package/dist/write/repeat-rule.js.map +1 -0
  177. package/dist/write/reversibility.js +49 -0
  178. package/dist/write/reversibility.js.map +1 -1
  179. package/dist/write/tag-refs.d.ts +47 -0
  180. package/dist/write/tag-refs.js +126 -0
  181. package/dist/write/tag-refs.js.map +1 -0
  182. package/dist/write/undo.d.ts +74 -5
  183. package/dist/write/undo.js +493 -80
  184. package/dist/write/undo.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +17 -1
  186. package/dist/write/vectors/registry.js +18 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/types.d.ts +119 -3
  189. package/dist/write/vectors/ui-certification.d.ts +48 -0
  190. package/dist/write/vectors/ui-certification.js +46 -0
  191. package/dist/write/vectors/ui-certification.js.map +1 -0
  192. package/dist/write/vectors/ui-drag.d.ts +188 -0
  193. package/dist/write/vectors/ui-drag.js +1095 -0
  194. package/dist/write/vectors/ui-drag.js.map +1 -0
  195. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  196. package/dist/write/vectors/ui-recipes.js +558 -0
  197. package/dist/write/vectors/ui-recipes.js.map +1 -0
  198. package/dist/write/vectors/ui.d.ts +137 -0
  199. package/dist/write/vectors/ui.js +656 -0
  200. package/dist/write/vectors/ui.js.map +1 -0
  201. package/dist/write/verify/delta.d.ts +31 -3
  202. package/dist/write/verify/delta.js +22 -6
  203. package/dist/write/verify/delta.js.map +1 -1
  204. package/dist/write/verify/poller.js +1 -0
  205. package/dist/write/verify/poller.js.map +1 -1
  206. package/package.json +1 -1
  207. package/dist/cli/exit-codes.d.ts +0 -26
  208. package/dist/cli/exit-codes.js +0 -26
  209. package/dist/cli/exit-codes.js.map +0 -1
  210. package/dist/cli/output.d.ts +0 -42
  211. package/dist/cli/output.js +0 -16
  212. package/dist/cli/output.js.map +0 -1
  213. package/dist/read/pagination.d.ts +0 -104
  214. 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, 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,11 +53,25 @@ 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
  }
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
+ }
60
75
  /** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
61
76
  function resolveLimit(args) {
62
77
  return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
@@ -80,14 +95,14 @@ function mutationResult(result) {
80
95
  return jsonResult(result);
81
96
  case "blocked":
82
97
  return errorResult({
83
- code: `blocked:${result.hazard ?? result.reason}`,
98
+ code: blockedCode(result),
84
99
  message: result.detail,
85
100
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
86
101
  remediation: result.remediation,
87
102
  });
88
103
  case "verify-failed":
89
104
  return errorResult({
90
- code: `verify-failed:${result.reason}`,
105
+ code: verifyFailedCode(result),
91
106
  message: result.detail,
92
107
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
93
108
  ...(result.hint !== undefined && { remediation: result.hint }),
@@ -109,19 +124,73 @@ function mutationResult(result) {
109
124
  const READ_ONLY = { readOnlyHint: true };
110
125
  const NON_DESTRUCTIVE = { destructiveHint: false };
111
126
  const DESTRUCTIVE = { destructiveHint: true };
112
- 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 = {
113
137
  tag: z
114
- .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()
115
149
  .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"),
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)"),
118
152
  };
153
+ const tagFilterShape = {
154
+ ...tagOnlyShape,
155
+ overdue: z
156
+ .boolean()
157
+ .optional()
158
+ .describe("Only open items past their deadline (due today is not overdue)"),
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";
119
176
  const dryRunShape = {
120
177
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
121
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
+ };
122
189
  const containerRef = (ref) => ({ uuid: ref, title: ref });
123
190
  /** Cap on project titles inlined into the server instructions. */
124
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}`;
125
194
  /**
126
195
  * Live-inventory preamble: conventions plus the user's actual areas, tags,
127
196
  * and open projects, read once at server start so models can reference real
@@ -146,13 +215,13 @@ function buildInstructions(getClient) {
146
215
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
147
216
  "Operations with cascading or permanent effects require the explicit confirmation " +
148
217
  "parameter named in their description; refused calls return an error saying what to pass.",
218
+ `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
149
219
  ];
150
220
  try {
151
221
  const c = getClient();
152
222
  const areas = c.read.areas();
153
223
  const tags = c.read.tags();
154
224
  const projects = c.read.projects();
155
- const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
156
225
  const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
157
226
  const overflow = projects.length - shown.length;
158
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"}` +
@@ -169,6 +238,24 @@ function buildInstructions(getClient) {
169
238
  function checklistTarget(args) {
170
239
  return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
171
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
+ }
172
259
  export function createThingsMcpServer(options = {}) {
173
260
  // One lazily-opened client for the server's lifetime; SQLite read
174
261
  // snapshots are per-statement, so fresh reads see external commits.
@@ -181,26 +268,70 @@ export function createThingsMcpServer(options = {}) {
181
268
  return client;
182
269
  };
183
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
+ });
184
289
  /** Run a handler, mapping environment/usage throws to tool errors. */
185
290
  const guard = async (fn) => {
186
291
  try {
187
292
  return await fn();
188
293
  }
189
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
+ }
190
305
  const message = err instanceof Error ? err.message : String(err);
191
306
  const code = err instanceof RangeError ? "usage" : "environment";
192
307
  return errorResult({ code, message });
193
308
  }
194
309
  };
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
- });
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
+ };
204
335
  /** Resolve a uuid to to-do/project for the type-generic item tools. */
205
336
  const itemType = (uuid) => {
206
337
  const item = getClient().read.byUuid(uuid);
@@ -216,7 +347,7 @@ export function createThingsMcpServer(options = {}) {
216
347
  description: "Read a Things list as the app presents it: today (split into Today and This " +
217
348
  "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
218
349
  "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 " +
350
+ "anytime/someday return sections in canonical order (area + items; null area = the " +
220
351
  "top-level block); children of someday/future-scheduled projects are excluded " +
221
352
  "from anytime — the project row represents them; someday lists each group's " +
222
353
  "project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
@@ -224,10 +355,12 @@ export function createThingsMcpServer(options = {}) {
224
355
  "anytime/someday always return every group and cap per block instead — " +
225
356
  `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
226
357
  `project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
227
- "all: true lifts every cap; the result's second block reports the counts.",
358
+ "all: true lifts every cap; the result's second block reports the counts. " +
359
+ OMIT_EMPTY_NOTE,
228
360
  inputSchema: {
229
361
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
230
362
  ...tagFilterShape,
363
+ evening: z.boolean().optional().describe("today only: show only the This Evening section"),
231
364
  show_active_project_items: z
232
365
  .union([z.boolean(), z.number().int().min(1)])
233
366
  .optional()
@@ -268,7 +401,17 @@ export function createThingsMcpServer(options = {}) {
268
401
  .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
269
402
  },
270
403
  annotations: READ_ONLY,
271
- }, 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);
272
415
  // show_active_project_items is the preferred name; active_project_items
273
416
  // stays accepted as a compatibility alias.
274
417
  const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
@@ -283,6 +426,9 @@ export function createThingsMcpServer(options = {}) {
283
426
  if (args.view !== "someday" && showActiveProjectItems !== undefined) {
284
427
  return usage("show_active_project_items applies only to someday");
285
428
  }
429
+ if (args.view !== "today" && args.evening === true) {
430
+ return usage(`evening applies only to today, not ${args.view}`);
431
+ }
286
432
  if (args.view === "someday" && args.project_limit !== undefined) {
287
433
  return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
288
434
  "to cap that section's project lists");
@@ -296,63 +442,63 @@ export function createThingsMcpServer(options = {}) {
296
442
  return usage("pass at most one of area_limit/project_limit / all");
297
443
  }
298
444
  const c = getClient();
299
- const filter = {
300
- ...(args.tag !== undefined && { tag: args.tag }),
301
- ...(args.exact_tag === true && { exactTag: true }),
302
- };
445
+ const filter = validated.filter;
303
446
  switch (args.view) {
304
447
  case "today": {
305
- const { data, pagination } = paginateToday(c.read.today(filter), limit);
306
- return paginatedResult(data, pagination);
448
+ const { view, truncation } = c.read.today({
449
+ ...filter,
450
+ ...(args.evening === true && { eveningOnly: true }),
451
+ limit,
452
+ });
453
+ return truncatedResult(view, truncation);
307
454
  }
308
455
  case "inbox": {
309
- const { data, pagination } = paginateList(c.read.inbox(filter), limit);
310
- return paginatedResult(data, pagination);
456
+ const { items, truncation } = c.read.inbox({ ...filter, limit });
457
+ return truncatedResult(items, truncation);
311
458
  }
312
459
  case "anytime": {
313
- const limits = { area: areaLimit, project: projectLimit };
314
- const { data, grouped } = previewSections(c.read.anytime(filter), limits);
315
- return groupedResult(data, grouped);
460
+ const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
461
+ return groupedResult(view, grouped);
316
462
  }
317
463
  case "upcoming": {
318
- const { data, pagination } = paginateList(c.read.upcoming({
464
+ const { items, truncation } = c.read.upcoming({
319
465
  ...filter,
320
466
  ...(args.horizon !== undefined && { horizon: args.horizon }),
321
- }), limit);
322
- return paginatedResult(data, pagination);
467
+ limit,
468
+ });
469
+ return truncatedResult(items, truncation);
323
470
  }
324
471
  case "someday": {
325
472
  const active = showActiveProjectItems;
326
473
  if (typeof active === "number" && args.all === true) {
327
474
  return usage("pass at most one of a numeric show_active_project_items / all");
328
475
  }
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({
476
+ const { view, grouped } = c.read.someday({
335
477
  ...filter,
336
478
  ...((active === true || typeof active === "number") && {
337
479
  activeProjectItems: true,
338
480
  }),
339
- }), limits);
340
- return groupedResult(data, grouped);
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);
341
486
  }
342
487
  case "logbook": {
343
- const { data, pagination } = paginateList(c.read.logbook({ ...filter, limit: null }), limit);
344
- return paginatedResult(data, pagination);
488
+ const { items, truncation } = c.read.logbook({ ...filter, limit });
489
+ return truncatedResult(items, truncation);
345
490
  }
346
491
  case "trash": {
347
- const { data, pagination } = paginateList(c.read.trash({ limit: null }), limit);
348
- return paginatedResult(data, pagination);
492
+ const { items, truncation } = c.read.trash({ limit });
493
+ return truncatedResult(items, truncation);
349
494
  }
350
495
  }
351
496
  }));
352
497
  server.registerTool("search", {
353
498
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
354
499
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
355
- "references must name existing items.",
500
+ "references must name existing items. " +
501
+ OMIT_EMPTY_NOTE,
356
502
  inputSchema: {
357
503
  query: z.string(),
358
504
  ...tagFilterShape,
@@ -374,33 +520,48 @@ export function createThingsMcpServer(options = {}) {
374
520
  limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
375
521
  },
376
522
  annotations: READ_ONLY,
377
- }, async (args) => guard(() => {
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);
378
539
  const limit = resolveLimit(args);
379
540
  if (limit === "conflict")
380
541
  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 }),
542
+ const { items, truncation } = getClient().read.search(args.query, {
543
+ limit,
544
+ ...validated.filter,
385
545
  ...(args.project !== undefined && { project: args.project }),
386
546
  ...(args.area !== undefined && { area: args.area }),
387
547
  ...(args.type !== undefined && { type: args.type }),
388
548
  ...(args.logged === true && { logged: true }),
389
549
  ...(args.trashed === true && { trashed: true }),
390
550
  ...(args.all === true && { all: true }),
391
- }), limit);
392
- return paginatedResult(data, pagination);
551
+ });
552
+ return truncatedResult(items, truncation);
393
553
  }));
394
554
  server.registerTool("changes_since", {
395
555
  description: "List items created or modified since a moment — including trashed, logged, and " +
396
556
  "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.",
557
+ "areas, and checklist items do not mark the containing item as modified. " +
558
+ OMIT_EMPTY_NOTE,
398
559
  inputSchema: {
399
560
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
400
561
  ...limitShape,
401
562
  },
402
563
  annotations: READ_ONLY,
403
- }, async (args) => guard(() => {
564
+ }, async (args) => readGuard(() => {
404
565
  const limit = resolveLimit(args);
405
566
  if (limit === "conflict")
406
567
  return usage("pass at most one of limit / all");
@@ -408,34 +569,54 @@ export function createThingsMcpServer(options = {}) {
408
569
  if (Number.isNaN(since.getTime())) {
409
570
  return usage(`since is not a parseable date: ${args.since}`);
410
571
  }
411
- const { data, pagination } = paginateList(getClient().read.changes({ since, limit: null }), limit);
412
- return paginatedResult(data, pagination);
572
+ const { items, truncation } = getClient().read.changes({ since, limit });
573
+ return truncatedResult(items, truncation);
413
574
  }));
414
575
  server.registerTool("get_item", {
415
576
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
416
577
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
417
- "project/area/heading.",
578
+ "project/area/heading. " +
579
+ OMIT_EMPTY_NOTE,
418
580
  inputSchema: { uuid: z.string() },
419
581
  annotations: READ_ONLY,
420
- }, async (args) => guard(() => {
582
+ }, async (args) => readGuard(() => {
421
583
  const item = getClient().read.byUuid(args.uuid);
422
584
  return item === null
423
- ? errorResult({ code: "not-found", message: `no record with uuid ${args.uuid}` })
424
- : jsonResult(item);
585
+ ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
586
+ : readResult(item);
425
587
  }));
426
588
  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") },
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
+ },
429
601
  annotations: READ_ONLY,
430
- }, 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
+ }));
431
610
  server.registerTool("get_area", {
432
611
  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. " +
612
+ "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
434
613
  `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
435
614
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
436
- "second result block reports the counts.",
615
+ "second result block reports the counts. " +
616
+ OMIT_EMPTY_NOTE,
437
617
  inputSchema: {
438
618
  ref: z.string().describe("Area uuid or unique name"),
619
+ ...tagOnlyShape,
439
620
  area_limit: z
440
621
  .number()
441
622
  .int()
@@ -448,28 +629,70 @@ export function createThingsMcpServer(options = {}) {
448
629
  .min(1)
449
630
  .optional()
450
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"),
451
636
  all: z.boolean().optional().describe("return both sections in full (no caps)"),
452
637
  },
453
638
  annotations: READ_ONLY,
454
- }, async (args) => guard(() => {
639
+ }, async (args) => readGuard(() => {
640
+ if (tagFlagConflict(tagPresence(args)))
641
+ return usage(MCP_UNTAGGED_CONFLICT);
455
642
  const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
456
643
  const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
457
644
  if (areaLimit === "conflict" || projectLimit === "conflict") {
458
645
  return usage("pass at most one of area_limit/project_limit / all");
459
646
  }
460
- const limits = { area: areaLimit, project: projectLimit };
461
- const { data, grouped } = capAreaSections(getClient().read.areaView(args.ref), limits);
462
- return groupedResult(data, grouped);
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);
463
654
  }));
464
655
  server.registerTool("list_collections", {
465
656
  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"]) },
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
+ },
468
668
  annotations: READ_ONLY,
469
- }, async (args) => guard(() => {
669
+ }, async (args) => readGuard(() => {
470
670
  const c = getClient();
471
- return jsonResult(args.kind === "projects"
472
- ? 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
+ })
473
696
  : args.kind === "areas"
474
697
  ? c.read.areas()
475
698
  : c.read.tags());
@@ -479,7 +702,7 @@ export function createThingsMcpServer(options = {}) {
479
702
  server.registerTool("add_todo", {
480
703
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
481
704
  "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 " +
705
+ "(optionally under an existing heading). A reminder " +
483
706
  "requires when = today, evening, or a date. Adding into a completed or canceled " +
484
707
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
485
708
  inputSchema: {
@@ -488,7 +711,7 @@ export function createThingsMcpServer(options = {}) {
488
711
  when: whenSchema,
489
712
  reminder: z.string().optional().describe(REMINDER_FORMAT),
490
713
  deadline: z.string().optional().describe(DATE_FORMAT),
491
- tags: z.array(z.string()).optional().describe("Existing tag names"),
714
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
492
715
  checklist_items: z.array(z.string()).optional(),
493
716
  project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
494
717
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
@@ -497,21 +720,29 @@ export function createThingsMcpServer(options = {}) {
497
720
  .boolean()
498
721
  .optional()
499
722
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
723
+ ...createTagsShape,
500
724
  ...dryRunShape,
501
725
  },
502
726
  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)))));
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
+ }));
515
746
  server.registerTool("update_todo", {
516
747
  description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
517
748
  "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
@@ -545,13 +776,18 @@ export function createThingsMcpServer(options = {}) {
545
776
  if (args.deadline !== undefined && args.clear_deadline === true) {
546
777
  return usage("pass at most one of deadline / clear_deadline");
547
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;
548
784
  return mutationResult(await getClient().write.updateTodo(args.uuid, {
549
785
  ...(args.title !== undefined && { title: args.title }),
550
786
  ...(args.notes !== undefined && { notes: args.notes }),
551
787
  ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
552
788
  ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
553
- ...(args.when !== undefined && { when: args.when }),
554
- ...(args.reminder !== undefined && { reminder: args.reminder }),
789
+ ...(when !== undefined && { when: when }),
790
+ ...(reminder !== undefined && { reminder }),
555
791
  ...(args.clear_reminder === true && { reminder: null }),
556
792
  ...(args.deadline !== undefined && { deadline: args.deadline }),
557
793
  ...(args.clear_deadline === true && { deadline: null }),
@@ -618,11 +854,12 @@ export function createThingsMcpServer(options = {}) {
618
854
  server.registerTool("set_tags", {
619
855
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
620
856
  "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).",
857
+ "tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
622
858
  inputSchema: {
623
859
  uuid: z.string(),
624
- tags: z.array(z.string()).describe("Existing tag names"),
860
+ tags: z.array(z.string()).describe(`Tags ${TAG_REF_FORMAT}`),
625
861
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
862
+ ...createTagsShape,
626
863
  ...dryRunShape,
627
864
  },
628
865
  annotations: NON_DESTRUCTIVE,
@@ -847,6 +1084,265 @@ export function createThingsMcpServer(options = {}) {
847
1084
  ? jsonResult(r)
848
1085
  : mutationResult(r.heading);
849
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
+ }));
850
1346
  server.registerTool("duplicate_item", {
851
1347
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
852
1348
  "includes its children. Not available for repeating items.",
@@ -887,7 +1383,7 @@ export function createThingsMcpServer(options = {}) {
887
1383
  "clear_reminder works while the project is scheduled for today or this evening; a " +
888
1384
  "reminder on a future date can only be changed, not cleared.",
889
1385
  inputSchema: {
890
- uuid: z.string(),
1386
+ uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
891
1387
  title: z.string().optional(),
892
1388
  notes: z.string().optional().describe("Replaces the whole notes body"),
893
1389
  append_notes: z.string().optional(),
@@ -931,7 +1427,7 @@ export function createThingsMcpServer(options = {}) {
931
1427
  "canceled project; its children stay completed/canceled unless restore_children " +
932
1428
  "also reopens the ones that were resolved together with the project.",
933
1429
  inputSchema: {
934
- uuid: z.string().describe("Project uuid"),
1430
+ uuid: z.string().describe(`The project (${REF_FORMAT})`),
935
1431
  status: z.enum(["completed", "canceled", "open"]),
936
1432
  children: z
937
1433
  .enum(["require-resolved", "auto-complete", "auto-cancel"])
@@ -976,7 +1472,7 @@ export function createThingsMcpServer(options = {}) {
976
1472
  description: "Move a project into an area, or detach it from its current area. Pass exactly " +
977
1473
  "one of area / detach. The project's status and schedule are unaffected.",
978
1474
  inputSchema: {
979
- uuid: z.string().describe("Project uuid"),
1475
+ uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
980
1476
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
981
1477
  detach: z.boolean().optional().describe("Remove the current area assignment"),
982
1478
  ...dryRunShape,
@@ -993,20 +1489,26 @@ export function createThingsMcpServer(options = {}) {
993
1489
  }));
994
1490
  // ----------------------------------------------------------------- areas
995
1491
  server.registerTool("add_area", {
996
- 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.",
997
1493
  inputSchema: {
998
1494
  title: z.string(),
999
- tags: z.array(z.string()).optional().describe("Existing tag names"),
1495
+ tags: z.array(z.string()).optional().describe(`Tags ${TAG_REF_FORMAT}`),
1496
+ ...createTagsShape,
1000
1497
  ...dryRunShape,
1001
1498
  },
1002
1499
  annotations: NON_DESTRUCTIVE,
1003
1500
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
1004
1501
  server.registerTool("update_area", {
1005
- 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.",
1006
1504
  inputSchema: {
1007
1505
  target: z.string().describe(`Area to update (${REF_FORMAT})`),
1008
1506
  title: z.string().optional().describe("New name"),
1009
- 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,
1010
1512
  ...dryRunShape,
1011
1513
  },
1012
1514
  annotations: NON_DESTRUCTIVE,
@@ -1133,10 +1635,10 @@ export function createThingsMcpServer(options = {}) {
1133
1635
  params: z.record(z.string(), z.unknown()),
1134
1636
  options: z
1135
1637
  .object({
1136
- acknowledgeChecklistReset: z.boolean().optional(),
1137
- acknowledgeProjectReopen: z.boolean().optional(),
1138
- dangerouslyPermanent: z.boolean().optional(),
1139
- 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(),
1140
1642
  })
1141
1643
  .optional(),
1142
1644
  }))
@@ -1146,10 +1648,29 @@ export function createThingsMcpServer(options = {}) {
1146
1648
  },
1147
1649
  annotations: DESTRUCTIVE,
1148
1650
  }, async (args) => guard(async () => {
1149
- 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, {
1150
1671
  ...(args.dry_run === true && { dryRun: true }),
1151
1672
  ...(args.fail_fast === true && { failFast: true }),
1152
- actor: "mcp",
1673
+ actor: mcpActor(),
1153
1674
  });
1154
1675
  return jsonResult(results);
1155
1676
  }));
@@ -1157,7 +1678,7 @@ export function createThingsMcpServer(options = {}) {
1157
1678
  description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1158
1679
  "area-less someday projects — one kind per call), a " +
1159
1680
  "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 — " +
1681
+ "their heading), an area, or the top-level projects (scope=projects — " +
1161
1682
  "each project takes a brief someday/anytime round-trip) — the given uuids move " +
1162
1683
  "to the TOP in the given order; unlisted items keep their relative order below. " +
1163
1684
  "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
@@ -1191,24 +1712,59 @@ export function createThingsMcpServer(options = {}) {
1191
1712
  ...(args.strategy !== undefined && { strategy: args.strategy }),
1192
1713
  }, writeOptions(args)))));
1193
1714
  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.",
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.",
1200
1727
  inputSchema: {
1201
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."),
1202
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."),
1203
1748
  ...dryRunShape,
1204
1749
  },
1205
1750
  annotations: DESTRUCTIVE,
1206
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
+ }
1207
1755
  const items = await getClient().write.undo({
1208
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() }),
1209
1762
  ...(args.dry_run === true && { dryRun: true }),
1210
1763
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
1211
- actor: "mcp",
1764
+ ...(args.acknowledge_out_of_band_changes === true && {
1765
+ acknowledgeOutOfBandChanges: true,
1766
+ }),
1767
+ actor: mcpActor(),
1212
1768
  });
1213
1769
  return jsonResult(items);
1214
1770
  }));
@@ -1226,8 +1782,10 @@ export function createThingsMcpServer(options = {}) {
1226
1782
  server.registerTool("doctor", {
1227
1783
  description: "Check the environment: whether the Things app and its database are reachable, " +
1228
1784
  "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.",
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.",
1231
1789
  inputSchema: {
1232
1790
  probe_automation: z
1233
1791
  .boolean()