things-api 0.18.0 → 0.19.1

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 (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +311 -142
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -16,7 +16,64 @@
16
16
  */
17
17
  import { z } from "zod";
18
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
- import { ALL_DESC, AREA_FILTER_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, FULL_DESC, hasTagPresence, isLooseRef, isValidTimeZone, LIMIT_DESC, looseShadowNotice, MCP_WHEN_LABELS, mutationWireData, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OP_ID_RE, opResult, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, REF_RULE_NOTE, ReferenceResolutionError, REMINDER_FORMAT, RESOLUTION_DATE_FORMAT, schemaWarnings, shapeReadPayload, withTodayBucketTotals, withAreaBucketTotals, withSectionTotals, withUpcomingBlockTotals, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
19
+ import { ALL_DESC, AREA_FILTER_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, buildUpdatePatch, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, FULL_DESC, hasTagPresence, isLooseRef, isValidTimeZone, LIMIT_DESC, looseShadowNotice, MCP_UPDATE_LABELS, MCP_WHEN_LABELS, mutationWireData, NOTES_FORMAT, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OP_ID_RE, opResult, OPERATION_KINDS, openThings, readAllowed, readCapability, uiAllowed, uiCapability, writeAllowed, writeCapability, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, REF_RULE_NOTE, ReferenceResolutionError, REMINDER_FORMAT, RESOLUTION_DATE_FORMAT, schemaWarnings, shapeReadPayload, withTodayBucketTotals, withAreaBucketTotals, withSectionTotals, withUpcomingBlockTotals, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
20
+ /** Tools that mutate through the app. Everything unlisted is a plain read. */
21
+ const WRITE_TOOLS = new Set([
22
+ "add_todo",
23
+ "update",
24
+ "set_status",
25
+ "move_todo",
26
+ "set_tags",
27
+ "edit_checklist",
28
+ "delete",
29
+ "restore_item",
30
+ "heading",
31
+ "clear_reminder",
32
+ "duplicate_item",
33
+ "clone_item",
34
+ "add_project",
35
+ "move_project",
36
+ "add_area",
37
+ "add_tag",
38
+ "log_now",
39
+ "run_operation",
40
+ "batch",
41
+ "reorder",
42
+ "undo",
43
+ ]);
44
+ /**
45
+ * Tools with NO headless spelling at all — every path through them drives the
46
+ * Things window. A tool that merely *can* reach a GUI op (`heading`'s promote,
47
+ * `reorder`'s areas) is NOT listed: those have working headless branches, and
48
+ * the pipeline's own Article IV gate refuses the GUI ones per call.
49
+ */
50
+ const UI_TOOLS = new Set(["repeat", "convert_to_project"]);
51
+ /** The always-available diagnostics. */
52
+ const DIAGNOSTIC_TOOLS = new Set(["doctor", "capabilities", "op_result"]);
53
+ function toolClass(name) {
54
+ if (DIAGNOSTIC_TOOLS.has(name))
55
+ return "none";
56
+ if (UI_TOOLS.has(name))
57
+ return "ui";
58
+ if (WRITE_TOOLS.has(name))
59
+ return "write";
60
+ return "read";
61
+ }
62
+ /** The one remediation every baked refusal ends with. */
63
+ const RESTART_REMEDIATION = "this server surveyed its permissions once at startup and does not re-check them, because a " +
64
+ "grant made now would attach to whoever launched it: stop this server, run the setup command " +
65
+ "above at the machine, then start it again.";
66
+ /** The vector classes this server can actually serve, for the instructions. */
67
+ function availableClasses(capability) {
68
+ const available = [];
69
+ if (readAllowed(capability.read))
70
+ available.push("read");
71
+ if (writeAllowed(capability.write))
72
+ available.push("write");
73
+ if (uiAllowed(capability.ui))
74
+ available.push("gui-driving");
75
+ return available;
76
+ }
20
77
  function jsonResult(data) {
21
78
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
22
79
  }
@@ -341,9 +398,11 @@ const preserveModifiedShape = {
341
398
  /**
342
399
  * The per-call idempotency key for a single write tool — the analogue of a batch
343
400
  * line's op_id. A resubmission carrying the same key is recognized as already
344
- * applied (a prior verified change with that key) and is not re-run. Spread into
345
- * the single-mutation write tools (not the variadic move/reorder tools, whose
346
- * idempotency is the batch-shaped per-line op_id).
401
+ * applied (a prior change recorded under that key) and is not re-run. Spread
402
+ * into every write tool whose call records ONE result — the single mutations,
403
+ * and the multi-step verbs that record one summary (the repeating-series tool,
404
+ * the template-target status/exception writes) — but not the variadic
405
+ * move/reorder tools, whose idempotency is the batch-shaped per-line op_id.
347
406
  */
348
407
  const opIdShape = {
349
408
  op_id: z
@@ -402,7 +461,7 @@ const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`
402
461
  * names without a discovery round-trip. Degrades to conventions-only when
403
462
  * the database is not readable.
404
463
  */
405
- function buildInstructions(getClient) {
464
+ function buildInstructions(getClient, capability) {
406
465
  const lines = [
407
466
  "This server reads and modifies the user's Things 3 data: to-dos, projects, areas, and tags.",
408
467
  "",
@@ -418,6 +477,8 @@ function buildInstructions(getClient) {
418
477
  `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
419
478
  `are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
420
479
  `below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
480
+ `- Notes vocabulary: the notes field is ${NOTES_FORMAT}. Newlines are kept as written, so ` +
481
+ `pass a multi-line body directly rather than flattening it into one line.`,
421
482
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
422
483
  "A preview creates no state, so later calls cannot reference an item that only appeared in " +
423
484
  "a dry-run result. Operations with cascading or permanent effects require the explicit " +
@@ -430,6 +491,27 @@ function buildInstructions(getClient) {
430
491
  "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
431
492
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
432
493
  ];
494
+ // What this server may do, decided once at startup and true for its whole
495
+ // life (permissions doctrine, Article II). Stated up front so an agent plans
496
+ // around the real surface instead of discovering it one refusal at a time.
497
+ const available = availableClasses(capability);
498
+ const missing = [];
499
+ if (!readAllowed(capability.read))
500
+ missing.push(`reads — ${capability.read.detail}`);
501
+ if (!writeAllowed(capability.write))
502
+ missing.push(`changes — ${capability.write.detail}`);
503
+ if (!uiAllowed(capability.ui))
504
+ missing.push(`GUI-driven operations — ${capability.ui.detail}`);
505
+ lines.push("", "Permissions (checked once at server start, fixed for this server's lifetime):", `- Available here: ${available.length > 0 ? available.join(", ") : "nothing — every tool below refuses"}.`, ...(missing.length > 0
506
+ ? [
507
+ `- Unavailable here: ${missing.join("; ")}.`,
508
+ "- Tools needing an unavailable capability refuse immediately rather than trying and " +
509
+ "raising a macOS permission dialog nobody is present to answer. Remediation for each " +
510
+ "is in its refusal. These grants attach to whichever process launched this server, so " +
511
+ "they cannot change while it runs: the fix is always stop the server, run the named " +
512
+ "setup command at the machine, start it again.",
513
+ ]
514
+ : ["- Nothing is missing; no tool will refuse for want of a permission."]));
433
515
  try {
434
516
  const c = getClient();
435
517
  // Under a container scope the inventory below is already limited to in-scope
@@ -499,7 +581,86 @@ export function createThingsMcpServer(options = {}) {
499
581
  });
500
582
  return client;
501
583
  };
502
- const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
584
+ // THE STARTUP BAKE (permissions doctrine, Article II). One prompt-free survey
585
+ // before the transport is connected; the verdict then shapes the instructions
586
+ // and gates the tools, and is never re-taken.
587
+ const capability = options.capability ?? {
588
+ read: readCapability(options.dbPath !== undefined ? { dbPath: options.dbPath } : {}),
589
+ // SURVEY: starting a server must never start the user's app as a side
590
+ // effect. A closed Things reads as the liveness state; the write gate wakes
591
+ // it when a change is actually dispatched (#617).
592
+ write: writeCapability(),
593
+ ui: uiCapability(),
594
+ };
595
+ const warn = options.onStartupWarning ?? ((line) => process.stderr.write(`${line}\n`));
596
+ const unavailable = [
597
+ ...(readAllowed(capability.read) ? [] : ["reads"]),
598
+ ...(writeAllowed(capability.write) ? [] : ["changes"]),
599
+ ...(uiAllowed(capability.ui) ? [] : ["GUI-driven operations"]),
600
+ ];
601
+ if (unavailable.length > 0) {
602
+ warn(`things-api MCP: ${unavailable.join(", ")} unavailable on this machine — ` +
603
+ `${capability.read.detail}; ${capability.write.detail}; ${capability.ui.detail}. ` +
604
+ "Tools needing them refuse; restart this server after running setup.");
605
+ }
606
+ const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient, capability) });
607
+ /**
608
+ * The baked refusal for one tool class, or null when the class is served.
609
+ * Article II in one function: the answer is the STARTUP verdict, quoted, with
610
+ * the setup command and the restart rule — never a fresh probe, and never a
611
+ * dispatch that could put a dialog on screen.
612
+ */
613
+ const bakedRefusal = (name) => {
614
+ switch (toolClass(name)) {
615
+ case "none":
616
+ return null;
617
+ case "read":
618
+ return readAllowed(capability.read)
619
+ ? null
620
+ : errorResult({
621
+ code: "environment",
622
+ message: `this tool reads the Things database, and ${capability.read.detail}`,
623
+ remediation: [...capability.read.remediation, RESTART_REMEDIATION].join("; "),
624
+ });
625
+ case "write":
626
+ // A write read-verifies, so it needs both classes; name the one that
627
+ // is actually missing rather than a generic "cannot write".
628
+ if (!readAllowed(capability.read)) {
629
+ return errorResult({
630
+ code: "environment",
631
+ message: `this tool changes Things data and verifies the result by reading it back, and ${capability.read.detail}`,
632
+ remediation: [...capability.read.remediation, RESTART_REMEDIATION].join("; "),
633
+ });
634
+ }
635
+ return writeAllowed(capability.write)
636
+ ? null
637
+ : errorResult({
638
+ code: "environment",
639
+ message: `this tool changes Things data by driving the app, and ${capability.write.detail}`,
640
+ remediation: [...capability.write.remediation, RESTART_REMEDIATION].join("; "),
641
+ });
642
+ case "ui":
643
+ return uiAllowed(capability.ui)
644
+ ? null
645
+ : errorResult({
646
+ code: "environment",
647
+ message: `this tool is delivered by driving the Things window, and ${capability.ui.detail}`,
648
+ remediation: [...capability.ui.remediation, RESTART_REMEDIATION].join("; "),
649
+ });
650
+ }
651
+ };
652
+ /**
653
+ * `server.registerTool` with the baked gate in front of every callback. Every
654
+ * registration below goes through this, so a tool cannot be added without a
655
+ * class — {@link toolClass} defaults an unlisted name to `read`, the weakest
656
+ * useful assumption, rather than to ungated.
657
+ */
658
+ const registerTool = ((name, config, cb) => server.registerTool(name, config, ((...args) => {
659
+ const refusal = bakedRefusal(name);
660
+ if (refusal !== null)
661
+ return refusal;
662
+ return cb(...args);
663
+ })));
503
664
  // The audit author for every write on this connection, derived once from the
504
665
  // client's handshake identity (clientInfo.name). Read per call: clientInfo is
505
666
  // populated when the initialize handshake completes, before any tool can run.
@@ -608,14 +769,18 @@ export function createThingsMcpServer(options = {}) {
608
769
  return item.type;
609
770
  };
610
771
  // ------------------------------------------------------------------ reads
611
- server.registerTool("read_view", {
772
+ registerTool("read_view", {
612
773
  description: "Read a Things list as the app presents it: today (two children buckets — " +
613
774
  "children.today and children.evening, evening expires daily; the whole-view " +
614
775
  "count due/overdue vs. other rides the result's second block), inbox, anytime, " +
615
776
  "upcoming, someday, logbook, trash, or deadlines (a flat items list of everything " +
616
777
  "carrying a deadline — to-dos and projects — in deadline order, most-overdue first; " +
617
778
  "repeating items appear at their next occurrence's projected deadline; scope with " +
618
- "today/overdue/project/area/tag). For upcoming, " +
779
+ "today/overdue/project/area/tag), or repeaters (every repeating series in the " +
780
+ "library — to-dos and projects — each carrying its decoded rule under " +
781
+ "repeating.rule; series appear in no other view, which show the occurrences a " +
782
+ "series spawns instead; paused and ended series are included; ordered by next " +
783
+ "occurrence, the ones with none last). For upcoming, " +
619
784
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
620
785
  "anytime/someday return sections in canonical order (area + items; null area = the " +
621
786
  "top-level block); children of someday/future-scheduled projects are excluded " +
@@ -639,6 +804,7 @@ export function createThingsMcpServer(options = {}) {
639
804
  "logbook",
640
805
  "trash",
641
806
  "deadlines",
807
+ "repeaters",
642
808
  ]),
643
809
  ...tagFilterShape,
644
810
  ...tzShape,
@@ -854,10 +1020,14 @@ export function createThingsMcpServer(options = {}) {
854
1020
  });
855
1021
  return truncatedResult(shapeReadPayload("deadlines", items, full, c.refPromoter()), truncation);
856
1022
  }
1023
+ case "repeaters": {
1024
+ const { items, truncation } = c.read.repeaters({ ...filter, ...zone, limit });
1025
+ return truncatedResult(shapeReadPayload("repeaters", items, full, c.refPromoter()), truncation);
1026
+ }
857
1027
  }
858
1028
  }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
859
1029
  });
860
- server.registerTool("search", {
1030
+ registerTool("search", {
861
1031
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
862
1032
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
863
1033
  "references must name existing items. " +
@@ -920,7 +1090,7 @@ export function createThingsMcpServer(options = {}) {
920
1090
  });
921
1091
  return truncatedResult(shapeReadPayload("search", items, args.full === true, getClient().refPromoter()), truncation);
922
1092
  }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
923
- server.registerTool("changes_since", {
1093
+ registerTool("changes_since", {
924
1094
  description: "List items created or modified since a moment — including trashed, logged, and " +
925
1095
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
926
1096
  "areas, and checklist items do not mark the containing item as modified. " +
@@ -950,7 +1120,7 @@ export function createThingsMcpServer(options = {}) {
950
1120
  });
951
1121
  return truncatedResult(shapeReadPayload("changes", items, args.full === true, getClient().refPromoter()), truncation);
952
1122
  }, args.tz));
953
- server.registerTool("get_item", {
1123
+ registerTool("get_item", {
954
1124
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
955
1125
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
956
1126
  "project/area/heading. " +
@@ -965,7 +1135,7 @@ export function createThingsMcpServer(options = {}) {
965
1135
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
966
1136
  : readResult(shapeReadPayload("detail", item, false, getClient().refPromoter()));
967
1137
  }));
968
- server.registerTool("get_project", {
1138
+ registerTool("get_project", {
969
1139
  description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
970
1140
  "Open headings (and archived ones not yet swept to the logbook) stay live, carrying " +
971
1141
  "archived once archived; a swept archived heading moves into the logged region as a " +
@@ -997,7 +1167,7 @@ export function createThingsMcpServer(options = {}) {
997
1167
  ...(args.tz !== undefined && { zone: args.tz }),
998
1168
  }), args.full === true, getClient().refPromoter()));
999
1169
  }, args.tz));
1000
- server.registerTool("get_area", {
1170
+ registerTool("get_area", {
1001
1171
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
1002
1172
  "projects in canonical order, and later (scheduled/repeating/someday). The area " +
1003
1173
  "logbook is not returned here — read it with read_view logbook + area; trashed " +
@@ -1061,7 +1231,7 @@ export function createThingsMcpServer(options = {}) {
1061
1231
  return groupedResult(withAreaBucketTotals(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), totals), truncation);
1062
1232
  }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
1063
1233
  });
1064
- server.registerTool("list_collections", {
1234
+ registerTool("list_collections", {
1065
1235
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
1066
1236
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
1067
1237
  "the projects list by each project's own tags (areas/tags reject them). " +
@@ -1116,7 +1286,7 @@ export function createThingsMcpServer(options = {}) {
1116
1286
  }, args.tz));
1117
1287
  // ---------------------------------------------------------------- to-dos
1118
1288
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
1119
- server.registerTool("add_todo", {
1289
+ registerTool("add_todo", {
1120
1290
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
1121
1291
  "deadline, tag it, give it a checklist, and place it in a project or area " +
1122
1292
  "(optionally under a heading within that project). A reminder " +
@@ -1124,7 +1294,7 @@ export function createThingsMcpServer(options = {}) {
1124
1294
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
1125
1295
  inputSchema: {
1126
1296
  title: z.string(),
1127
- notes: z.string().optional(),
1297
+ notes: z.string().optional().describe(`Notes body — ${NOTES_FORMAT}`),
1128
1298
  when: whenSchema,
1129
1299
  reminder: z.string().optional().describe(REMINDER_FORMAT),
1130
1300
  deadline: z.string().optional().describe(DATE_FORMAT),
@@ -1177,7 +1347,7 @@ export function createThingsMcpServer(options = {}) {
1177
1347
  ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1178
1348
  }, writeOptions(args)));
1179
1349
  }));
1180
- server.registerTool("update", {
1350
+ registerTool("update", {
1181
1351
  description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
1182
1352
  "kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
1183
1353
  "existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
@@ -1198,7 +1368,10 @@ export function createThingsMcpServer(options = {}) {
1198
1368
  .describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
1199
1369
  "unique name"),
1200
1370
  title: z.string().optional().describe("New title (any kind)"),
1201
- notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
1371
+ notes: z
1372
+ .string()
1373
+ .optional()
1374
+ .describe(`todo/project: replaces the whole notes body — ${NOTES_FORMAT}`),
1202
1375
  append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
1203
1376
  prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
1204
1377
  when: whenSchema,
@@ -1220,6 +1393,17 @@ export function createThingsMcpServer(options = {}) {
1220
1393
  .array(z.string())
1221
1394
  .optional()
1222
1395
  .describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
1396
+ exception: z
1397
+ .boolean()
1398
+ .optional()
1399
+ .describe("todo, repeating only: change just the NEXT occurrence and leave the series alone " +
1400
+ "(the occurrence is created if it has not appeared yet). Refused when the series " +
1401
+ "already lands on the requested day, and when the series names no next date at " +
1402
+ "all (a paused one — resume it first). The result's occurrence field names both the occurrence " +
1403
+ "and the series. Each call takes another occurrence out of the series, so pass " +
1404
+ "op_id when retrying: a resubmission with the same key replays the first result " +
1405
+ "instead of creating a second occurrence. Undo restores the occurrence's own " +
1406
+ "change but cannot remove the occurrence or rewind the series"),
1223
1407
  parent: z.string().optional().describe("tag: existing tag to nest under"),
1224
1408
  unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
1225
1409
  shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
@@ -1238,49 +1422,23 @@ export function createThingsMcpServer(options = {}) {
1238
1422
  const opts = writeOptions(args);
1239
1423
  const c = getClient();
1240
1424
  if (args.kind === "todo" || args.kind === "project") {
1241
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
1242
- if (notesModes.length > 1) {
1243
- return usage("notes, append_notes, prepend_notes are exclusive");
1244
- }
1245
- if (args.reminder !== undefined && args.clear_reminder === true) {
1246
- return usage("pass at most one of reminder / clear_reminder");
1247
- }
1248
- if (args.deadline !== undefined && args.clear_deadline === true) {
1249
- return usage("pass at most one of deadline / clear_deadline");
1250
- }
1251
- if (args.kind === "todo") {
1252
- const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
1253
- if (sugar.kind === "error")
1254
- return usage(sugar.message);
1255
- const when = sugar.kind === "split" ? sugar.when : args.when;
1256
- const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
1257
- return mutationResult(await c.write.updateTodo(args.uuid, {
1258
- ...(args.title !== undefined && { title: args.title }),
1259
- ...(args.notes !== undefined && { notes: args.notes }),
1260
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1261
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1262
- ...(when !== undefined && { when: when }),
1263
- ...(reminder !== undefined && { reminder }),
1264
- ...(args.clear_reminder === true && { reminder: null }),
1265
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1266
- ...(args.clear_deadline === true && { deadline: null }),
1267
- ...(args.created_at !== undefined && { createdAt: args.created_at }),
1268
- ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1269
- }, opts));
1425
+ // The patch and every exclusive pair in the vocabulary, the `@time`
1426
+ // sugar included comes from the ONE builder the CLI shares, so the two
1427
+ // surfaces cannot drift on which flags they accept (#491 doctrine).
1428
+ const built = buildUpdatePatch(args, MCP_UPDATE_LABELS);
1429
+ if (built.kind === "error")
1430
+ return usage(built.message);
1431
+ if (args.exception === true) {
1432
+ if (args.kind !== "todo")
1433
+ return usage("exception applies to kind todo");
1434
+ if (args.created_at !== undefined || args.completed_at !== undefined) {
1435
+ return usage("exception cannot be combined with created_at/completed_at");
1436
+ }
1437
+ return mutationResult(await c.write.updateTodoOccurrence(args.uuid, built.patch, opts));
1270
1438
  }
1271
- return mutationResult(await c.write.updateProject(args.uuid, {
1272
- ...(args.title !== undefined && { title: args.title }),
1273
- ...(args.notes !== undefined && { notes: args.notes }),
1274
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1275
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1276
- ...(args.when !== undefined && { when: args.when }),
1277
- ...(args.reminder !== undefined && { reminder: args.reminder }),
1278
- ...(args.clear_reminder === true && { reminder: null }),
1279
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1280
- ...(args.clear_deadline === true && { deadline: null }),
1281
- ...(args.created_at !== undefined && { createdAt: args.created_at }),
1282
- ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1283
- }, opts));
1439
+ return mutationResult(args.kind === "todo"
1440
+ ? await c.write.updateTodo(args.uuid, built.patch, opts)
1441
+ : await c.write.updateProject(args.uuid, built.patch, opts));
1284
1442
  }
1285
1443
  if (args.kind === "area") {
1286
1444
  if (args.title === undefined && args.tags === undefined) {
@@ -1313,9 +1471,15 @@ export function createThingsMcpServer(options = {}) {
1313
1471
  ...(args.clear_shortcut === true && { clearShortcut: true }),
1314
1472
  }, opts));
1315
1473
  }));
1316
- server.registerTool("set_status", {
1474
+ registerTool("set_status", {
1317
1475
  description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
1318
- "(reopening a completed/canceled item). Not available for repeating to-dos. " +
1476
+ "(reopening a completed/canceled item). scope todo, a REPEATING to-do: completed/canceled " +
1477
+ "resolves the series' CURRENT occurrence — the unfinished one if there is one, otherwise " +
1478
+ "the next one, created for the purpose — and leaves the series running; the result's " +
1479
+ "occurrence field names both uuids and says which of the two was created. Each call is a " +
1480
+ "new resolution that takes the FOLLOWING occurrence, so pass op_id when retrying: a " +
1481
+ "resubmission with the same key replays the first result instead of creating a second " +
1482
+ "occurrence. Reopening a repeating to-do is not available. " +
1319
1483
  "scope project, completing or canceling requires a children policy: 'require-resolved' " +
1320
1484
  "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
1321
1485
  "the project (canceling never alters already-completed children). scope project, status " +
@@ -1401,7 +1565,7 @@ export function createThingsMcpServer(options = {}) {
1401
1565
  ? jsonResult(outcome)
1402
1566
  : mutationResult(outcome.project);
1403
1567
  }));
1404
- server.registerTool("move_todo", {
1568
+ registerTool("move_todo", {
1405
1569
  description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
1406
1570
  "belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
1407
1571
  "without changing membership, call this with a position (first/last/before/after) and " +
@@ -1474,7 +1638,7 @@ export function createThingsMcpServer(options = {}) {
1474
1638
  };
1475
1639
  return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
1476
1640
  }));
1477
- server.registerTool("set_tags", {
1641
+ registerTool("set_tags", {
1478
1642
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
1479
1643
  "the given list — an empty list removes all tags; mode 'add' merges with the current " +
1480
1644
  "tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
@@ -1501,7 +1665,7 @@ export function createThingsMcpServer(options = {}) {
1501
1665
  ? await c.write.setProjectTags(args.uuid, args.tags, opts)
1502
1666
  : await c.write.setTags(args.uuid, args.tags, opts));
1503
1667
  }));
1504
- server.registerTool("edit_checklist", {
1668
+ registerTool("edit_checklist", {
1505
1669
  description: "Edit a to-do's checklist. The single-item actions add / remove / check / uncheck " +
1506
1670
  "/ rename / move change one item — targeted by title or 1-based index — and leave " +
1507
1671
  "every other item and its checked state untouched (duplicate titles resolve " +
@@ -1591,11 +1755,16 @@ export function createThingsMcpServer(options = {}) {
1591
1755
  return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
1592
1756
  }));
1593
1757
  // ------------------------------------------------- to-dos AND projects
1594
- server.registerTool("delete", {
1758
+ registerTool("delete", {
1595
1759
  description: "Delete a to-do/project (kind item), an area, or a tag — kind selects which. " +
1596
1760
  "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1597
- "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1598
- "repeating to-dos). kind area: PERMANENT areas do not go to the Trash, so this cannot " +
1761
+ "the Trash is emptied; a deleted project takes its to-dos with it). Deleting a repeating " +
1762
+ "series' TEMPLATE is allowed and ends the series: it stops generating new occurrences and " +
1763
+ "its existing occurrences are left in place (the result names how many, and the current " +
1764
+ "one). That one cannot be undone — neither undo nor restore_item brings a template back, " +
1765
+ "so no undo token is returned; the series returns only via Put Back in the Things app's " +
1766
+ "Trash. Deleting a single occurrence leaves the series running. kind area: PERMANENT — " +
1767
+ "areas do not go to the Trash, so this cannot " +
1599
1768
  "be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
1600
1769
  "projects to the Trash, so a non-empty area is refused unless you pass " +
1601
1770
  "allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
@@ -1637,7 +1806,7 @@ export function createThingsMcpServer(options = {}) {
1637
1806
  return mutationResult(await c.write.deleteTag(args.uuid, opts));
1638
1807
  }
1639
1808
  }));
1640
- server.registerTool("restore_item", {
1809
+ registerTool("restore_item", {
1641
1810
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
1642
1811
  "previous schedule or project/area. A project is restored in place: its schedule, " +
1643
1812
  "area, and children come back exactly as they were.",
@@ -1655,11 +1824,11 @@ export function createThingsMcpServer(options = {}) {
1655
1824
  // write tools: add_todo / add_project (Logbook import), update (rewrite an
1656
1825
  // already-resolved item's timestamps), and set_status (backdate on
1657
1826
  // complete/cancel) — matching the CLI's --created-at/--completed-at flags.
1658
- server.registerTool("heading", {
1827
+ registerTool("heading", {
1659
1828
  description: "Manage a project's headings — action selects which; project is always required, and a " +
1660
1829
  "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
1661
1830
  "new heading in the project (project + title; returns its uuid; uses the Things proxy " +
1662
- "shortcuts, set up once with `things setup shortcuts`); a placement flag positions it, " +
1831
+ "shortcuts, set up once with `things setup`); a placement flag positions it, " +
1663
1832
  "else it appends. rename_heading: rename in place (project + heading + title; works on " +
1664
1833
  "archived headings). archive_heading: retire a heading so it leaves the active project " +
1665
1834
  "view (reversible with unarchive_heading); with open children pass children — complete " +
@@ -1669,11 +1838,15 @@ export function createThingsMcpServer(options = {}) {
1669
1838
  "promote_heading: promote a heading into a new project — this REPLACES the heading and " +
1670
1839
  "cannot be undone (its to-dos move under the new project), and requires " +
1671
1840
  "dangerously_drive_gui. move_heading: reposition headings as an ordered block (children " +
1672
- "follow); pass exactly one placement flag. Reordering headings needs allow-experimental. " +
1841
+ "follow); pass exactly one placement flag. GUI-only (requires dangerously_drive_gui) " +
1842
+ "Things offers heading order as keyboard shortcuts on a selected heading row and nowhere " +
1843
+ "else, so this moves one heading one slot at a time and re-reads the order after each " +
1844
+ "step; it refuses when the project holds a completed or canceled heading. " +
1673
1845
  "move_heading_to_project: relocate ONE heading (with its to-dos) to a DIFFERENT project " +
1674
1846
  "(project + heading + to_project) — the cross-project move, distinct from move_heading's " +
1675
1847
  "within-project reorder; GUI-only (requires dangerously_drive_gui), fails closed on a " +
1676
- "source-heading or destination-project title collision, and has no undo (move it back). " +
1848
+ "source-heading or destination-project title collision and on a completed or canceled " +
1849
+ "destination project (reopen it first), and has no undo (move it back). " +
1677
1850
  "dissolve_heading: remove a heading but KEEP its to-dos as direct project children (NOT " +
1678
1851
  "trashed — the opposite of a delete cascade); GUI-only (requires dangerously_drive_gui), " +
1679
1852
  "fails closed on a title collision, no undo.",
@@ -1820,7 +1993,7 @@ export function createThingsMcpServer(options = {}) {
1820
1993
  }
1821
1994
  }
1822
1995
  }));
1823
- server.registerTool("convert_to_project", {
1996
+ registerTool("convert_to_project", {
1824
1997
  description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
1825
1998
  "are kept); the to-do's identity is gone and it cannot be undone. Requires " +
1826
1999
  "dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
@@ -1834,7 +2007,7 @@ export function createThingsMcpServer(options = {}) {
1834
2007
  },
1835
2008
  annotations: DESTRUCTIVE,
1836
2009
  }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
1837
- server.registerTool("clear_reminder", {
2010
+ registerTool("clear_reminder", {
1838
2011
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1839
2012
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
1840
2013
  "to-do); otherwise a non-repeating dated to-do falls back to a URL re-schedule that " +
@@ -1921,7 +2094,7 @@ export function createThingsMcpServer(options = {}) {
1921
2094
  fields.startDaysEarlier = a.start_days_earlier;
1922
2095
  return fields;
1923
2096
  };
1924
- server.registerTool("repeat", {
2097
+ registerTool("repeat", {
1925
2098
  description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
1926
2099
  "interface — every action needs dangerously_drive_gui. action start: turn a plain item " +
1927
2100
  "into a repeating one (promote-via-clone: a disposable copy is promoted and the ORIGINAL " +
@@ -1934,7 +2107,9 @@ export function createThingsMcpServer(options = {}) {
1934
2107
  "pause/resume: stop or restart its new occurrences, keeping the rule. action add: create " +
1935
2108
  "an item and make it repeating in one call — it is created first and PERSISTS even if the " +
1936
2109
  "promote refuses (give a title; for a project give an area to place it or omit it to " +
1937
- "create in Someday); undo removes the created series. Returns the new template's uuid.",
2110
+ "create in Someday); undo removes the created series. Returns the new template's uuid. " +
2111
+ "Every start/add re-run makes ANOTHER series, so pass op_id when retrying: a resubmission " +
2112
+ "with the same key replays the first result instead of making a second series.",
1938
2113
  inputSchema: {
1939
2114
  scope: z.enum(["todo", "project"]),
1940
2115
  action: z.enum(["start", "reschedule", "pause", "resume", "add"]),
@@ -1943,15 +2118,16 @@ export function createThingsMcpServer(options = {}) {
1943
2118
  .optional()
1944
2119
  .describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
1945
2120
  title: z.string().optional().describe("add: the new item's title"),
1946
- notes: z.string().optional().describe("add: notes"),
2121
+ notes: z.string().optional().describe(`add: notes — ${NOTES_FORMAT}`),
1947
2122
  area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
1948
2123
  project_deadline: z
1949
2124
  .string()
1950
2125
  .optional()
1951
- .describe(`add: due date (${DATE_FORMAT}). For a to-do it deadlines EVERY occurrence — each ` +
1952
- `starts (deadline − when) days before its own deadline, so it needs a concrete when ` +
1953
- `on or before it (an alternative spelling of start_days_earlier give one or the ` +
1954
- `other, both must agree). For a project it is the project's own due date.`),
2126
+ .describe(`add: due date (${DATE_FORMAT}). It deadlines EVERY occurrence — each starts ` +
2127
+ `(deadline − when) days before its own deadline, so it needs a concrete when on or ` +
2128
+ `before it. For a to-do it is an alternative spelling of start_days_earlier (give ` +
2129
+ `one or the other, both must agree). Not available with after_completion on a ` +
2130
+ `project, whose occurrences are created without a deadline.`),
1955
2131
  todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
1956
2132
  frequency: z
1957
2133
  .enum(["daily", "weekly", "monthly", "yearly"])
@@ -1978,6 +2154,7 @@ export function createThingsMcpServer(options = {}) {
1978
2154
  ...driveGuiShape,
1979
2155
  ...dryRunShape,
1980
2156
  ...preserveModifiedShape,
2157
+ ...opIdShape,
1981
2158
  },
1982
2159
  annotations: DESTRUCTIVE,
1983
2160
  }, async (args) => guard(async () => {
@@ -1992,10 +2169,10 @@ export function createThingsMcpServer(options = {}) {
1992
2169
  }
1993
2170
  // add-repeating carries only the calendar-anchor rule fields here; the rule
1994
2171
  // reminder is set with a follow-up reschedule. A concrete `project_deadline`
1995
- // maps to the RULE for a to-do (DBLSPAWN1 — each occurrence deadlined, seed
1996
- // deadline-free) and to the project's own due date for a project. For a to-do,
1997
- // `start_days_earlier` is the alternative spelling of that deadline geometry
1998
- // (the two must AGREE when both are given — enforced in the library).
2172
+ // maps to the RULE on BOTH scopes (DBLSPAWN1 — each occurrence deadlined,
2173
+ // the created item deadline-free). For a to-do, `start_days_earlier` is the
2174
+ // alternative spelling of that deadline geometry (the two must AGREE when
2175
+ // both are given — enforced in the library).
1999
2176
  const { reminder: _reminder, deadline: _deadline, startDaysEarlier, ...ruleExtras } = repeatExtras(args, frequency);
2000
2177
  if (args.scope === "todo") {
2001
2178
  return mutationResult(await c.write.addRepeatingTodo({
@@ -2049,7 +2226,7 @@ export function createThingsMcpServer(options = {}) {
2049
2226
  // scope project, action start → the dedicated make-repeating method
2050
2227
  return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
2051
2228
  }));
2052
- server.registerTool("duplicate_item", {
2229
+ registerTool("duplicate_item", {
2053
2230
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
2054
2231
  "includes its children. Not available for repeating items.",
2055
2232
  inputSchema: { uuid: z.string(), ...dryRunShape, ...preserveModifiedShape, ...opIdShape },
@@ -2060,7 +2237,7 @@ export function createThingsMcpServer(options = {}) {
2060
2237
  ? await c.write.duplicateTodo(args.uuid, writeOptions(args))
2061
2238
  : await c.write.duplicateProject(args.uuid, writeOptions(args)));
2062
2239
  }));
2063
- server.registerTool("clone_item", {
2240
+ registerTool("clone_item", {
2064
2241
  description: "Clone a to-do or project — a faithful content copy through official write surfaces and " +
2065
2242
  "return the clone's uuid. Copies title, notes, tags, when, reminder, deadline, checklist " +
2066
2243
  "items and their checked state, container, and completed/canceled state with the exact " +
@@ -2097,12 +2274,12 @@ export function createThingsMcpServer(options = {}) {
2097
2274
  : await c.write.cloneProject(args.uuid, clone, writeOptions(args)));
2098
2275
  }));
2099
2276
  // -------------------------------------------------------------- projects
2100
- server.registerTool("add_project", {
2277
+ registerTool("add_project", {
2101
2278
  description: "Create a project and return its uuid. Optionally place it in an area, schedule " +
2102
2279
  "it, set a deadline, and seed it with initial to-dos.",
2103
2280
  inputSchema: {
2104
2281
  title: z.string(),
2105
- notes: z.string().optional(),
2282
+ notes: z.string().optional().describe(`Notes body — ${NOTES_FORMAT}`),
2106
2283
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
2107
2284
  when: whenSchema,
2108
2285
  deadline: z.string().optional().describe(DATE_FORMAT),
@@ -2137,7 +2314,7 @@ export function createThingsMcpServer(options = {}) {
2137
2314
  ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
2138
2315
  }, writeOptions(args)));
2139
2316
  }));
2140
- server.registerTool("move_project", {
2317
+ registerTool("move_project", {
2141
2318
  description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
2142
2319
  "destination: to_area, or no_area (leave the area — a project's complete detach). To " +
2143
2320
  "REORDER projects among their siblings without changing area, pass a position " +
@@ -2172,7 +2349,7 @@ export function createThingsMcpServer(options = {}) {
2172
2349
  return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
2173
2350
  }));
2174
2351
  // ----------------------------------------------------------------- areas
2175
- server.registerTool("add_area", {
2352
+ registerTool("add_area", {
2176
2353
  description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
2177
2354
  inputSchema: {
2178
2355
  title: z.string(),
@@ -2185,7 +2362,7 @@ export function createThingsMcpServer(options = {}) {
2185
2362
  annotations: NON_DESTRUCTIVE,
2186
2363
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
2187
2364
  // ------------------------------------------------------------------ tags
2188
- server.registerTool("add_tag", {
2365
+ registerTool("add_tag", {
2189
2366
  description: "Create a tag, optionally nested under an existing parent tag.",
2190
2367
  inputSchema: {
2191
2368
  title: z.string(),
@@ -2196,7 +2373,7 @@ export function createThingsMcpServer(options = {}) {
2196
2373
  },
2197
2374
  annotations: NON_DESTRUCTIVE,
2198
2375
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
2199
- server.registerTool("log_now", {
2376
+ registerTool("log_now", {
2200
2377
  description: "Move completed items to the Logbook now. The result discloses how many items were moved " +
2201
2378
  "(observed.logged); when none are waiting it logs nothing — a clean no-op, not an error. " +
2202
2379
  "This cannot be undone.",
@@ -2204,10 +2381,13 @@ export function createThingsMcpServer(options = {}) {
2204
2381
  annotations: DESTRUCTIVE,
2205
2382
  }, async (args) => guard(async () => mutationResult(await getClient().write.logNow(writeOptions(args)))));
2206
2383
  // -------------------------------------------------- generic + discovery
2207
- server.registerTool("run_operation", {
2384
+ registerTool("run_operation", {
2208
2385
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
2209
2386
  "dedicated tool (e.g. trash.empty). Call capabilities first for the catalog of " +
2210
- "operation kinds and their parameter shapes.",
2387
+ "operation kinds and their parameter shapes (each entry carries a params list naming " +
2388
+ "every field, whether it is optional, and the shape it accepts). A malformed params " +
2389
+ "bag — an unknown field, a wrong type, a bare string where a container reference " +
2390
+ "object belongs — is refused before anything runs.",
2211
2391
  inputSchema: {
2212
2392
  op: z.enum(OPERATION_KINDS),
2213
2393
  params: z
@@ -2233,11 +2413,13 @@ export function createThingsMcpServer(options = {}) {
2233
2413
  },
2234
2414
  annotations: DESTRUCTIVE,
2235
2415
  }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
2236
- server.registerTool("batch", {
2416
+ registerTool("batch", {
2237
2417
  description: "Run several operations in order, each independently — there are no transactions, " +
2238
2418
  "and a failure does not roll back earlier operations. A statically-invalid operation " +
2239
- "(bad shape, unknown op, a $ref to an undeclared/forward temp_id, a duplicate temp_id) " +
2240
- "refuses the WHOLE batch before anything runs, naming every bad operation. Otherwise " +
2419
+ "(unknown op, an unknown or wrongly-typed param, a $ref to an undeclared/forward " +
2420
+ "temp_id, a duplicate temp_id) refuses the WHOLE batch before anything runs, naming " +
2421
+ "every bad operation and the field it names. A container param takes an object " +
2422
+ '({"project": {"uuid": "…"}}), never a bare string. Otherwise ' +
2241
2423
  "per-operation results return in order. By DEFAULT a runtime failure STOPS the batch " +
2242
2424
  "(later operations reported not-run, with resume guidance in the summary); " +
2243
2425
  "continue_on_error runs past failures. " +
@@ -2248,8 +2430,11 @@ export function createThingsMcpServer(options = {}) {
2248
2430
  "reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
2249
2431
  "safe — an operation matching an earlier success is reported already-applied, not " +
2250
2432
  "re-created (put an op_id on EVERY operation so a stopped batch can be resubmitted " +
2251
- "verbatim to resume). The result adds temp_id_mapping (handle uuid) and undo_token, " +
2252
- "which reverses the whole batch as one unit via the undo tool.",
2433
+ "verbatim to resume). TIMELINE: preserve_modified applies to EVERY operation (an " +
2434
+ "operation may override it with its own options.preserve_modified), so a bulk re-tag " +
2435
+ "stays off the modification-date timeline under one undo token. The result adds " +
2436
+ "temp_id_mapping (handle → uuid) and undo_token, which reverses the whole batch as one " +
2437
+ "unit via the undo tool.",
2253
2438
  inputSchema: {
2254
2439
  ops: z
2255
2440
  .array(z.object({
@@ -2271,6 +2456,11 @@ export function createThingsMcpServer(options = {}) {
2271
2456
  acknowledge_tag_subtree: z.boolean().optional(),
2272
2457
  allow_non_empty_area: z.boolean().optional(),
2273
2458
  dangerously_drive_gui: z.boolean().optional(),
2459
+ preserve_modified: z
2460
+ .boolean()
2461
+ .optional()
2462
+ .describe("Keep THIS operation off the modification-date timeline; overrides the " +
2463
+ "run-level preserve_modified (false opts one operation back on)"),
2274
2464
  })
2275
2465
  .optional(),
2276
2466
  }))
@@ -2297,6 +2487,12 @@ export function createThingsMcpServer(options = {}) {
2297
2487
  ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
2298
2488
  ...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
2299
2489
  ...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
2490
+ // Per-op override of the run-level preserve_modified; passed through
2491
+ // when EXPLICIT (including false, which opts one op back onto the
2492
+ // timeline) so the batch engine's `??` precedence sees it.
2493
+ ...(o?.preserve_modified !== undefined && {
2494
+ preserveModified: o.preserve_modified,
2495
+ }),
2300
2496
  ...(ceiling !== undefined && { maxDisruption: ceiling }),
2301
2497
  };
2302
2498
  return {
@@ -2310,6 +2506,7 @@ export function createThingsMcpServer(options = {}) {
2310
2506
  const batchResult = await getClient().write.batch(ops, {
2311
2507
  ...(args.dry_run === true && { dryRun: true }),
2312
2508
  ...(args.continue_on_error === true && { continueOnError: true }),
2509
+ ...(args.preserve_modified === true && { preserveModified: true }),
2313
2510
  actor: mcpActor(),
2314
2511
  });
2315
2512
  // First block: the per-op results, each FLATTENED to the wire shape (a
@@ -2340,7 +2537,7 @@ export function createThingsMcpServer(options = {}) {
2340
2537
  ],
2341
2538
  };
2342
2539
  }));
2343
- server.registerTool("reorder", {
2540
+ registerTool("reorder", {
2344
2541
  description: "The ONE reorder tool — rearrange a single-KIND set IN PLACE: to-dos, projects, headings, " +
2345
2542
  "OR sidebar areas. This REARRANGES, never changes what an item belongs to (to change " +
2346
2543
  "membership use move_todo / move_project). All refs must be one kind; only to-dos and " +
@@ -2350,9 +2547,11 @@ export function createThingsMcpServer(options = {}) {
2350
2547
  "assembles the named items as a block at the EARLIEST one's current slot; start/end/" +
2351
2548
  "before/after position the block. A Today or This Evening member also holds a slot in its " +
2352
2549
  "container, so a set coherent on BOTH axes is ambiguous — pass `in` to name the axis. " +
2353
- "HEADINGS: same-project heading re-ranking runs the native heading-block wire (children " +
2354
- "follow); an archived heading is reorderable but repositioning it brings it back to open " +
2355
- "(disclosed in the result). AREAS: this drives the local Things app (sidebar drag) and " +
2550
+ "HEADINGS: same-project heading re-ranking drives the local Things app (keyboard " +
2551
+ "shortcuts on the heading row; children follow) and must be turned on the same way as " +
2552
+ "areas below. It covers the open headings only a completed or canceled heading is not " +
2553
+ "shown in the project view, so it is stepped over and cannot be moved or used as an " +
2554
+ "anchor. AREAS: this drives the local Things app (sidebar drag) and " +
2356
2555
  "must be turned on with `things config set ui-enabled true` plus dangerously_drive_gui; " +
2357
2556
  "the areas' projects and to-dos are untouched. Ordering the Today, Inbox, or Someday " +
2358
2557
  "lists, or a project's to-dos, must first be enabled once via `things config set " +
@@ -2390,7 +2589,7 @@ export function createThingsMcpServer(options = {}) {
2390
2589
  };
2391
2590
  return moveResult(await getClient().write.reorderAny(request, writeOptions(args)));
2392
2591
  }));
2393
- server.registerTool("undo", {
2592
+ registerTool("undo", {
2394
2593
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +
2395
2594
  "cannot be undone here). By default this undoes only changes made through THIS " +
2396
2595
  "connection — this client's own writes; it will not touch the user's own edits, or " +
@@ -2453,7 +2652,7 @@ export function createThingsMcpServer(options = {}) {
2453
2652
  });
2454
2653
  return jsonResult(items);
2455
2654
  }));
2456
- server.registerTool("capabilities", {
2655
+ registerTool("capabilities", {
2457
2656
  description: "Support reference for every operation kind usable with run_operation and batch: " +
2458
2657
  "whether it is available, its caveats, and the confirmation parameters it needs.",
2459
2658
  inputSchema: {
@@ -2464,7 +2663,7 @@ export function createThingsMcpServer(options = {}) {
2464
2663
  },
2465
2664
  annotations: READ_ONLY,
2466
2665
  }, async (args) => guard(() => jsonResult(capabilitiesTable(args.op))));
2467
- server.registerTool("doctor", {
2666
+ registerTool("doctor", {
2468
2667
  description: "Check the environment: whether the Things app and its database are reachable, " +
2469
2668
  "whether changes can be made, any one-time setup still needed (macOS permissions, " +
2470
2669
  "the app's 'Enable Things URLs' setting), whether the environment changed since " +
@@ -2488,7 +2687,7 @@ export function createThingsMcpServer(options = {}) {
2488
2687
  ? jsonResult(report)
2489
2688
  : errorResult(error ?? { code: "unexpected", message: "no report" });
2490
2689
  }));
2491
- server.registerTool("op_result", {
2690
+ registerTool("op_result", {
2492
2691
  description: "Look up what happened to a write you dispatched with op_id, from the local change history " +
2493
2692
  "alone (opens no database, changes nothing). Use it to recover the outcome when the write " +
2494
2693
  "call was interrupted before it returned: report FOUND (the final result + target + " +