things-api 0.18.0 → 0.19.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 (191) hide show
  1. package/README.md +16 -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 +13 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +160 -0
  22. package/dist/capability.js +416 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.js +127 -1
  25. package/dist/cli/commands/doctor.js.map +1 -1
  26. package/dist/cli/commands/helpers.js +178 -51
  27. package/dist/cli/commands/helpers.js.map +1 -1
  28. package/dist/cli/commands/op-result.js +6 -0
  29. package/dist/cli/commands/op-result.js.map +1 -1
  30. package/dist/cli/commands/reads.js +47 -1
  31. package/dist/cli/commands/reads.js.map +1 -1
  32. package/dist/cli/commands/setup.d.ts +9 -0
  33. package/dist/cli/commands/setup.js +63 -83
  34. package/dist/cli/commands/setup.js.map +1 -1
  35. package/dist/cli/commands/writes.d.ts +1 -1
  36. package/dist/cli/commands/writes.js +241 -107
  37. package/dist/cli/commands/writes.js.map +1 -1
  38. package/dist/cli/dry-run.d.ts +1 -1
  39. package/dist/cli/dry-run.js +1 -1
  40. package/dist/cli/glyphs.d.ts +23 -4
  41. package/dist/cli/glyphs.js +46 -19
  42. package/dist/cli/glyphs.js.map +1 -1
  43. package/dist/cli/help.d.ts +1 -1
  44. package/dist/cli/help.js +49 -27
  45. package/dist/cli/help.js.map +1 -1
  46. package/dist/cli/period.d.ts +3 -1
  47. package/dist/cli/period.js +1 -1
  48. package/dist/cli/period.js.map +1 -1
  49. package/dist/cli/read-driver.js +26 -1
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +31 -4
  52. package/dist/cli/render.js +127 -15
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/client.d.ts +22 -2
  55. package/dist/client.js +36 -13
  56. package/dist/client.js.map +1 -1
  57. package/dist/contracts.d.ts +6 -2
  58. package/dist/contracts.js +1 -1
  59. package/dist/contracts.js.map +1 -1
  60. package/dist/deputy/install.d.ts +219 -5
  61. package/dist/deputy/install.js +803 -31
  62. package/dist/deputy/install.js.map +1 -1
  63. package/dist/deputy/notices.js +2 -2
  64. package/dist/deputy/notices.js.map +1 -1
  65. package/dist/deputy/protocol.d.ts +59 -5
  66. package/dist/deputy/protocol.js +48 -11
  67. package/dist/deputy/protocol.js.map +1 -1
  68. package/dist/deputy/routing.d.ts +21 -0
  69. package/dist/deputy/routing.js +111 -27
  70. package/dist/deputy/routing.js.map +1 -1
  71. package/dist/diagnose.d.ts +14 -1
  72. package/dist/diagnose.js +46 -6
  73. package/dist/diagnose.js.map +1 -1
  74. package/dist/direct-setup.d.ts +78 -0
  75. package/dist/direct-setup.js +484 -0
  76. package/dist/direct-setup.js.map +1 -0
  77. package/dist/host-access.d.ts +63 -0
  78. package/dist/host-access.js +165 -0
  79. package/dist/host-access.js.map +1 -0
  80. package/dist/index.d.ts +20 -5
  81. package/dist/index.js +14 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/mcp/server.d.ts +27 -1
  84. package/dist/mcp/server.js +285 -100
  85. package/dist/mcp/server.js.map +1 -1
  86. package/dist/op-result.d.ts +9 -0
  87. package/dist/op-result.js +5 -0
  88. package/dist/op-result.js.map +1 -1
  89. package/dist/read/filter-contract.d.ts +1 -1
  90. package/dist/read/filter-contract.js +4 -0
  91. package/dist/read/filter-contract.js.map +1 -1
  92. package/dist/read/shape.js +4 -0
  93. package/dist/read/shape.js.map +1 -1
  94. package/dist/read/views.d.ts +29 -0
  95. package/dist/read/views.js +60 -0
  96. package/dist/read/views.js.map +1 -1
  97. package/dist/session-grant.d.ts +48 -0
  98. package/dist/session-grant.js +170 -0
  99. package/dist/session-grant.js.map +1 -0
  100. package/dist/surface-copy.d.ts +8 -0
  101. package/dist/surface-copy.js +8 -0
  102. package/dist/surface-copy.js.map +1 -1
  103. package/dist/wizard.d.ts +58 -0
  104. package/dist/wizard.js +167 -0
  105. package/dist/wizard.js.map +1 -0
  106. package/dist/write/availability.d.ts +1 -1
  107. package/dist/write/availability.js +3 -3
  108. package/dist/write/availability.js.map +1 -1
  109. package/dist/write/batch.d.ts +20 -0
  110. package/dist/write/batch.js +94 -15
  111. package/dist/write/batch.js.map +1 -1
  112. package/dist/write/capabilities.d.ts +9 -0
  113. package/dist/write/capabilities.js +2 -0
  114. package/dist/write/capabilities.js.map +1 -1
  115. package/dist/write/clear-reminder.js +1 -1
  116. package/dist/write/clear-reminder.js.map +1 -1
  117. package/dist/write/commands.d.ts +12 -1
  118. package/dist/write/commands.js +112 -224
  119. package/dist/write/commands.js.map +1 -1
  120. package/dist/write/failure-hints.d.ts +3 -1
  121. package/dist/write/failure-hints.js.map +1 -1
  122. package/dist/write/guards.js +19 -2
  123. package/dist/write/guards.js.map +1 -1
  124. package/dist/write/lock.d.ts +16 -13
  125. package/dist/write/lock.js +55 -0
  126. package/dist/write/lock.js.map +1 -1
  127. package/dist/write/make-repeating-project.js +9 -13
  128. package/dist/write/make-repeating-project.js.map +1 -1
  129. package/dist/write/operations.d.ts +36 -21
  130. package/dist/write/operations.js +7 -0
  131. package/dist/write/operations.js.map +1 -1
  132. package/dist/write/opid.d.ts +14 -0
  133. package/dist/write/opid.js +22 -0
  134. package/dist/write/opid.js.map +1 -1
  135. package/dist/write/param-schema.d.ts +124 -0
  136. package/dist/write/param-schema.js +747 -0
  137. package/dist/write/param-schema.js.map +1 -0
  138. package/dist/write/pipeline.d.ts +76 -2
  139. package/dist/write/pipeline.js +190 -2
  140. package/dist/write/pipeline.js.map +1 -1
  141. package/dist/write/pre-state.d.ts +8 -0
  142. package/dist/write/pre-state.js +29 -2
  143. package/dist/write/pre-state.js.map +1 -1
  144. package/dist/write/promote-clone.js +539 -439
  145. package/dist/write/promote-clone.js.map +1 -1
  146. package/dist/write/reorder.js +25 -8
  147. package/dist/write/reorder.js.map +1 -1
  148. package/dist/write/repeat-rule.d.ts +28 -1
  149. package/dist/write/repeat-rule.js +54 -4
  150. package/dist/write/repeat-rule.js.map +1 -1
  151. package/dist/write/resolution-timestamps.d.ts +11 -0
  152. package/dist/write/resolution-timestamps.js +83 -38
  153. package/dist/write/resolution-timestamps.js.map +1 -1
  154. package/dist/write/reversibility.js +4 -0
  155. package/dist/write/reversibility.js.map +1 -1
  156. package/dist/write/template-mutation.d.ts +137 -0
  157. package/dist/write/template-mutation.js +451 -0
  158. package/dist/write/template-mutation.js.map +1 -0
  159. package/dist/write/undo.js +10 -20
  160. package/dist/write/undo.js.map +1 -1
  161. package/dist/write/update-fields.d.ts +151 -0
  162. package/dist/write/update-fields.js +427 -0
  163. package/dist/write/update-fields.js.map +1 -0
  164. package/dist/write/vectors/applescript.js +3 -0
  165. package/dist/write/vectors/applescript.js.map +1 -1
  166. package/dist/write/vectors/registry.d.ts +1 -1
  167. package/dist/write/vectors/registry.js +10 -2
  168. package/dist/write/vectors/registry.js.map +1 -1
  169. package/dist/write/vectors/shortcuts.js +2 -2
  170. package/dist/write/vectors/shortcuts.js.map +1 -1
  171. package/dist/write/vectors/simulator.js +7 -2
  172. package/dist/write/vectors/simulator.js.map +1 -1
  173. package/dist/write/vectors/types.d.ts +57 -0
  174. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  175. package/dist/write/vectors/ui-recipes.js +30 -0
  176. package/dist/write/vectors/ui-recipes.js.map +1 -1
  177. package/dist/write/vectors/ui.d.ts +13 -0
  178. package/dist/write/vectors/ui.js +62 -13
  179. package/dist/write/vectors/ui.js.map +1 -1
  180. package/dist/write/verify/delta.d.ts +21 -0
  181. package/dist/write/verify/delta.js.map +1 -1
  182. package/package.json +2 -1
  183. package/schema/envelope.schema.json +5 -3
  184. package/scripts/build-helpers.sh +5 -0
  185. package/skills/things-cli/SKILL.md +5 -3
  186. package/skills/things-cli/references/contracts.md +1 -0
  187. package/skills/things-cli/references/data-model.md +3 -0
  188. package/skills/things-cli/references/errors.md +1 -1
  189. package/skills/things-cli/references/gui.md +1 -0
  190. package/skills/things-cli/references/repeating.md +6 -0
  191. 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
  }
@@ -402,7 +459,7 @@ const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`
402
459
  * names without a discovery round-trip. Degrades to conventions-only when
403
460
  * the database is not readable.
404
461
  */
405
- function buildInstructions(getClient) {
462
+ function buildInstructions(getClient, capability) {
406
463
  const lines = [
407
464
  "This server reads and modifies the user's Things 3 data: to-dos, projects, areas, and tags.",
408
465
  "",
@@ -418,6 +475,8 @@ function buildInstructions(getClient) {
418
475
  `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
419
476
  `are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
420
477
  `below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
478
+ `- Notes vocabulary: the notes field is ${NOTES_FORMAT}. Newlines are kept as written, so ` +
479
+ `pass a multi-line body directly rather than flattening it into one line.`,
421
480
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
422
481
  "A preview creates no state, so later calls cannot reference an item that only appeared in " +
423
482
  "a dry-run result. Operations with cascading or permanent effects require the explicit " +
@@ -430,6 +489,27 @@ function buildInstructions(getClient) {
430
489
  "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
431
490
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
432
491
  ];
492
+ // What this server may do, decided once at startup and true for its whole
493
+ // life (permissions doctrine, Article II). Stated up front so an agent plans
494
+ // around the real surface instead of discovering it one refusal at a time.
495
+ const available = availableClasses(capability);
496
+ const missing = [];
497
+ if (!readAllowed(capability.read))
498
+ missing.push(`reads — ${capability.read.detail}`);
499
+ if (!writeAllowed(capability.write))
500
+ missing.push(`changes — ${capability.write.detail}`);
501
+ if (!uiAllowed(capability.ui))
502
+ missing.push(`GUI-driven operations — ${capability.ui.detail}`);
503
+ 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
504
+ ? [
505
+ `- Unavailable here: ${missing.join("; ")}.`,
506
+ "- Tools needing an unavailable capability refuse immediately rather than trying and " +
507
+ "raising a macOS permission dialog nobody is present to answer. Remediation for each " +
508
+ "is in its refusal. These grants attach to whichever process launched this server, so " +
509
+ "they cannot change while it runs: the fix is always stop the server, run the named " +
510
+ "setup command at the machine, start it again.",
511
+ ]
512
+ : ["- Nothing is missing; no tool will refuse for want of a permission."]));
433
513
  try {
434
514
  const c = getClient();
435
515
  // Under a container scope the inventory below is already limited to in-scope
@@ -499,7 +579,83 @@ export function createThingsMcpServer(options = {}) {
499
579
  });
500
580
  return client;
501
581
  };
502
- const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
582
+ // THE STARTUP BAKE (permissions doctrine, Article II). One prompt-free survey
583
+ // before the transport is connected; the verdict then shapes the instructions
584
+ // and gates the tools, and is never re-taken.
585
+ const capability = options.capability ?? {
586
+ read: readCapability(options.dbPath !== undefined ? { dbPath: options.dbPath } : {}),
587
+ write: writeCapability(),
588
+ ui: uiCapability(),
589
+ };
590
+ const warn = options.onStartupWarning ?? ((line) => process.stderr.write(`${line}\n`));
591
+ const unavailable = [
592
+ ...(readAllowed(capability.read) ? [] : ["reads"]),
593
+ ...(writeAllowed(capability.write) ? [] : ["changes"]),
594
+ ...(uiAllowed(capability.ui) ? [] : ["GUI-driven operations"]),
595
+ ];
596
+ if (unavailable.length > 0) {
597
+ warn(`things-api MCP: ${unavailable.join(", ")} unavailable on this machine — ` +
598
+ `${capability.read.detail}; ${capability.write.detail}; ${capability.ui.detail}. ` +
599
+ "Tools needing them refuse; restart this server after running setup.");
600
+ }
601
+ const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient, capability) });
602
+ /**
603
+ * The baked refusal for one tool class, or null when the class is served.
604
+ * Article II in one function: the answer is the STARTUP verdict, quoted, with
605
+ * the setup command and the restart rule — never a fresh probe, and never a
606
+ * dispatch that could put a dialog on screen.
607
+ */
608
+ const bakedRefusal = (name) => {
609
+ switch (toolClass(name)) {
610
+ case "none":
611
+ return null;
612
+ case "read":
613
+ return readAllowed(capability.read)
614
+ ? null
615
+ : errorResult({
616
+ code: "environment",
617
+ message: `this tool reads the Things database, and ${capability.read.detail}`,
618
+ remediation: [...capability.read.remediation, RESTART_REMEDIATION].join("; "),
619
+ });
620
+ case "write":
621
+ // A write read-verifies, so it needs both classes; name the one that
622
+ // is actually missing rather than a generic "cannot write".
623
+ if (!readAllowed(capability.read)) {
624
+ return errorResult({
625
+ code: "environment",
626
+ message: `this tool changes Things data and verifies the result by reading it back, and ${capability.read.detail}`,
627
+ remediation: [...capability.read.remediation, RESTART_REMEDIATION].join("; "),
628
+ });
629
+ }
630
+ return writeAllowed(capability.write)
631
+ ? null
632
+ : errorResult({
633
+ code: "environment",
634
+ message: `this tool changes Things data by driving the app, and ${capability.write.detail}`,
635
+ remediation: [...capability.write.remediation, RESTART_REMEDIATION].join("; "),
636
+ });
637
+ case "ui":
638
+ return uiAllowed(capability.ui)
639
+ ? null
640
+ : errorResult({
641
+ code: "environment",
642
+ message: `this tool is delivered by driving the Things window, and ${capability.ui.detail}`,
643
+ remediation: [...capability.ui.remediation, RESTART_REMEDIATION].join("; "),
644
+ });
645
+ }
646
+ };
647
+ /**
648
+ * `server.registerTool` with the baked gate in front of every callback. Every
649
+ * registration below goes through this, so a tool cannot be added without a
650
+ * class — {@link toolClass} defaults an unlisted name to `read`, the weakest
651
+ * useful assumption, rather than to ungated.
652
+ */
653
+ const registerTool = ((name, config, cb) => server.registerTool(name, config, ((...args) => {
654
+ const refusal = bakedRefusal(name);
655
+ if (refusal !== null)
656
+ return refusal;
657
+ return cb(...args);
658
+ })));
503
659
  // The audit author for every write on this connection, derived once from the
504
660
  // client's handshake identity (clientInfo.name). Read per call: clientInfo is
505
661
  // populated when the initialize handshake completes, before any tool can run.
@@ -608,14 +764,18 @@ export function createThingsMcpServer(options = {}) {
608
764
  return item.type;
609
765
  };
610
766
  // ------------------------------------------------------------------ reads
611
- server.registerTool("read_view", {
767
+ registerTool("read_view", {
612
768
  description: "Read a Things list as the app presents it: today (two children buckets — " +
613
769
  "children.today and children.evening, evening expires daily; the whole-view " +
614
770
  "count due/overdue vs. other rides the result's second block), inbox, anytime, " +
615
771
  "upcoming, someday, logbook, trash, or deadlines (a flat items list of everything " +
616
772
  "carrying a deadline — to-dos and projects — in deadline order, most-overdue first; " +
617
773
  "repeating items appear at their next occurrence's projected deadline; scope with " +
618
- "today/overdue/project/area/tag). For upcoming, " +
774
+ "today/overdue/project/area/tag), or repeaters (every repeating series in the " +
775
+ "library — to-dos and projects — each carrying its decoded rule under " +
776
+ "repeating.rule; series appear in no other view, which show the occurrences a " +
777
+ "series spawns instead; paused and ended series are included; ordered by next " +
778
+ "occurrence, the ones with none last). For upcoming, " +
619
779
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
620
780
  "anytime/someday return sections in canonical order (area + items; null area = the " +
621
781
  "top-level block); children of someday/future-scheduled projects are excluded " +
@@ -639,6 +799,7 @@ export function createThingsMcpServer(options = {}) {
639
799
  "logbook",
640
800
  "trash",
641
801
  "deadlines",
802
+ "repeaters",
642
803
  ]),
643
804
  ...tagFilterShape,
644
805
  ...tzShape,
@@ -854,10 +1015,14 @@ export function createThingsMcpServer(options = {}) {
854
1015
  });
855
1016
  return truncatedResult(shapeReadPayload("deadlines", items, full, c.refPromoter()), truncation);
856
1017
  }
1018
+ case "repeaters": {
1019
+ const { items, truncation } = c.read.repeaters({ ...filter, ...zone, limit });
1020
+ return truncatedResult(shapeReadPayload("repeaters", items, full, c.refPromoter()), truncation);
1021
+ }
857
1022
  }
858
1023
  }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
859
1024
  });
860
- server.registerTool("search", {
1025
+ registerTool("search", {
861
1026
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
862
1027
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
863
1028
  "references must name existing items. " +
@@ -920,7 +1085,7 @@ export function createThingsMcpServer(options = {}) {
920
1085
  });
921
1086
  return truncatedResult(shapeReadPayload("search", items, args.full === true, getClient().refPromoter()), truncation);
922
1087
  }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
923
- server.registerTool("changes_since", {
1088
+ registerTool("changes_since", {
924
1089
  description: "List items created or modified since a moment — including trashed, logged, and " +
925
1090
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
926
1091
  "areas, and checklist items do not mark the containing item as modified. " +
@@ -950,7 +1115,7 @@ export function createThingsMcpServer(options = {}) {
950
1115
  });
951
1116
  return truncatedResult(shapeReadPayload("changes", items, args.full === true, getClient().refPromoter()), truncation);
952
1117
  }, args.tz));
953
- server.registerTool("get_item", {
1118
+ registerTool("get_item", {
954
1119
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
955
1120
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
956
1121
  "project/area/heading. " +
@@ -965,7 +1130,7 @@ export function createThingsMcpServer(options = {}) {
965
1130
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
966
1131
  : readResult(shapeReadPayload("detail", item, false, getClient().refPromoter()));
967
1132
  }));
968
- server.registerTool("get_project", {
1133
+ registerTool("get_project", {
969
1134
  description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
970
1135
  "Open headings (and archived ones not yet swept to the logbook) stay live, carrying " +
971
1136
  "archived once archived; a swept archived heading moves into the logged region as a " +
@@ -997,7 +1162,7 @@ export function createThingsMcpServer(options = {}) {
997
1162
  ...(args.tz !== undefined && { zone: args.tz }),
998
1163
  }), args.full === true, getClient().refPromoter()));
999
1164
  }, args.tz));
1000
- server.registerTool("get_area", {
1165
+ registerTool("get_area", {
1001
1166
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
1002
1167
  "projects in canonical order, and later (scheduled/repeating/someday). The area " +
1003
1168
  "logbook is not returned here — read it with read_view logbook + area; trashed " +
@@ -1061,7 +1226,7 @@ export function createThingsMcpServer(options = {}) {
1061
1226
  return groupedResult(withAreaBucketTotals(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), totals), truncation);
1062
1227
  }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
1063
1228
  });
1064
- server.registerTool("list_collections", {
1229
+ registerTool("list_collections", {
1065
1230
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
1066
1231
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
1067
1232
  "the projects list by each project's own tags (areas/tags reject them). " +
@@ -1116,7 +1281,7 @@ export function createThingsMcpServer(options = {}) {
1116
1281
  }, args.tz));
1117
1282
  // ---------------------------------------------------------------- to-dos
1118
1283
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
1119
- server.registerTool("add_todo", {
1284
+ registerTool("add_todo", {
1120
1285
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
1121
1286
  "deadline, tag it, give it a checklist, and place it in a project or area " +
1122
1287
  "(optionally under a heading within that project). A reminder " +
@@ -1124,7 +1289,7 @@ export function createThingsMcpServer(options = {}) {
1124
1289
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
1125
1290
  inputSchema: {
1126
1291
  title: z.string(),
1127
- notes: z.string().optional(),
1292
+ notes: z.string().optional().describe(`Notes body — ${NOTES_FORMAT}`),
1128
1293
  when: whenSchema,
1129
1294
  reminder: z.string().optional().describe(REMINDER_FORMAT),
1130
1295
  deadline: z.string().optional().describe(DATE_FORMAT),
@@ -1177,7 +1342,7 @@ export function createThingsMcpServer(options = {}) {
1177
1342
  ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1178
1343
  }, writeOptions(args)));
1179
1344
  }));
1180
- server.registerTool("update", {
1345
+ registerTool("update", {
1181
1346
  description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
1182
1347
  "kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
1183
1348
  "existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
@@ -1198,7 +1363,10 @@ export function createThingsMcpServer(options = {}) {
1198
1363
  .describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
1199
1364
  "unique name"),
1200
1365
  title: z.string().optional().describe("New title (any kind)"),
1201
- notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
1366
+ notes: z
1367
+ .string()
1368
+ .optional()
1369
+ .describe(`todo/project: replaces the whole notes body — ${NOTES_FORMAT}`),
1202
1370
  append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
1203
1371
  prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
1204
1372
  when: whenSchema,
@@ -1220,6 +1388,17 @@ export function createThingsMcpServer(options = {}) {
1220
1388
  .array(z.string())
1221
1389
  .optional()
1222
1390
  .describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
1391
+ exception: z
1392
+ .boolean()
1393
+ .optional()
1394
+ .describe("todo, repeating only: change just the NEXT occurrence and leave the series alone " +
1395
+ "(the occurrence is created if it has not appeared yet). Refused when the series " +
1396
+ "already lands on the requested day, and when the series names no next date at " +
1397
+ "all (a paused one — resume it first). The result's occurrence field names both the occurrence " +
1398
+ "and the series. Each call takes another occurrence out of the series, so pass " +
1399
+ "op_id when retrying: a resubmission with the same key replays the first result " +
1400
+ "instead of creating a second occurrence. Undo restores the occurrence's own " +
1401
+ "change but cannot remove the occurrence or rewind the series"),
1223
1402
  parent: z.string().optional().describe("tag: existing tag to nest under"),
1224
1403
  unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
1225
1404
  shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
@@ -1238,49 +1417,23 @@ export function createThingsMcpServer(options = {}) {
1238
1417
  const opts = writeOptions(args);
1239
1418
  const c = getClient();
1240
1419
  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));
1420
+ // The patch and every exclusive pair in the vocabulary, the `@time`
1421
+ // sugar included comes from the ONE builder the CLI shares, so the two
1422
+ // surfaces cannot drift on which flags they accept (#491 doctrine).
1423
+ const built = buildUpdatePatch(args, MCP_UPDATE_LABELS);
1424
+ if (built.kind === "error")
1425
+ return usage(built.message);
1426
+ if (args.exception === true) {
1427
+ if (args.kind !== "todo")
1428
+ return usage("exception applies to kind todo");
1429
+ if (args.created_at !== undefined || args.completed_at !== undefined) {
1430
+ return usage("exception cannot be combined with created_at/completed_at");
1431
+ }
1432
+ return mutationResult(await c.write.updateTodoOccurrence(args.uuid, built.patch, opts));
1270
1433
  }
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));
1434
+ return mutationResult(args.kind === "todo"
1435
+ ? await c.write.updateTodo(args.uuid, built.patch, opts)
1436
+ : await c.write.updateProject(args.uuid, built.patch, opts));
1284
1437
  }
1285
1438
  if (args.kind === "area") {
1286
1439
  if (args.title === undefined && args.tags === undefined) {
@@ -1313,9 +1466,15 @@ export function createThingsMcpServer(options = {}) {
1313
1466
  ...(args.clear_shortcut === true && { clearShortcut: true }),
1314
1467
  }, opts));
1315
1468
  }));
1316
- server.registerTool("set_status", {
1469
+ registerTool("set_status", {
1317
1470
  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. " +
1471
+ "(reopening a completed/canceled item). scope todo, a REPEATING to-do: completed/canceled " +
1472
+ "resolves the series' CURRENT occurrence — the unfinished one if there is one, otherwise " +
1473
+ "the next one, created for the purpose — and leaves the series running; the result's " +
1474
+ "occurrence field names both uuids and says which of the two was created. Each call is a " +
1475
+ "new resolution that takes the FOLLOWING occurrence, so pass op_id when retrying: a " +
1476
+ "resubmission with the same key replays the first result instead of creating a second " +
1477
+ "occurrence. Reopening a repeating to-do is not available. " +
1319
1478
  "scope project, completing or canceling requires a children policy: 'require-resolved' " +
1320
1479
  "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
1321
1480
  "the project (canceling never alters already-completed children). scope project, status " +
@@ -1401,7 +1560,7 @@ export function createThingsMcpServer(options = {}) {
1401
1560
  ? jsonResult(outcome)
1402
1561
  : mutationResult(outcome.project);
1403
1562
  }));
1404
- server.registerTool("move_todo", {
1563
+ registerTool("move_todo", {
1405
1564
  description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
1406
1565
  "belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
1407
1566
  "without changing membership, call this with a position (first/last/before/after) and " +
@@ -1474,7 +1633,7 @@ export function createThingsMcpServer(options = {}) {
1474
1633
  };
1475
1634
  return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
1476
1635
  }));
1477
- server.registerTool("set_tags", {
1636
+ registerTool("set_tags", {
1478
1637
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
1479
1638
  "the given list — an empty list removes all tags; mode 'add' merges with the current " +
1480
1639
  "tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
@@ -1501,7 +1660,7 @@ export function createThingsMcpServer(options = {}) {
1501
1660
  ? await c.write.setProjectTags(args.uuid, args.tags, opts)
1502
1661
  : await c.write.setTags(args.uuid, args.tags, opts));
1503
1662
  }));
1504
- server.registerTool("edit_checklist", {
1663
+ registerTool("edit_checklist", {
1505
1664
  description: "Edit a to-do's checklist. The single-item actions add / remove / check / uncheck " +
1506
1665
  "/ rename / move change one item — targeted by title or 1-based index — and leave " +
1507
1666
  "every other item and its checked state untouched (duplicate titles resolve " +
@@ -1591,11 +1750,16 @@ export function createThingsMcpServer(options = {}) {
1591
1750
  return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
1592
1751
  }));
1593
1752
  // ------------------------------------------------- to-dos AND projects
1594
- server.registerTool("delete", {
1753
+ registerTool("delete", {
1595
1754
  description: "Delete a to-do/project (kind item), an area, or a tag — kind selects which. " +
1596
1755
  "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 " +
1756
+ "the Trash is emptied; a deleted project takes its to-dos with it). Deleting a repeating " +
1757
+ "series' TEMPLATE is allowed and ends the series: it stops generating new occurrences and " +
1758
+ "its existing occurrences are left in place (the result names how many, and the current " +
1759
+ "one). That one cannot be undone — neither undo nor restore_item brings a template back, " +
1760
+ "so no undo token is returned; the series returns only via Put Back in the Things app's " +
1761
+ "Trash. Deleting a single occurrence leaves the series running. kind area: PERMANENT — " +
1762
+ "areas do not go to the Trash, so this cannot " +
1599
1763
  "be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
1600
1764
  "projects to the Trash, so a non-empty area is refused unless you pass " +
1601
1765
  "allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
@@ -1637,7 +1801,7 @@ export function createThingsMcpServer(options = {}) {
1637
1801
  return mutationResult(await c.write.deleteTag(args.uuid, opts));
1638
1802
  }
1639
1803
  }));
1640
- server.registerTool("restore_item", {
1804
+ registerTool("restore_item", {
1641
1805
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
1642
1806
  "previous schedule or project/area. A project is restored in place: its schedule, " +
1643
1807
  "area, and children come back exactly as they were.",
@@ -1655,11 +1819,11 @@ export function createThingsMcpServer(options = {}) {
1655
1819
  // write tools: add_todo / add_project (Logbook import), update (rewrite an
1656
1820
  // already-resolved item's timestamps), and set_status (backdate on
1657
1821
  // complete/cancel) — matching the CLI's --created-at/--completed-at flags.
1658
- server.registerTool("heading", {
1822
+ registerTool("heading", {
1659
1823
  description: "Manage a project's headings — action selects which; project is always required, and a " +
1660
1824
  "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
1661
1825
  "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, " +
1826
+ "shortcuts, set up once with `things setup`); a placement flag positions it, " +
1663
1827
  "else it appends. rename_heading: rename in place (project + heading + title; works on " +
1664
1828
  "archived headings). archive_heading: retire a heading so it leaves the active project " +
1665
1829
  "view (reversible with unarchive_heading); with open children pass children — complete " +
@@ -1820,7 +1984,7 @@ export function createThingsMcpServer(options = {}) {
1820
1984
  }
1821
1985
  }
1822
1986
  }));
1823
- server.registerTool("convert_to_project", {
1987
+ registerTool("convert_to_project", {
1824
1988
  description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
1825
1989
  "are kept); the to-do's identity is gone and it cannot be undone. Requires " +
1826
1990
  "dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
@@ -1834,7 +1998,7 @@ export function createThingsMcpServer(options = {}) {
1834
1998
  },
1835
1999
  annotations: DESTRUCTIVE,
1836
2000
  }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
1837
- server.registerTool("clear_reminder", {
2001
+ registerTool("clear_reminder", {
1838
2002
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1839
2003
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
1840
2004
  "to-do); otherwise a non-repeating dated to-do falls back to a URL re-schedule that " +
@@ -1921,7 +2085,7 @@ export function createThingsMcpServer(options = {}) {
1921
2085
  fields.startDaysEarlier = a.start_days_earlier;
1922
2086
  return fields;
1923
2087
  };
1924
- server.registerTool("repeat", {
2088
+ registerTool("repeat", {
1925
2089
  description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
1926
2090
  "interface — every action needs dangerously_drive_gui. action start: turn a plain item " +
1927
2091
  "into a repeating one (promote-via-clone: a disposable copy is promoted and the ORIGINAL " +
@@ -1943,15 +2107,16 @@ export function createThingsMcpServer(options = {}) {
1943
2107
  .optional()
1944
2108
  .describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
1945
2109
  title: z.string().optional().describe("add: the new item's title"),
1946
- notes: z.string().optional().describe("add: notes"),
2110
+ notes: z.string().optional().describe(`add: notes — ${NOTES_FORMAT}`),
1947
2111
  area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
1948
2112
  project_deadline: z
1949
2113
  .string()
1950
2114
  .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.`),
2115
+ .describe(`add: due date (${DATE_FORMAT}). It deadlines EVERY occurrence — each starts ` +
2116
+ `(deadline − when) days before its own deadline, so it needs a concrete when on or ` +
2117
+ `before it. For a to-do it is an alternative spelling of start_days_earlier (give ` +
2118
+ `one or the other, both must agree). Not available with after_completion on a ` +
2119
+ `project, whose occurrences are created without a deadline.`),
1955
2120
  todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
1956
2121
  frequency: z
1957
2122
  .enum(["daily", "weekly", "monthly", "yearly"])
@@ -1992,10 +2157,10 @@ export function createThingsMcpServer(options = {}) {
1992
2157
  }
1993
2158
  // add-repeating carries only the calendar-anchor rule fields here; the rule
1994
2159
  // 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).
2160
+ // maps to the RULE on BOTH scopes (DBLSPAWN1 — each occurrence deadlined,
2161
+ // the created item deadline-free). For a to-do, `start_days_earlier` is the
2162
+ // alternative spelling of that deadline geometry (the two must AGREE when
2163
+ // both are given — enforced in the library).
1999
2164
  const { reminder: _reminder, deadline: _deadline, startDaysEarlier, ...ruleExtras } = repeatExtras(args, frequency);
2000
2165
  if (args.scope === "todo") {
2001
2166
  return mutationResult(await c.write.addRepeatingTodo({
@@ -2049,7 +2214,7 @@ export function createThingsMcpServer(options = {}) {
2049
2214
  // scope project, action start → the dedicated make-repeating method
2050
2215
  return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
2051
2216
  }));
2052
- server.registerTool("duplicate_item", {
2217
+ registerTool("duplicate_item", {
2053
2218
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
2054
2219
  "includes its children. Not available for repeating items.",
2055
2220
  inputSchema: { uuid: z.string(), ...dryRunShape, ...preserveModifiedShape, ...opIdShape },
@@ -2060,7 +2225,7 @@ export function createThingsMcpServer(options = {}) {
2060
2225
  ? await c.write.duplicateTodo(args.uuid, writeOptions(args))
2061
2226
  : await c.write.duplicateProject(args.uuid, writeOptions(args)));
2062
2227
  }));
2063
- server.registerTool("clone_item", {
2228
+ registerTool("clone_item", {
2064
2229
  description: "Clone a to-do or project — a faithful content copy through official write surfaces and " +
2065
2230
  "return the clone's uuid. Copies title, notes, tags, when, reminder, deadline, checklist " +
2066
2231
  "items and their checked state, container, and completed/canceled state with the exact " +
@@ -2097,12 +2262,12 @@ export function createThingsMcpServer(options = {}) {
2097
2262
  : await c.write.cloneProject(args.uuid, clone, writeOptions(args)));
2098
2263
  }));
2099
2264
  // -------------------------------------------------------------- projects
2100
- server.registerTool("add_project", {
2265
+ registerTool("add_project", {
2101
2266
  description: "Create a project and return its uuid. Optionally place it in an area, schedule " +
2102
2267
  "it, set a deadline, and seed it with initial to-dos.",
2103
2268
  inputSchema: {
2104
2269
  title: z.string(),
2105
- notes: z.string().optional(),
2270
+ notes: z.string().optional().describe(`Notes body — ${NOTES_FORMAT}`),
2106
2271
  area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
2107
2272
  when: whenSchema,
2108
2273
  deadline: z.string().optional().describe(DATE_FORMAT),
@@ -2137,7 +2302,7 @@ export function createThingsMcpServer(options = {}) {
2137
2302
  ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
2138
2303
  }, writeOptions(args)));
2139
2304
  }));
2140
- server.registerTool("move_project", {
2305
+ registerTool("move_project", {
2141
2306
  description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
2142
2307
  "destination: to_area, or no_area (leave the area — a project's complete detach). To " +
2143
2308
  "REORDER projects among their siblings without changing area, pass a position " +
@@ -2172,7 +2337,7 @@ export function createThingsMcpServer(options = {}) {
2172
2337
  return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
2173
2338
  }));
2174
2339
  // ----------------------------------------------------------------- areas
2175
- server.registerTool("add_area", {
2340
+ registerTool("add_area", {
2176
2341
  description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
2177
2342
  inputSchema: {
2178
2343
  title: z.string(),
@@ -2185,7 +2350,7 @@ export function createThingsMcpServer(options = {}) {
2185
2350
  annotations: NON_DESTRUCTIVE,
2186
2351
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
2187
2352
  // ------------------------------------------------------------------ tags
2188
- server.registerTool("add_tag", {
2353
+ registerTool("add_tag", {
2189
2354
  description: "Create a tag, optionally nested under an existing parent tag.",
2190
2355
  inputSchema: {
2191
2356
  title: z.string(),
@@ -2196,7 +2361,7 @@ export function createThingsMcpServer(options = {}) {
2196
2361
  },
2197
2362
  annotations: NON_DESTRUCTIVE,
2198
2363
  }, 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", {
2364
+ registerTool("log_now", {
2200
2365
  description: "Move completed items to the Logbook now. The result discloses how many items were moved " +
2201
2366
  "(observed.logged); when none are waiting it logs nothing — a clean no-op, not an error. " +
2202
2367
  "This cannot be undone.",
@@ -2204,10 +2369,13 @@ export function createThingsMcpServer(options = {}) {
2204
2369
  annotations: DESTRUCTIVE,
2205
2370
  }, async (args) => guard(async () => mutationResult(await getClient().write.logNow(writeOptions(args)))));
2206
2371
  // -------------------------------------------------- generic + discovery
2207
- server.registerTool("run_operation", {
2372
+ registerTool("run_operation", {
2208
2373
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
2209
2374
  "dedicated tool (e.g. trash.empty). Call capabilities first for the catalog of " +
2210
- "operation kinds and their parameter shapes.",
2375
+ "operation kinds and their parameter shapes (each entry carries a params list naming " +
2376
+ "every field, whether it is optional, and the shape it accepts). A malformed params " +
2377
+ "bag — an unknown field, a wrong type, a bare string where a container reference " +
2378
+ "object belongs — is refused before anything runs.",
2211
2379
  inputSchema: {
2212
2380
  op: z.enum(OPERATION_KINDS),
2213
2381
  params: z
@@ -2233,11 +2401,13 @@ export function createThingsMcpServer(options = {}) {
2233
2401
  },
2234
2402
  annotations: DESTRUCTIVE,
2235
2403
  }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
2236
- server.registerTool("batch", {
2404
+ registerTool("batch", {
2237
2405
  description: "Run several operations in order, each independently — there are no transactions, " +
2238
2406
  "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 " +
2407
+ "(unknown op, an unknown or wrongly-typed param, a $ref to an undeclared/forward " +
2408
+ "temp_id, a duplicate temp_id) refuses the WHOLE batch before anything runs, naming " +
2409
+ "every bad operation and the field it names. A container param takes an object " +
2410
+ '({"project": {"uuid": "…"}}), never a bare string. Otherwise ' +
2241
2411
  "per-operation results return in order. By DEFAULT a runtime failure STOPS the batch " +
2242
2412
  "(later operations reported not-run, with resume guidance in the summary); " +
2243
2413
  "continue_on_error runs past failures. " +
@@ -2248,8 +2418,11 @@ export function createThingsMcpServer(options = {}) {
2248
2418
  "reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
2249
2419
  "safe — an operation matching an earlier success is reported already-applied, not " +
2250
2420
  "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.",
2421
+ "verbatim to resume). TIMELINE: preserve_modified applies to EVERY operation (an " +
2422
+ "operation may override it with its own options.preserve_modified), so a bulk re-tag " +
2423
+ "stays off the modification-date timeline under one undo token. The result adds " +
2424
+ "temp_id_mapping (handle → uuid) and undo_token, which reverses the whole batch as one " +
2425
+ "unit via the undo tool.",
2253
2426
  inputSchema: {
2254
2427
  ops: z
2255
2428
  .array(z.object({
@@ -2271,6 +2444,11 @@ export function createThingsMcpServer(options = {}) {
2271
2444
  acknowledge_tag_subtree: z.boolean().optional(),
2272
2445
  allow_non_empty_area: z.boolean().optional(),
2273
2446
  dangerously_drive_gui: z.boolean().optional(),
2447
+ preserve_modified: z
2448
+ .boolean()
2449
+ .optional()
2450
+ .describe("Keep THIS operation off the modification-date timeline; overrides the " +
2451
+ "run-level preserve_modified (false opts one operation back on)"),
2274
2452
  })
2275
2453
  .optional(),
2276
2454
  }))
@@ -2297,6 +2475,12 @@ export function createThingsMcpServer(options = {}) {
2297
2475
  ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
2298
2476
  ...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
2299
2477
  ...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
2478
+ // Per-op override of the run-level preserve_modified; passed through
2479
+ // when EXPLICIT (including false, which opts one op back onto the
2480
+ // timeline) so the batch engine's `??` precedence sees it.
2481
+ ...(o?.preserve_modified !== undefined && {
2482
+ preserveModified: o.preserve_modified,
2483
+ }),
2300
2484
  ...(ceiling !== undefined && { maxDisruption: ceiling }),
2301
2485
  };
2302
2486
  return {
@@ -2310,6 +2494,7 @@ export function createThingsMcpServer(options = {}) {
2310
2494
  const batchResult = await getClient().write.batch(ops, {
2311
2495
  ...(args.dry_run === true && { dryRun: true }),
2312
2496
  ...(args.continue_on_error === true && { continueOnError: true }),
2497
+ ...(args.preserve_modified === true && { preserveModified: true }),
2313
2498
  actor: mcpActor(),
2314
2499
  });
2315
2500
  // First block: the per-op results, each FLATTENED to the wire shape (a
@@ -2340,7 +2525,7 @@ export function createThingsMcpServer(options = {}) {
2340
2525
  ],
2341
2526
  };
2342
2527
  }));
2343
- server.registerTool("reorder", {
2528
+ registerTool("reorder", {
2344
2529
  description: "The ONE reorder tool — rearrange a single-KIND set IN PLACE: to-dos, projects, headings, " +
2345
2530
  "OR sidebar areas. This REARRANGES, never changes what an item belongs to (to change " +
2346
2531
  "membership use move_todo / move_project). All refs must be one kind; only to-dos and " +
@@ -2390,7 +2575,7 @@ export function createThingsMcpServer(options = {}) {
2390
2575
  };
2391
2576
  return moveResult(await getClient().write.reorderAny(request, writeOptions(args)));
2392
2577
  }));
2393
- server.registerTool("undo", {
2578
+ registerTool("undo", {
2394
2579
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +
2395
2580
  "cannot be undone here). By default this undoes only changes made through THIS " +
2396
2581
  "connection — this client's own writes; it will not touch the user's own edits, or " +
@@ -2453,7 +2638,7 @@ export function createThingsMcpServer(options = {}) {
2453
2638
  });
2454
2639
  return jsonResult(items);
2455
2640
  }));
2456
- server.registerTool("capabilities", {
2641
+ registerTool("capabilities", {
2457
2642
  description: "Support reference for every operation kind usable with run_operation and batch: " +
2458
2643
  "whether it is available, its caveats, and the confirmation parameters it needs.",
2459
2644
  inputSchema: {
@@ -2464,7 +2649,7 @@ export function createThingsMcpServer(options = {}) {
2464
2649
  },
2465
2650
  annotations: READ_ONLY,
2466
2651
  }, async (args) => guard(() => jsonResult(capabilitiesTable(args.op))));
2467
- server.registerTool("doctor", {
2652
+ registerTool("doctor", {
2468
2653
  description: "Check the environment: whether the Things app and its database are reachable, " +
2469
2654
  "whether changes can be made, any one-time setup still needed (macOS permissions, " +
2470
2655
  "the app's 'Enable Things URLs' setting), whether the environment changed since " +
@@ -2488,7 +2673,7 @@ export function createThingsMcpServer(options = {}) {
2488
2673
  ? jsonResult(report)
2489
2674
  : errorResult(error ?? { code: "unexpected", message: "no report" });
2490
2675
  }));
2491
- server.registerTool("op_result", {
2676
+ registerTool("op_result", {
2492
2677
  description: "Look up what happened to a write you dispatched with op_id, from the local change history " +
2493
2678
  "alone (opens no database, changes nothing). Use it to recover the outcome when the write " +
2494
2679
  "call was interrupted before it returned: report FOUND (the final result + target + " +