things-api 0.10.0 → 0.12.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 (239) hide show
  1. package/README.md +50 -13
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/clock.d.ts +8 -0
  5. package/dist/cli/clock.js +24 -0
  6. package/dist/cli/clock.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +61 -74
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -2
  11. package/dist/cli/commands/doctor.js +11 -1
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/install-skill.d.ts +60 -0
  14. package/dist/cli/commands/install-skill.js +256 -0
  15. package/dist/cli/commands/install-skill.js.map +1 -0
  16. package/dist/cli/commands/mcp.js +9 -0
  17. package/dist/cli/commands/mcp.js.map +1 -1
  18. package/dist/cli/commands/project.d.ts +1 -0
  19. package/dist/cli/commands/project.js +27 -34
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +15 -2
  22. package/dist/cli/commands/reads.js +144 -44
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +7 -2
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +38 -14
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +15 -9
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +797 -174
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +14 -3
  33. package/dist/cli/did-you-mean.js +26 -3
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/glyphs.d.ts +32 -2
  36. package/dist/cli/glyphs.js +83 -12
  37. package/dist/cli/glyphs.js.map +1 -1
  38. package/dist/cli/help.js +121 -16
  39. package/dist/cli/help.js.map +1 -1
  40. package/dist/cli/main.js +33 -2
  41. package/dist/cli/main.js.map +1 -1
  42. package/dist/cli/move-hint.d.ts +46 -0
  43. package/dist/cli/move-hint.js +196 -0
  44. package/dist/cli/move-hint.js.map +1 -0
  45. package/dist/cli/period.d.ts +2 -8
  46. package/dist/cli/period.js +44 -30
  47. package/dist/cli/period.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +43 -5
  49. package/dist/cli/read-driver.js +91 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +19 -8
  52. package/dist/cli/render.js +70 -27
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/cli/resolve-invocation.d.ts +2 -2
  55. package/dist/cli/resolve-invocation.js +3 -3
  56. package/dist/cli/resolve-invocation.js.map +1 -1
  57. package/dist/cli/skill-check.d.ts +27 -0
  58. package/dist/cli/skill-check.js +99 -0
  59. package/dist/cli/skill-check.js.map +1 -0
  60. package/dist/cli/skill.d.ts +70 -0
  61. package/dist/cli/skill.js +152 -0
  62. package/dist/cli/skill.js.map +1 -0
  63. package/dist/cli/tag-filters.d.ts +6 -2
  64. package/dist/cli/tag-filters.js +8 -6
  65. package/dist/cli/tag-filters.js.map +1 -1
  66. package/dist/cli/verb-hint.js +28 -3
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/cli/version.d.ts +4 -0
  69. package/dist/cli/version.js +18 -0
  70. package/dist/cli/version.js.map +1 -0
  71. package/dist/client.d.ts +171 -27
  72. package/dist/client.js +279 -40
  73. package/dist/client.js.map +1 -1
  74. package/dist/config.d.ts +66 -2
  75. package/dist/config.js +120 -12
  76. package/dist/config.js.map +1 -1
  77. package/dist/contracts.d.ts +186 -28
  78. package/dist/contracts.js +22 -1
  79. package/dist/contracts.js.map +1 -1
  80. package/dist/diagnose.d.ts +34 -2
  81. package/dist/diagnose.js +36 -1
  82. package/dist/diagnose.js.map +1 -1
  83. package/dist/index.d.ts +25 -9
  84. package/dist/index.js +22 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/mcp/server.d.ts +9 -0
  87. package/dist/mcp/server.js +1157 -684
  88. package/dist/mcp/server.js.map +1 -1
  89. package/dist/model/clock.d.ts +61 -0
  90. package/dist/model/clock.js +71 -0
  91. package/dist/model/clock.js.map +1 -0
  92. package/dist/model/dates.d.ts +26 -3
  93. package/dist/model/dates.js +84 -3
  94. package/dist/model/dates.js.map +1 -1
  95. package/dist/model/entities.d.ts +60 -3
  96. package/dist/model/entities.js.map +1 -1
  97. package/dist/model/mappers.d.ts +4 -2
  98. package/dist/model/mappers.js +66 -9
  99. package/dist/model/mappers.js.map +1 -1
  100. package/dist/model/recurrence.d.ts +8 -1
  101. package/dist/model/recurrence.js.map +1 -1
  102. package/dist/read/area-filter.d.ts +76 -0
  103. package/dist/read/area-filter.js +59 -0
  104. package/dist/read/area-filter.js.map +1 -0
  105. package/dist/read/area-view.d.ts +12 -12
  106. package/dist/read/area-view.js +58 -34
  107. package/dist/read/area-view.js.map +1 -1
  108. package/dist/read/detail.d.ts +1 -1
  109. package/dist/read/detail.js +26 -12
  110. package/dist/read/detail.js.map +1 -1
  111. package/dist/read/log-boundary.d.ts +1 -1
  112. package/dist/read/log-boundary.js +13 -3
  113. package/dist/read/log-boundary.js.map +1 -1
  114. package/dist/read/predicates.d.ts +18 -0
  115. package/dist/read/predicates.js +19 -0
  116. package/dist/read/predicates.js.map +1 -1
  117. package/dist/read/project-view.d.ts +32 -16
  118. package/dist/read/project-view.js +80 -45
  119. package/dist/read/project-view.js.map +1 -1
  120. package/dist/read/pseudo-area.d.ts +29 -0
  121. package/dist/read/pseudo-area.js +27 -0
  122. package/dist/read/pseudo-area.js.map +1 -0
  123. package/dist/read/queries.d.ts +108 -19
  124. package/dist/read/queries.js +212 -35
  125. package/dist/read/queries.js.map +1 -1
  126. package/dist/read/scope.d.ts +126 -0
  127. package/dist/read/scope.js +162 -0
  128. package/dist/read/scope.js.map +1 -0
  129. package/dist/read/search-rank.d.ts +2 -5
  130. package/dist/read/shape.d.ts +162 -0
  131. package/dist/read/shape.js +686 -0
  132. package/dist/read/shape.js.map +1 -0
  133. package/dist/read/show-target.d.ts +9 -1
  134. package/dist/read/show-target.js +45 -4
  135. package/dist/read/show-target.js.map +1 -1
  136. package/dist/read/snapshot.d.ts +1 -1
  137. package/dist/read/snapshot.js +9 -2
  138. package/dist/read/snapshot.js.map +1 -1
  139. package/dist/read/stage.d.ts +199 -0
  140. package/dist/read/stage.js +125 -0
  141. package/dist/read/stage.js.map +1 -0
  142. package/dist/read/truncation.d.ts +8 -6
  143. package/dist/read/truncation.js +35 -10
  144. package/dist/read/truncation.js.map +1 -1
  145. package/dist/read/views.d.ts +61 -20
  146. package/dist/read/views.js +240 -82
  147. package/dist/read/views.js.map +1 -1
  148. package/dist/surface-copy.d.ts +9 -0
  149. package/dist/surface-copy.js +9 -0
  150. package/dist/surface-copy.js.map +1 -1
  151. package/dist/write/accessibility-probe.d.ts +1 -1
  152. package/dist/write/accessibility-probe.js +8 -0
  153. package/dist/write/accessibility-probe.js.map +1 -1
  154. package/dist/write/automation-probe.d.ts +1 -1
  155. package/dist/write/automation-probe.js +8 -0
  156. package/dist/write/automation-probe.js.map +1 -1
  157. package/dist/write/batch.d.ts +49 -10
  158. package/dist/write/batch.js +423 -70
  159. package/dist/write/batch.js.map +1 -1
  160. package/dist/write/commands.d.ts +1 -1
  161. package/dist/write/commands.js +316 -66
  162. package/dist/write/commands.js.map +1 -1
  163. package/dist/write/guards.d.ts +1 -1
  164. package/dist/write/guards.js +94 -10
  165. package/dist/write/guards.js.map +1 -1
  166. package/dist/write/heading.d.ts +10 -1
  167. package/dist/write/heading.js +35 -5
  168. package/dist/write/heading.js.map +1 -1
  169. package/dist/write/make-repeating-project.d.ts +2 -2
  170. package/dist/write/make-repeating-project.js +9 -9
  171. package/dist/write/make-repeating-project.js.map +1 -1
  172. package/dist/write/move.d.ts +130 -0
  173. package/dist/write/move.js +1533 -0
  174. package/dist/write/move.js.map +1 -0
  175. package/dist/write/operations.d.ts +105 -19
  176. package/dist/write/operations.js +56 -7
  177. package/dist/write/operations.js.map +1 -1
  178. package/dist/write/opid.d.ts +31 -0
  179. package/dist/write/opid.js +30 -0
  180. package/dist/write/opid.js.map +1 -0
  181. package/dist/write/pipeline.d.ts +122 -3
  182. package/dist/write/pipeline.js +309 -52
  183. package/dist/write/pipeline.js.map +1 -1
  184. package/dist/write/planner.js +8 -1
  185. package/dist/write/planner.js.map +1 -1
  186. package/dist/write/pre-state.d.ts +197 -6
  187. package/dist/write/pre-state.js +427 -20
  188. package/dist/write/pre-state.js.map +1 -1
  189. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  190. package/dist/write/recurrence-rule-blob.js +150 -0
  191. package/dist/write/recurrence-rule-blob.js.map +1 -0
  192. package/dist/write/reorder.d.ts +23 -3
  193. package/dist/write/reorder.js +1871 -85
  194. package/dist/write/reorder.js.map +1 -1
  195. package/dist/write/reversibility.js +19 -7
  196. package/dist/write/reversibility.js.map +1 -1
  197. package/dist/write/scope-guard.d.ts +31 -0
  198. package/dist/write/scope-guard.js +162 -0
  199. package/dist/write/scope-guard.js.map +1 -0
  200. package/dist/write/undo.js +120 -13
  201. package/dist/write/undo.js.map +1 -1
  202. package/dist/write/vectors/applescript.js +16 -7
  203. package/dist/write/vectors/applescript.js.map +1 -1
  204. package/dist/write/vectors/registry.d.ts +5 -7
  205. package/dist/write/vectors/registry.js +49 -2
  206. package/dist/write/vectors/registry.js.map +1 -1
  207. package/dist/write/vectors/shortcuts.js +1 -1
  208. package/dist/write/vectors/shortcuts.js.map +1 -1
  209. package/dist/write/vectors/simulator.d.ts +44 -0
  210. package/dist/write/vectors/simulator.js +1192 -0
  211. package/dist/write/vectors/simulator.js.map +1 -0
  212. package/dist/write/vectors/types.d.ts +46 -0
  213. package/dist/write/vectors/ui-certification.d.ts +10 -4
  214. package/dist/write/vectors/ui-certification.js +43 -10
  215. package/dist/write/vectors/ui-certification.js.map +1 -1
  216. package/dist/write/vectors/ui-drag.d.ts +21 -1
  217. package/dist/write/vectors/ui-drag.js +138 -27
  218. package/dist/write/vectors/ui-drag.js.map +1 -1
  219. package/dist/write/vectors/ui-recipes.d.ts +34 -1
  220. package/dist/write/vectors/ui-recipes.js +217 -6
  221. package/dist/write/vectors/ui-recipes.js.map +1 -1
  222. package/dist/write/vectors/ui.d.ts +40 -1
  223. package/dist/write/vectors/ui.js +196 -39
  224. package/dist/write/vectors/ui.js.map +1 -1
  225. package/dist/write/verify/delta.d.ts +127 -2
  226. package/dist/write/verify/delta.js +255 -16
  227. package/dist/write/verify/delta.js.map +1 -1
  228. package/dist/write/verify/poller.d.ts +7 -1
  229. package/dist/write/verify/poller.js +20 -1
  230. package/dist/write/verify/poller.js.map +1 -1
  231. package/package.json +24 -3
  232. package/schema/envelope.schema.json +383 -0
  233. package/skills/things-cli/SKILL.md +70 -0
  234. package/skills/things-cli/references/banner.md +35 -0
  235. package/skills/things-cli/references/contracts.md +62 -0
  236. package/skills/things-cli/references/errors.md +49 -0
  237. package/skills/things-cli/references/gui.md +11 -0
  238. package/skills/things-cli/references/model.md +60 -0
  239. package/skills/things-cli/references/ordering.md +71 -0
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * MCP surface over ThingsClient — the third thin layer (CLI, library, MCP),
3
- * all consuming the same client. Tools return the SAME JSON objects the
4
- * library returns (and the CLI wraps in --json envelopes); mutation failures
5
- * surface as MCP tool errors carrying the machine-readable code + the
6
- * remediation text the guards produce. Nothing here contains Things logic.
3
+ * all consuming the same client. Read tools return the library's JSON objects
4
+ * verbatim; mutation/move tools frame their result the same way the CLI's wire
5
+ * envelope does (docs/contract.md) a success drops the internal discriminator
6
+ * (via mutationWireData), a dry-run emits the bare plan, and batch lines flatten
7
+ * to the string-`outcome` JSONL shape. Mutation failures surface as MCP tool
8
+ * errors carrying the machine-readable code + the remediation text the guards
9
+ * produce. Nothing here contains Things logic.
7
10
  *
8
11
  * Tool descriptions and the server instructions follow the consumer-voice
9
12
  * contract in docs/design/surface-copy.md: behavior and side effects only,
@@ -13,7 +16,7 @@
13
16
  */
14
17
  import { z } from "zod";
15
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
- import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, LIMIT_DESC, MCP_WHEN_LABELS, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
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, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, shapeReadPayload, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
17
20
  function jsonResult(data) {
18
21
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
19
22
  }
@@ -44,17 +47,18 @@ function truncatedResult(data, truncation) {
44
47
  }
45
48
  /**
46
49
  * Grouped read result (anytime/someday): the per-block-truncated sections plus
47
- * a second block carrying the {@link GroupedTruncation} counts and, when
48
- * anything was hidden, a one-line note the agent can read.
50
+ * a second block carrying the unified {@link Truncation} counts (its `blocks`
51
+ * hold the per-block nesting) and, when anything was hidden, a one-line note the
52
+ * agent can read.
49
53
  */
50
- function groupedResult(data, grouped) {
51
- const note = grouped.truncated
54
+ function groupedResult(data, truncation) {
55
+ const note = truncation.truncated
52
56
  ? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
53
57
  : undefined;
54
58
  return {
55
59
  content: [
56
60
  { type: "text", text: JSON.stringify(omitEmpty(data)) },
57
- { type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
61
+ { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
58
62
  ],
59
63
  };
60
64
  }
@@ -72,27 +76,60 @@ function resolveCap(value, all, defaultLimit) {
72
76
  return null;
73
77
  return value ?? defaultLimit;
74
78
  }
75
- /** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
79
+ /**
80
+ * Resolve MCP limit/all (flat read tools) into a row cap (null = every row).
81
+ * all:true wins: it lifts the cap and takes precedence over a limit passed
82
+ * alongside it — an explicit "everything" request resolves the contradiction
83
+ * rather than erroring — so the pair is never a usage error on the flat tools.
84
+ */
76
85
  function resolveLimit(args) {
77
- return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
86
+ if (args.all === true)
87
+ return null;
88
+ return args.limit ?? DEFAULT_LIST_LIMIT;
78
89
  }
90
+ /** Precedence notes appended to `limit`/`all` wherever a flat tool accepts both. */
91
+ const LIMIT_IGNORED_NOTE = "ignored when all is set";
92
+ const ALL_WINS_NOTE = "wins over limit if both are set";
79
93
  /** Shared limit/all input schema fragment for the flat read tools. */
80
94
  const limitShape = {
81
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
82
- all: z.boolean().optional().describe(ALL_DESC),
95
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
96
+ all: z.boolean().optional().describe(`${ALL_DESC}; ${ALL_WINS_NOTE}`),
83
97
  };
98
+ /**
99
+ * The per-call time-zone knob for date-sensitive tools: an IANA zone that
100
+ * evaluates every date boundary (today/evening/upcoming/logbook/overdue/…) for
101
+ * the consumer's calendar, overriding the server's THINGS_TZ for THIS call.
102
+ */
103
+ const TZ_DESC = "IANA time zone (e.g. Asia/Tokyo) to evaluate date boundaries in for this call — " +
104
+ "overrides the server default. Reminder times stay wall-clock and are never shifted.";
105
+ const tzShape = { tz: z.string().optional().describe(TZ_DESC) };
106
+ /** A usage result when `tz` is present but not a recognized IANA zone; null when it is valid/absent. */
107
+ function badTz(tz) {
108
+ if (tz !== undefined && !isValidTimeZone(tz)) {
109
+ return usage(`tz is not a valid IANA time zone: "${tz}" — expected e.g. "America/New_York" or "Asia/Tokyo"`);
110
+ }
111
+ return null;
112
+ }
84
113
  function errorResult(error) {
85
114
  return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
86
115
  }
87
116
  function usage(message) {
88
117
  return errorResult({ code: "usage", message });
89
118
  }
90
- /** Map a mutation outcome to an MCP result (errors carry remediation). */
119
+ /**
120
+ * Map a mutation outcome to an MCP result (errors carry remediation). Framing
121
+ * mirrors the CLI wire (docs/contract.md, phase-2 alignment): a SUCCESS drops
122
+ * the internal success discriminator via {@link mutationWireData} (call success
123
+ * is the tool result's own not-an-error signal — the payload carries no `kind`),
124
+ * and a dry-run returns the bare plan (the CLI's `mutation-plan` data). Failures
125
+ * stay tool errors carrying the machine-readable code.
126
+ */
91
127
  function mutationResult(result) {
92
128
  switch (result.kind) {
93
129
  case "ok":
130
+ return jsonResult(mutationWireData(result));
94
131
  case "dry-run":
95
- return jsonResult(result);
132
+ return jsonResult(result.plan);
96
133
  case "blocked":
97
134
  return errorResult({
98
135
  code: blockedCode(result),
@@ -121,6 +158,89 @@ function mutationResult(result) {
121
158
  });
122
159
  }
123
160
  }
161
+ /**
162
+ * Map a move/reorder orchestrator outcome to an MCP result (spec §4/§7). Framing
163
+ * mirrors {@link mutationResult}: a `move-ok` drops the internal discriminator
164
+ * (the placement-honesty `note` and every `membership`/`placement` field — with
165
+ * their own `touched`/`warnings` disclosures — pass through), and a dry-run
166
+ * returns the bare plan.
167
+ */
168
+ function moveResult(result) {
169
+ switch (result.kind) {
170
+ case "move-ok":
171
+ return jsonResult(mutationWireData(result));
172
+ case "move-dry-run":
173
+ return jsonResult(result.plan);
174
+ case "move-refused":
175
+ return errorResult({
176
+ code: result.refusal,
177
+ message: result.detail,
178
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
179
+ ...(result.candidates !== undefined && { details: { candidates: result.candidates } }),
180
+ });
181
+ case "move-leg-failed":
182
+ return errorResult({
183
+ code: "verify-failed",
184
+ message: result.detail,
185
+ remediation: `completed ${result.completed.length} leg(s) before the failure`,
186
+ });
187
+ }
188
+ }
189
+ /**
190
+ * Flatten one batch line to the wire shape (docs/contract.md — the JSONL grammar
191
+ * shared with the CLI): the internal `outcome` union object collapses to one
192
+ * level. `outcome` becomes its tag as a plain string (`"ok"`, `"blocked"`,
193
+ * `"dry-run"`, `"already-applied"`, …), and every variant field (uuid, detail,
194
+ * plan, expected, observed, …) sits as a sibling of the line-level keys. The
195
+ * outcome's own `op` duplicates the line-level `op`, so it is dropped — no
196
+ * variant field name collides with a line-level key.
197
+ */
198
+ function flattenBatchLine(r) {
199
+ const { index, op, outcome, tempId, boundUuid, opId } = r;
200
+ const { kind, op: _outcomeOp, ...variant } = outcome;
201
+ return {
202
+ index,
203
+ op,
204
+ outcome: kind,
205
+ ...variant,
206
+ ...(tempId !== undefined && { tempId }),
207
+ ...(boundUuid !== undefined && { boundUuid }),
208
+ ...(opId !== undefined && { opId }),
209
+ };
210
+ }
211
+ /** Build a MovePosition from the shared MCP position args (null when none). */
212
+ function movePositionArgs(args) {
213
+ const chosen = [
214
+ args.first === true,
215
+ args.last === true,
216
+ args.before !== undefined,
217
+ args.after !== undefined,
218
+ ].filter(Boolean).length;
219
+ if (chosen > 1)
220
+ return "conflict";
221
+ if (args.first === true)
222
+ return { at: "first" };
223
+ if (args.last === true)
224
+ return { at: "last" };
225
+ if (args.before !== undefined)
226
+ return { before: args.before };
227
+ if (args.after !== undefined)
228
+ return { after: args.after };
229
+ return undefined;
230
+ }
231
+ /** The shared position input schema for the move tools. */
232
+ const positionShape = {
233
+ first: z.boolean().optional().describe("place the block at the top of its bucket"),
234
+ last: z.boolean().optional().describe("place the block at the bottom of its bucket"),
235
+ before: z
236
+ .string()
237
+ .optional()
238
+ .describe("place the block immediately before this item (same bucket)"),
239
+ after: z
240
+ .string()
241
+ .optional()
242
+ .describe("place the block immediately after this item (same bucket)"),
243
+ };
124
244
  const READ_ONLY = { readOnlyHint: true };
125
245
  const NON_DESTRUCTIVE = { destructiveHint: false };
126
246
  const DESTRUCTIVE = { destructiveHint: true };
@@ -176,6 +296,35 @@ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
176
296
  const dryRunShape = {
177
297
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
178
298
  };
299
+ /**
300
+ * The per-call idempotency key for a single write tool — the analogue of a batch
301
+ * line's op_id. A resubmission carrying the same key is recognized as already
302
+ * applied (a prior verified change with that key) and is not re-run. Spread into
303
+ * the single-mutation write tools (not the variadic move/reorder tools, whose
304
+ * idempotency is the batch-shaped per-line op_id).
305
+ */
306
+ const opIdShape = {
307
+ op_id: z
308
+ .string()
309
+ .optional()
310
+ .describe("Idempotency key: a resubmission with the same key is recognized as already applied " +
311
+ "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)"),
312
+ };
313
+ /**
314
+ * The per-call opt-in for the tools that reach a change only by driving the
315
+ * local Things app's accessibility interface. Shared by `repeat`, the
316
+ * `convert_to_project` tool, the heading tool's `promote_heading` action, and
317
+ * the `areas` reorder scope.
318
+ */
319
+ const driveGuiShape = {
320
+ dangerously_drive_gui: z
321
+ .boolean()
322
+ .optional()
323
+ .describe("Required: this drives the local Things app through its accessibility interface to " +
324
+ "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
325
+ "on the machine running this server, and must be turned on first with `things config " +
326
+ "set ui-enabled true`. Intended for a dedicated always-on Mac."),
327
+ };
179
328
  /** How a tag value may be expressed on any tag-accepting tool. */
180
329
  const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
181
330
  /** create_tags param, shared by every tag-accepting write tool. */
@@ -187,6 +336,20 @@ const createTagsShape = {
187
336
  "instead of stopping on an unknown tag"),
188
337
  };
189
338
  const containerRef = (ref) => ({ uuid: ref, title: ref });
339
+ /**
340
+ * The `loose` reserved-word disclosure for the area-filtered read tools
341
+ * (read_view, search — the same advisory the CLI surfaces, #333/#346). `loose`
342
+ * ALWAYS addresses the null area; when a real area named "Loose" shadows the
343
+ * reserved word this names it (by uuid) so it stays targetable. Returns
344
+ * undefined when the ref is not the reserved word, or nothing shadows it.
345
+ * (get_area reads its disclosure from the area-view result's own `notice`.)
346
+ */
347
+ function looseAreaWarnings(c, areaRef) {
348
+ if (areaRef === undefined || !isLooseRef(areaRef))
349
+ return undefined;
350
+ const shadow = c.read.areas().find((a) => isLooseRef(a.title));
351
+ return shadow !== undefined ? [looseShadowNotice(shadow.uuid)] : undefined;
352
+ }
190
353
  /** Cap on project titles inlined into the server instructions. */
191
354
  const INSTRUCTIONS_MAX_PROJECTS = 100;
192
355
  /** A tag's display label: nested tags show `parent > child`. */
@@ -211,20 +374,39 @@ function buildInstructions(getClient) {
211
374
  "rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
212
375
  "add_area, add_project).",
213
376
  `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
214
- `are ${REMINDER_FORMAT}.`,
377
+ `are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
378
+ `below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
215
379
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
216
- "Operations with cascading or permanent effects require the explicit confirmation " +
217
- "parameter named in their description; refused calls return an error saying what to pass.",
380
+ "A preview creates no state, so later calls cannot reference an item that only appeared in " +
381
+ "a dry-run result. Operations with cascading or permanent effects require the explicit " +
382
+ "confirmation parameter named in their description; refused calls return an error saying " +
383
+ "what to pass.",
384
+ "- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
385
+ "tags inherited from its containing project and area. The today and evening markers are " +
386
+ "present only for an item in Today (evening implies today); an unscheduled start=active item " +
387
+ "is in Anytime and carries neither. Completing an item makes it findable in Logbook.",
388
+ "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
218
389
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
219
390
  ];
220
391
  try {
221
392
  const c = getClient();
393
+ // Under a container scope the inventory below is already limited to in-scope
394
+ // containers (the client filters areas/projects), so out-of-scope names are
395
+ // never embedded here. State the scope plainly so the agent knows its bounds.
396
+ if (c.scope !== undefined) {
397
+ lines.push("", `Scope: this server is limited to the ${c.scope.kind} "${c.scope.title}". Only items ` +
398
+ "within it are readable, and every change is confined to it; a reference naming anything " +
399
+ "outside it returns not-found, exactly as a nonexistent reference does.");
400
+ }
222
401
  const areas = c.read.areas();
223
402
  const tags = c.read.tags();
224
403
  const projects = c.read.projects();
404
+ const clock = c.clockMeta();
225
405
  const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
226
406
  const overflow = projects.length - shown.length;
227
- lines.push("", "Current inventory (read at server start — refresh with list_collections):", `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
407
+ lines.push("", "Current inventory (read at server start — refresh with list_collections):", ...(clock !== undefined
408
+ ? [`- Calendar context at server start: ${JSON.stringify(clock)}`]
409
+ : []), `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
228
410
  (overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
229
411
  }
230
412
  catch {
@@ -256,6 +438,13 @@ function deriveMcpActor(clientName) {
256
438
  .replaceAll(/-+$/g, "");
257
439
  return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
258
440
  }
441
+ /** Validate an op_id (throwing RangeError → mapped to a `usage` tool error by `guard`). */
442
+ function assertOpId(opId) {
443
+ if (!OP_ID_RE.test(opId)) {
444
+ throw new RangeError("op_id must match [A-Za-z0-9_-] and be 1-64 characters");
445
+ }
446
+ return opId;
447
+ }
259
448
  export function createThingsMcpServer(options = {}) {
260
449
  // One lazily-opened client for the server's lifetime; SQLite read
261
450
  // snapshots are per-statement, so fresh reads see external commits.
@@ -263,6 +452,7 @@ export function createThingsMcpServer(options = {}) {
263
452
  const getClient = () => {
264
453
  client ??= openThings({
265
454
  ...(options.dbPath !== undefined && { dbPath: options.dbPath }),
455
+ ...(options.scope !== undefined && { scope: options.scope }),
266
456
  ...options.openOptions,
267
457
  });
268
458
  return client;
@@ -283,8 +473,11 @@ export function createThingsMcpServer(options = {}) {
283
473
  ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
284
474
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
285
475
  ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
476
+ ...(args.allow_non_empty_area === true && { allowNonEmptyArea: true }),
286
477
  ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
287
478
  ...(args.create_tags === true && { createTags: true }),
479
+ ...(args.tz !== undefined && { zone: args.tz }),
480
+ ...(args.op_id !== undefined && { opId: assertOpId(args.op_id) }),
288
481
  });
289
482
  /** Run a handler, mapping environment/usage throws to tool errors. */
290
483
  const guard = async (fn) => {
@@ -313,23 +506,52 @@ export function createThingsMcpServer(options = {}) {
313
506
  * same note the CLI prints), so a consumer sees when the Things database no
314
507
  * longer matches the validated schema and its data may be incomplete. No
315
508
  * block is added when the schema checks out or the read itself errored.
509
+ *
510
+ * `extraWarnings` folds in the read's own advisories (the `loose` reserved-word
511
+ * shadow disclosure, an area-view's placement `notice`) so they reach the
512
+ * consumer in the SAME `meta.warnings` array the CLI surfaces — schema warnings
513
+ * first, then the read-specific notices.
316
514
  */
317
- const readGuard = async (fn) => {
515
+ const readGuard = async (fn, tz, extraMeta, extraWarnings) => {
318
516
  const result = await guard(fn);
319
517
  if (result.isError === true)
320
518
  return result;
321
519
  let warnings = [];
520
+ let clock;
322
521
  try {
323
- warnings = schemaWarnings(getClient().schemaStatus());
522
+ const c = getClient();
523
+ warnings = schemaWarnings(c.schemaStatus());
524
+ // The clock honesty field for this call's effective zone (the per-call
525
+ // tz over the server default) — present only when a consumer zone /
526
+ // pinned now is in effect.
527
+ clock = c.clockMeta(tz);
324
528
  }
325
529
  catch {
326
530
  warnings = [];
327
531
  }
328
- if (warnings.length === 0)
532
+ // Read-specific advisories the handler resolved (loose-shadow / area notice)
533
+ // — merged AFTER the schema warnings, defensive against a DB read throwing.
534
+ let readNotices = [];
535
+ try {
536
+ readNotices = extraWarnings?.() ?? [];
537
+ }
538
+ catch {
539
+ readNotices = [];
540
+ }
541
+ const allWarnings = [...warnings, ...readNotices];
542
+ // Extra additive meta the handler resolved during the read (e.g. the active
543
+ // `area` filter) — evaluated AFTER fn ran, so a value it populated is seen.
544
+ const extra = extraMeta?.() ?? {};
545
+ const meta = {
546
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
547
+ ...(clock !== undefined && { clock }),
548
+ ...extra,
549
+ };
550
+ if (Object.keys(meta).length === 0)
329
551
  return result;
330
552
  return {
331
553
  ...result,
332
- content: [...result.content, { type: "text", text: JSON.stringify({ meta: { warnings } }) }],
554
+ content: [...result.content, { type: "text", text: JSON.stringify({ meta }) }],
333
555
  };
334
556
  };
335
557
  /** Resolve a uuid to to-do/project for the type-generic item tools. */
@@ -360,6 +582,11 @@ export function createThingsMcpServer(options = {}) {
360
582
  inputSchema: {
361
583
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
362
584
  ...tagFilterShape,
585
+ ...tzShape,
586
+ area: z
587
+ .string()
588
+ .optional()
589
+ .describe(`today/anytime/someday/upcoming/logbook only: ${AREA_FILTER_DESC}`),
363
590
  evening: z.boolean().optional().describe("today only: show only the This Evening section"),
364
591
  show_active_project_items: z
365
592
  .union([z.boolean(), z.number().int().min(1)])
@@ -382,7 +609,7 @@ export function createThingsMcpServer(options = {}) {
382
609
  .int()
383
610
  .min(1)
384
611
  .optional()
385
- .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
612
+ .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
386
613
  area_limit: z
387
614
  .number()
388
615
  .int()
@@ -398,102 +625,143 @@ export function createThingsMcpServer(options = {}) {
398
625
  all: z
399
626
  .boolean()
400
627
  .optional()
401
- .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
628
+ .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
629
+ ALL_WINS_NOTE),
630
+ full: z.boolean().optional().describe(FULL_DESC),
402
631
  },
403
632
  annotations: READ_ONLY,
404
- }, async (args) => readGuard(() => {
405
- // Tag-conflict AND overdue-applicability both derive from the shared
406
- // contract: read_view honors overdue only on today/inbox/anytime/someday
407
- // (the current-work views), matching FILTER_CONTRACT.
408
- const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
409
- untaggedConflict: MCP_UNTAGGED_CONFLICT,
410
- overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
411
- overdueStatusWiden: "",
412
- });
413
- if (!validated.ok)
414
- return usage(validated.message);
415
- // show_active_project_items is the preferred name; active_project_items
416
- // stays accepted as a compatibility alias.
417
- const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
418
- const isGrouped = args.view === "anytime" || args.view === "someday";
419
- if (isGrouped && args.limit !== undefined) {
420
- return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
421
- `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
422
- }
423
- if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
424
- return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
425
- }
426
- if (args.view !== "someday" && showActiveProjectItems !== undefined) {
427
- return usage("show_active_project_items applies only to someday");
428
- }
429
- if (args.view !== "today" && args.evening === true) {
430
- return usage(`evening applies only to today, not ${args.view}`);
431
- }
432
- if (args.view === "someday" && args.project_limit !== undefined) {
433
- return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
434
- "to cap that section's project lists");
435
- }
436
- const limit = resolveLimit(args);
437
- if (limit === "conflict")
438
- return usage("pass at most one of limit / all");
439
- const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
440
- const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
441
- if (areaLimit === "conflict" || projectLimit === "conflict") {
442
- return usage("pass at most one of area_limit/project_limit / all");
443
- }
444
- const c = getClient();
445
- const filter = validated.filter;
446
- switch (args.view) {
447
- case "today": {
448
- const { view, truncation } = c.read.today({
449
- ...filter,
450
- ...(args.evening === true && { eveningOnly: true }),
451
- limit,
452
- });
453
- return truncatedResult(view, truncation);
633
+ }, async (args) => {
634
+ // The active `area` scope, resolved during the read and surfaced as the
635
+ // additive `meta.filter` (the readGuard extra-meta thunk reads it after fn).
636
+ let filterMeta;
637
+ const full = args.full === true;
638
+ return readGuard(() => {
639
+ const badZone = badTz(args.tz);
640
+ if (badZone !== null)
641
+ return badZone;
642
+ // Tag-conflict AND overdue-applicability both derive from the shared
643
+ // contract: read_view honors overdue only on today/inbox/anytime/someday
644
+ // (the current-work views), matching FILTER_CONTRACT.
645
+ const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
646
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
647
+ overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
648
+ overdueStatusWiden: "",
649
+ });
650
+ if (!validated.ok)
651
+ return usage(validated.message);
652
+ // show_active_project_items is the preferred name; active_project_items
653
+ // stays accepted as a compatibility alias.
654
+ const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
655
+ const isGrouped = args.view === "anytime" || args.view === "someday";
656
+ if (isGrouped && args.limit !== undefined) {
657
+ return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
658
+ `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
454
659
  }
455
- case "inbox": {
456
- const { items, truncation } = c.read.inbox({ ...filter, limit });
457
- return truncatedResult(items, truncation);
660
+ if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
661
+ return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
458
662
  }
459
- case "anytime": {
460
- const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
461
- return groupedResult(view, grouped);
663
+ if (args.view !== "someday" && showActiveProjectItems !== undefined) {
664
+ return usage("show_active_project_items applies only to someday");
462
665
  }
463
- case "upcoming": {
464
- const { items, truncation } = c.read.upcoming({
465
- ...filter,
466
- ...(args.horizon !== undefined && { horizon: args.horizon }),
467
- limit,
468
- });
469
- return truncatedResult(items, truncation);
666
+ if (args.view !== "today" && args.evening === true) {
667
+ return usage(`evening applies only to today, not ${args.view}`);
470
668
  }
471
- case "someday": {
472
- const active = showActiveProjectItems;
473
- if (typeof active === "number" && args.all === true) {
474
- return usage("pass at most one of a numeric show_active_project_items / all");
475
- }
476
- const { view, grouped } = c.read.someday({
477
- ...filter,
478
- ...((active === true || typeof active === "number") && {
479
- activeProjectItems: true,
480
- }),
481
- areaLimit,
482
- // true = every item per project; a number caps each list.
483
- projectLimit: typeof active === "number" ? active : null,
484
- });
485
- return groupedResult(view, grouped);
669
+ // The `area` filter applies to the area-carrying views only; inbox
670
+ // (area-less captures) and trash have no area to scope by.
671
+ if ((args.view === "inbox" || args.view === "trash") && args.area !== undefined) {
672
+ return usage(`area applies to today/anytime/someday/upcoming/logbook, not ${args.view}`);
486
673
  }
487
- case "logbook": {
488
- const { items, truncation } = c.read.logbook({ ...filter, limit });
489
- return truncatedResult(items, truncation);
674
+ if (args.view === "someday" && args.project_limit !== undefined) {
675
+ return usage("project_limit does not apply to someday pass a number as show_active_project_items " +
676
+ "to cap that section's project lists");
490
677
  }
491
- case "trash": {
492
- const { items, truncation } = c.read.trash({ limit });
493
- return truncatedResult(items, truncation);
678
+ const limit = resolveLimit(args);
679
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
680
+ const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
681
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
682
+ return usage("pass at most one of area_limit/project_limit / all");
494
683
  }
495
- }
496
- }));
684
+ const c = getClient();
685
+ const filter = validated.filter;
686
+ const zone = args.tz !== undefined ? { zone: args.tz } : {};
687
+ // The `area` scope, threaded into the area-carrying views; the client
688
+ // resolves it, applies the post-filter, and hands back the target for
689
+ // the `meta.filter` annotation (captured into `filterMeta`).
690
+ const area = args.area !== undefined ? { area: args.area } : {};
691
+ switch (args.view) {
692
+ case "today": {
693
+ const { view, truncation, filter: fm, } = c.read.today({
694
+ ...filter,
695
+ ...zone,
696
+ ...area,
697
+ ...(args.evening === true && { eveningOnly: true }),
698
+ limit,
699
+ });
700
+ filterMeta = fm;
701
+ return truncatedResult(shapeReadPayload("today", view, full), truncation);
702
+ }
703
+ case "inbox": {
704
+ const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
705
+ return truncatedResult(shapeReadPayload("inbox", items, full), truncation);
706
+ }
707
+ case "anytime": {
708
+ const { view, truncation, filter: fm, } = c.read.anytime({
709
+ ...filter,
710
+ ...zone,
711
+ ...area,
712
+ areaLimit,
713
+ projectLimit,
714
+ });
715
+ filterMeta = fm;
716
+ return groupedResult(shapeReadPayload("anytime", view, full), truncation);
717
+ }
718
+ case "upcoming": {
719
+ const { items, truncation, filter: fm, } = c.read.upcoming({
720
+ ...filter,
721
+ ...zone,
722
+ ...area,
723
+ ...(args.horizon !== undefined && { horizon: args.horizon }),
724
+ limit,
725
+ });
726
+ filterMeta = fm;
727
+ return truncatedResult(shapeReadPayload("upcoming", items, full), truncation);
728
+ }
729
+ case "someday": {
730
+ const active = showActiveProjectItems;
731
+ if (typeof active === "number" && args.all === true) {
732
+ return usage("pass at most one of a numeric show_active_project_items / all");
733
+ }
734
+ const { view, truncation, filter: fm, } = c.read.someday({
735
+ ...filter,
736
+ ...zone,
737
+ ...area,
738
+ ...((active === true || typeof active === "number") && {
739
+ activeProjectItems: true,
740
+ }),
741
+ areaLimit,
742
+ // true = every item per project; a number caps each list.
743
+ projectLimit: typeof active === "number" ? active : null,
744
+ });
745
+ filterMeta = fm;
746
+ return groupedResult(shapeReadPayload("someday", view, full), truncation);
747
+ }
748
+ case "logbook": {
749
+ const { items, truncation, filter: fm, } = c.read.logbook({
750
+ ...filter,
751
+ ...zone,
752
+ ...area,
753
+ limit,
754
+ });
755
+ filterMeta = fm;
756
+ return truncatedResult(shapeReadPayload("logbook", items, full), truncation);
757
+ }
758
+ case "trash": {
759
+ const { items, truncation } = c.read.trash({ ...zone, limit });
760
+ return truncatedResult(shapeReadPayload("trash", items, full), truncation);
761
+ }
762
+ }
763
+ }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
764
+ });
497
765
  server.registerTool("search", {
498
766
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
499
767
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
@@ -502,6 +770,7 @@ export function createThingsMcpServer(options = {}) {
502
770
  inputSchema: {
503
771
  query: z.string(),
504
772
  ...tagFilterShape,
773
+ ...tzShape,
505
774
  project: z
506
775
  .string()
507
776
  .optional()
@@ -509,18 +778,22 @@ export function createThingsMcpServer(options = {}) {
509
778
  area: z
510
779
  .string()
511
780
  .optional()
512
- .describe(`Restrict to one area's direct members (${REF_FORMAT})`),
781
+ .describe(`Restrict to one area's subtree — its direct items plus its projects' children (${REF_FORMAT}, or \`loose\` for area-less)`),
513
782
  type: z.enum(["to-do", "project"]).optional(),
514
783
  logged: z.boolean().optional().describe("Also include completed/canceled items"),
515
784
  trashed: z.boolean().optional().describe("Also include trashed items"),
516
785
  all: z
517
786
  .boolean()
518
787
  .optional()
519
- .describe("Everything, unbounded: open + logged + trashed, no row limit"),
520
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
788
+ .describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
789
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
790
+ full: z.boolean().optional().describe(FULL_DESC),
521
791
  },
522
792
  annotations: READ_ONLY,
523
793
  }, async (args) => readGuard(() => {
794
+ const badZone = badTz(args.tz);
795
+ if (badZone !== null)
796
+ return badZone;
524
797
  // Tag-conflict AND the overdue/status-widening incompatibility both
525
798
  // derive from the shared contract (search: statusWidening = true).
526
799
  const validated = validateViewArgs("search", {
@@ -537,11 +810,10 @@ export function createThingsMcpServer(options = {}) {
537
810
  if (!validated.ok)
538
811
  return usage(validated.message);
539
812
  const limit = resolveLimit(args);
540
- if (limit === "conflict")
541
- return usage("pass at most one of limit / all");
542
813
  const { items, truncation } = getClient().read.search(args.query, {
543
814
  limit,
544
815
  ...validated.filter,
816
+ ...(args.tz !== undefined && { zone: args.tz }),
545
817
  ...(args.project !== undefined && { project: args.project }),
546
818
  ...(args.area !== undefined && { area: args.area }),
547
819
  ...(args.type !== undefined && { type: args.type }),
@@ -549,8 +821,8 @@ export function createThingsMcpServer(options = {}) {
549
821
  ...(args.trashed === true && { trashed: true }),
550
822
  ...(args.all === true && { all: true }),
551
823
  });
552
- return truncatedResult(items, truncation);
553
- }));
824
+ return truncatedResult(shapeReadPayload("search", items, args.full === true), truncation);
825
+ }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
554
826
  server.registerTool("changes_since", {
555
827
  description: "List items created or modified since a moment — including trashed, logged, and " +
556
828
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
@@ -559,19 +831,26 @@ export function createThingsMcpServer(options = {}) {
559
831
  inputSchema: {
560
832
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
561
833
  ...limitShape,
834
+ ...tzShape,
835
+ full: z.boolean().optional().describe(FULL_DESC),
562
836
  },
563
837
  annotations: READ_ONLY,
564
838
  }, async (args) => readGuard(() => {
839
+ const badZone = badTz(args.tz);
840
+ if (badZone !== null)
841
+ return badZone;
565
842
  const limit = resolveLimit(args);
566
- if (limit === "conflict")
567
- return usage("pass at most one of limit / all");
568
843
  const since = new Date(args.since);
569
844
  if (Number.isNaN(since.getTime())) {
570
845
  return usage(`since is not a parseable date: ${args.since}`);
571
846
  }
572
- const { items, truncation } = getClient().read.changes({ since, limit });
573
- return truncatedResult(items, truncation);
574
- }));
847
+ const { items, truncation } = getClient().read.changes({
848
+ since,
849
+ limit,
850
+ ...(args.tz !== undefined && { zone: args.tz }),
851
+ });
852
+ return truncatedResult(shapeReadPayload("changes", items, args.full === true), truncation);
853
+ }, args.tz));
575
854
  server.registerTool("get_item", {
576
855
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
577
856
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
@@ -583,7 +862,7 @@ export function createThingsMcpServer(options = {}) {
583
862
  const item = getClient().read.byUuid(args.uuid);
584
863
  return item === null
585
864
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
586
- : readResult(item);
865
+ : readResult(shapeReadPayload("detail", item, false));
587
866
  }));
588
867
  server.registerTool("get_project", {
589
868
  description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
@@ -593,23 +872,31 @@ export function createThingsMcpServer(options = {}) {
593
872
  inputSchema: {
594
873
  uuid: z.string().describe("Project uuid or unique name"),
595
874
  ...tagOnlyShape,
875
+ ...tzShape,
596
876
  overdue: z
597
877
  .boolean()
598
878
  .optional()
599
879
  .describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
880
+ full: z.boolean().optional().describe(FULL_DESC),
600
881
  },
601
882
  annotations: READ_ONLY,
602
883
  }, async (args) => readGuard(() => {
884
+ const badZone = badTz(args.tz);
885
+ if (badZone !== null)
886
+ return badZone;
603
887
  if (tagFlagConflict(tagPresence(args)))
604
888
  return usage(MCP_UNTAGGED_CONFLICT);
605
- return readResult(getClient().read.projectView(args.uuid, {
889
+ return readResult(shapeReadPayload("project-view", getClient().read.projectView(args.uuid, {
606
890
  overdue: args.overdue === true,
607
891
  ...tagFilterFields(tagPresence(args)),
608
- }));
609
- }));
892
+ ...(args.tz !== undefined && { zone: args.tz }),
893
+ }), args.full === true));
894
+ }, args.tz));
610
895
  server.registerTool("get_area", {
611
896
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
612
- "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
897
+ "projects in canonical order, and later (scheduled/repeating/someday). The area " +
898
+ "logbook is not returned here — read it with read_view logbook + area; trashed " +
899
+ "rows live in read_view trash. " +
613
900
  `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
614
901
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
615
902
  "second result block reports the counts. " +
@@ -617,6 +904,7 @@ export function createThingsMcpServer(options = {}) {
617
904
  inputSchema: {
618
905
  ref: z.string().describe("Area uuid or unique name"),
619
906
  ...tagOnlyShape,
907
+ ...tzShape,
620
908
  area_limit: z
621
909
  .number()
622
910
  .int()
@@ -634,24 +922,36 @@ export function createThingsMcpServer(options = {}) {
634
922
  .optional()
635
923
  .describe("Keep only rows (loose to-dos AND child projects) whose own deadline is past (due today is not overdue); no descent into project contents"),
636
924
  all: z.boolean().optional().describe("return both sections in full (no caps)"),
925
+ full: z.boolean().optional().describe(FULL_DESC),
637
926
  },
638
927
  annotations: READ_ONLY,
639
- }, async (args) => readGuard(() => {
640
- if (tagFlagConflict(tagPresence(args)))
641
- return usage(MCP_UNTAGGED_CONFLICT);
642
- const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
643
- const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
644
- if (areaLimit === "conflict" || projectLimit === "conflict") {
645
- return usage("pass at most one of area_limit/project_limit / all");
646
- }
647
- const { view, grouped } = getClient().read.areaView(args.ref, {
648
- overdue: args.overdue === true,
649
- ...tagFilterFields(tagPresence(args)),
650
- areaLimit,
651
- projectLimit,
652
- });
653
- return groupedResult(view, grouped);
654
- }));
928
+ }, async (args) => {
929
+ // The `loose` pseudo-area shadow disclosure the area-view read resolves —
930
+ // captured after fn runs and folded into meta.warnings (parity with the
931
+ // CLI's `area show loose` notice).
932
+ let areaNotice;
933
+ return readGuard(() => {
934
+ const badZone = badTz(args.tz);
935
+ if (badZone !== null)
936
+ return badZone;
937
+ if (tagFlagConflict(tagPresence(args)))
938
+ return usage(MCP_UNTAGGED_CONFLICT);
939
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
940
+ const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
941
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
942
+ return usage("pass at most one of area_limit/project_limit / all");
943
+ }
944
+ const { view, truncation, notice } = getClient().read.areaView(args.ref, {
945
+ overdue: args.overdue === true,
946
+ ...tagFilterFields(tagPresence(args)),
947
+ ...(args.tz !== undefined && { zone: args.tz }),
948
+ areaLimit,
949
+ projectLimit,
950
+ });
951
+ areaNotice = notice;
952
+ return groupedResult(shapeReadPayload("area-view", view, args.full === true), truncation);
953
+ }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
954
+ });
655
955
  server.registerTool("list_collections", {
656
956
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
657
957
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
@@ -660,13 +960,18 @@ export function createThingsMcpServer(options = {}) {
660
960
  inputSchema: {
661
961
  kind: z.enum(["projects", "areas", "tags"]),
662
962
  ...tagOnlyShape,
963
+ ...tzShape,
663
964
  overdue: z
664
965
  .boolean()
665
966
  .optional()
666
967
  .describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
968
+ full: z.boolean().optional().describe(`projects only: ${FULL_DESC}`),
667
969
  },
668
970
  annotations: READ_ONLY,
669
971
  }, async (args) => readGuard(() => {
972
+ const badZone = badTz(args.tz);
973
+ if (badZone !== null)
974
+ return badZone;
670
975
  const c = getClient();
671
976
  // areas/tags are not dated entities and have no per-row tag list to
672
977
  // filter — overdue and the tag filters are vacuous there, rejected
@@ -688,21 +993,22 @@ export function createThingsMcpServer(options = {}) {
688
993
  }
689
994
  if (tagFlagConflict(tagPresence(args)))
690
995
  return usage(MCP_UNTAGGED_CONFLICT);
691
- return readResult(args.kind === "projects"
996
+ return readResult(shapeReadPayload(args.kind, args.kind === "projects"
692
997
  ? c.read.projects({
693
998
  overdue: args.overdue === true,
694
999
  ...tagFilterFields(tagPresence(args)),
1000
+ ...(args.tz !== undefined && { zone: args.tz }),
695
1001
  })
696
1002
  : args.kind === "areas"
697
1003
  ? c.read.areas()
698
- : c.read.tags());
699
- }));
1004
+ : c.read.tags(), args.full === true));
1005
+ }, args.tz));
700
1006
  // ---------------------------------------------------------------- to-dos
701
1007
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
702
1008
  server.registerTool("add_todo", {
703
1009
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
704
1010
  "deadline, tag it, give it a checklist, and place it in a project or area " +
705
- "(optionally under an existing heading). A reminder " +
1011
+ "(optionally under a heading within that project). A reminder " +
706
1012
  "requires when = today, evening, or a date. Adding into a completed or canceled " +
707
1013
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
708
1014
  inputSchema: {
@@ -721,10 +1027,15 @@ export function createThingsMcpServer(options = {}) {
721
1027
  .optional()
722
1028
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
723
1029
  ...createTagsShape,
1030
+ ...tzShape,
724
1031
  ...dryRunShape,
1032
+ ...opIdShape,
725
1033
  },
726
1034
  annotations: NON_DESTRUCTIVE,
727
1035
  }, async (args) => guard(async () => {
1036
+ const badZone = badTz(args.tz);
1037
+ if (badZone !== null)
1038
+ return badZone;
728
1039
  const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
729
1040
  if (sugar.kind === "error")
730
1041
  return usage(sugar.message);
@@ -743,90 +1054,228 @@ export function createThingsMcpServer(options = {}) {
743
1054
  ...(args.heading !== undefined && { heading: args.heading }),
744
1055
  }, writeOptions(args)));
745
1056
  }));
746
- server.registerTool("update_todo", {
747
- description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
748
- "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
749
- "notes). Changing the schedule keeps an existing reminder unless the call sets a " +
750
- "new one. clear_reminder works while the to-do is scheduled for today or this " +
751
- "evening; a reminder on a future date can only be changed, not cleared " +
752
- "(re-schedule to today first). Schedule and deadline changes are not available " +
753
- "for repeating to-dos.",
1057
+ server.registerTool("update", {
1058
+ description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
1059
+ "kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
1060
+ "existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
1061
+ "deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
1062
+ "one is set, schedule and deadline changes are unavailable for repeating items, and " +
1063
+ "clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
1064
+ "future date can only be changed, not cleared). kind area: title and/or tags (the full " +
1065
+ "replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
1066
+ "exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
1067
+ "create_tags is set.",
754
1068
  inputSchema: {
755
- uuid: z.string(),
756
- title: z.string().optional(),
757
- notes: z.string().optional().describe("Replaces the whole notes body"),
758
- append_notes: z.string().optional(),
759
- prepend_notes: z.string().optional(),
1069
+ kind: z.enum(["todo", "project", "area", "tag"]),
1070
+ uuid: z
1071
+ .string()
1072
+ .describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
1073
+ "unique name"),
1074
+ title: z.string().optional().describe("New title (any kind)"),
1075
+ notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
1076
+ append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
1077
+ prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
760
1078
  when: whenSchema,
761
- reminder: z.string().optional().describe(REMINDER_FORMAT),
762
- clear_reminder: z.boolean().optional(),
763
- deadline: z.string().optional().describe(DATE_FORMAT),
764
- clear_deadline: z.boolean().optional(),
1079
+ reminder: z.string().optional().describe(`todo/project: ${REMINDER_FORMAT}`),
1080
+ clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
1081
+ deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
1082
+ clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
1083
+ tags: z
1084
+ .array(z.string())
1085
+ .optional()
1086
+ .describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
1087
+ parent: z.string().optional().describe("tag: existing tag to nest under"),
1088
+ unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
1089
+ shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
1090
+ clear_shortcut: z.boolean().optional().describe("tag: remove the keyboard shortcut"),
1091
+ ...createTagsShape,
1092
+ ...tzShape,
765
1093
  ...dryRunShape,
1094
+ ...opIdShape,
766
1095
  },
767
1096
  annotations: NON_DESTRUCTIVE,
768
1097
  }, async (args) => guard(async () => {
769
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
770
- if (notesModes.length > 1) {
771
- return usage("notes, append_notes, prepend_notes are exclusive");
1098
+ const badZone = badTz(args.tz);
1099
+ if (badZone !== null)
1100
+ return badZone;
1101
+ const opts = writeOptions(args);
1102
+ const c = getClient();
1103
+ if (args.kind === "todo" || args.kind === "project") {
1104
+ const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
1105
+ if (notesModes.length > 1) {
1106
+ return usage("notes, append_notes, prepend_notes are exclusive");
1107
+ }
1108
+ if (args.reminder !== undefined && args.clear_reminder === true) {
1109
+ return usage("pass at most one of reminder / clear_reminder");
1110
+ }
1111
+ if (args.deadline !== undefined && args.clear_deadline === true) {
1112
+ return usage("pass at most one of deadline / clear_deadline");
1113
+ }
1114
+ if (args.kind === "todo") {
1115
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
1116
+ if (sugar.kind === "error")
1117
+ return usage(sugar.message);
1118
+ const when = sugar.kind === "split" ? sugar.when : args.when;
1119
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
1120
+ return mutationResult(await c.write.updateTodo(args.uuid, {
1121
+ ...(args.title !== undefined && { title: args.title }),
1122
+ ...(args.notes !== undefined && { notes: args.notes }),
1123
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1124
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1125
+ ...(when !== undefined && { when: when }),
1126
+ ...(reminder !== undefined && { reminder }),
1127
+ ...(args.clear_reminder === true && { reminder: null }),
1128
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
1129
+ ...(args.clear_deadline === true && { deadline: null }),
1130
+ }, opts));
1131
+ }
1132
+ return mutationResult(await c.write.updateProject(args.uuid, {
1133
+ ...(args.title !== undefined && { title: args.title }),
1134
+ ...(args.notes !== undefined && { notes: args.notes }),
1135
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1136
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1137
+ ...(args.when !== undefined && { when: args.when }),
1138
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
1139
+ ...(args.clear_reminder === true && { reminder: null }),
1140
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
1141
+ ...(args.clear_deadline === true && { deadline: null }),
1142
+ }, opts));
1143
+ }
1144
+ if (args.kind === "area") {
1145
+ if (args.title === undefined && args.tags === undefined) {
1146
+ return usage("kind area requires title and/or tags");
1147
+ }
1148
+ return mutationResult(await c.write.updateArea(args.uuid, {
1149
+ ...(args.title !== undefined && { title: args.title }),
1150
+ ...(args.tags !== undefined && { tags: args.tags }),
1151
+ }, opts));
1152
+ }
1153
+ // kind tag
1154
+ if (args.title === undefined &&
1155
+ args.parent === undefined &&
1156
+ args.unnest === undefined &&
1157
+ args.shortcut === undefined &&
1158
+ args.clear_shortcut === undefined) {
1159
+ return usage("kind tag requires title, parent, unnest, shortcut, and/or clear_shortcut");
772
1160
  }
773
- if (args.reminder !== undefined && args.clear_reminder === true) {
774
- return usage("pass at most one of reminder / clear_reminder");
1161
+ if (args.parent !== undefined && args.unnest === true) {
1162
+ return usage("parent and unnest are exclusive");
775
1163
  }
776
- if (args.deadline !== undefined && args.clear_deadline === true) {
777
- return usage("pass at most one of deadline / clear_deadline");
1164
+ if (args.shortcut !== undefined && args.clear_shortcut === true) {
1165
+ return usage("shortcut and clear_shortcut are exclusive");
778
1166
  }
779
- const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
780
- if (sugar.kind === "error")
781
- return usage(sugar.message);
782
- const when = sugar.kind === "split" ? sugar.when : args.when;
783
- const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
784
- return mutationResult(await getClient().write.updateTodo(args.uuid, {
1167
+ return mutationResult(await c.write.updateTag(args.uuid, {
785
1168
  ...(args.title !== undefined && { title: args.title }),
786
- ...(args.notes !== undefined && { notes: args.notes }),
787
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
788
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
789
- ...(when !== undefined && { when: when }),
790
- ...(reminder !== undefined && { reminder }),
791
- ...(args.clear_reminder === true && { reminder: null }),
792
- ...(args.deadline !== undefined && { deadline: args.deadline }),
793
- ...(args.clear_deadline === true && { deadline: null }),
794
- }, writeOptions(args)));
1169
+ ...(args.parent !== undefined && { parent: args.parent }),
1170
+ ...(args.unnest === true && { unnest: true }),
1171
+ ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
1172
+ ...(args.clear_shortcut === true && { clearShortcut: true }),
1173
+ }, opts));
795
1174
  }));
796
- server.registerTool("set_todo_status", {
797
- description: "Set a to-do's status: completed, canceled, or open (reopening a " +
798
- "completed/canceled to-do). Not available for repeating to-dos.",
1175
+ server.registerTool("set_status", {
1176
+ description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
1177
+ "(reopening a completed/canceled item). Not available for repeating to-dos. " +
1178
+ "scope project, completing or canceling requires a children policy: 'require-resolved' " +
1179
+ "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
1180
+ "the project (canceling never alters already-completed children). scope project, status " +
1181
+ "open, restore_children also reopens the to-dos that were resolved with the project.",
799
1182
  inputSchema: {
800
- uuid: z.string(),
1183
+ scope: z.enum(["todo", "project"]),
1184
+ uuid: z
1185
+ .string()
1186
+ .describe("The item — a to-do by uuid; a project also accepts a unique name"),
801
1187
  status: z.enum(["completed", "canceled", "open"]),
1188
+ children: z
1189
+ .enum(["require-resolved", "auto-complete", "auto-cancel"])
1190
+ .optional()
1191
+ .describe("scope project, completed/canceled: what to do with the project's open to-dos"),
1192
+ restore_children: z
1193
+ .boolean()
1194
+ .optional()
1195
+ .describe("scope project, open only: also reopen the to-dos resolved with the project"),
802
1196
  ...dryRunShape,
1197
+ ...opIdShape,
803
1198
  },
804
1199
  annotations: NON_DESTRUCTIVE,
805
1200
  }, async (args) => guard(async () => {
806
1201
  const c = getClient();
807
1202
  const opts = writeOptions(args);
808
- return mutationResult(args.status === "completed"
809
- ? await c.write.completeTodo(args.uuid, opts)
810
- : args.status === "canceled"
811
- ? await c.write.cancelTodo(args.uuid, opts)
812
- : await c.write.reopenTodo(args.uuid, opts));
1203
+ if (args.scope === "todo") {
1204
+ if (args.children !== undefined || args.restore_children !== undefined) {
1205
+ return usage("children/restore_children apply only to scope project");
1206
+ }
1207
+ return mutationResult(args.status === "completed"
1208
+ ? await c.write.completeTodo(args.uuid, opts)
1209
+ : args.status === "canceled"
1210
+ ? await c.write.cancelTodo(args.uuid, opts)
1211
+ : await c.write.reopenTodo(args.uuid, opts));
1212
+ }
1213
+ // scope project
1214
+ if (args.status !== "open" && args.restore_children !== undefined) {
1215
+ return usage("restore_children applies only to status 'open'");
1216
+ }
1217
+ if (args.status === "completed") {
1218
+ if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1219
+ return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1220
+ }
1221
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
1222
+ }
1223
+ if (args.status === "canceled") {
1224
+ if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1225
+ return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1226
+ }
1227
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
1228
+ }
1229
+ if (args.children !== undefined) {
1230
+ return usage("children applies only to status 'completed' or 'canceled'");
1231
+ }
1232
+ // Reopening a project is a multi-leg compound — single-op idempotency
1233
+ // (op_id) does not apply to it in phase 1 (every other set_status path
1234
+ // is a single mutation). Refuse rather than silently drop the key.
1235
+ if (args.op_id !== undefined) {
1236
+ return usage("op_id is not available when reopening a project (a multi-leg compound in phase 1) — " +
1237
+ "use the batch tool with a per-line op_id");
1238
+ }
1239
+ const outcome = await c.write.reopenProject(args.uuid, {
1240
+ ...opts,
1241
+ ...(args.restore_children === true && { restoreChildren: true }),
1242
+ });
1243
+ return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
1244
+ ? jsonResult(outcome)
1245
+ : mutationResult(outcome.project);
813
1246
  }));
814
1247
  server.registerTool("move_todo", {
815
- description: "Move a to-do. Pass exactly one destination: a project and/or area (optionally an " +
816
- "existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
817
- "removes any schedule; detach removes the project/area/heading assignment while " +
818
- "keeping the schedule. Moving into a completed or canceled project reopens that " +
819
- "project pass acknowledge_project_reopen to confirm.",
1248
+ description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
1249
+ "belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
1250
+ "without changing membership, call this with a position (first/last/before/after) and " +
1251
+ "NO destination that is an in-place reorder, anchored at the earliest movee's slot, " +
1252
+ "and unmentioned siblings keep their order. The uuids order is the order they land " +
1253
+ "(name them backwards to reverse). Pass at most one destination: to_project, to_heading " +
1254
+ "(within to_project, or the movees' shared project), to_area, no_heading (leave the " +
1255
+ "heading, stay in the project), loose (leave heading, project, AND area), or to_inbox. " +
1256
+ "An anchor (before/after) positions but never migrates — an anchor-only move that would " +
1257
+ "cross containers is refused, as is a --before/--after whose movees span buckets. " +
1258
+ "Membership always succeeds; top-of-bucket placement is guaranteed only where a reorder " +
1259
+ "protocol exists (the result's placementClass states which). Moving into a " +
1260
+ "completed/canceled project reopens it — pass acknowledge_project_reopen.",
820
1261
  inputSchema: {
821
- uuid: z.string(),
822
- project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
823
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
824
- heading: z.string().optional().describe("Existing heading in the destination project"),
825
- to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
826
- detach: z
1262
+ uuids: z.array(z.string()).describe("The to-dos to move, in the order they should land"),
1263
+ to_project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
1264
+ to_heading: z
1265
+ .string()
1266
+ .optional()
1267
+ .describe("Destination heading (exact title or uuid; within to_project or the shared project)"),
1268
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1269
+ no_heading: z
827
1270
  .boolean()
828
1271
  .optional()
829
- .describe("Remove the project/area/heading assignment, keeping the schedule"),
1272
+ .describe("Leave the heading but stay in the current project (unheaded block)"),
1273
+ loose: z
1274
+ .boolean()
1275
+ .optional()
1276
+ .describe("Detach from heading, project, AND area, keeping the schedule"),
1277
+ to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
1278
+ ...positionShape,
830
1279
  acknowledge_project_reopen: z
831
1280
  .boolean()
832
1281
  .optional()
@@ -835,21 +1284,37 @@ export function createThingsMcpServer(options = {}) {
835
1284
  },
836
1285
  annotations: NON_DESTRUCTIVE,
837
1286
  }, async (args) => guard(async () => {
838
- const dest = args.project !== undefined || args.area !== undefined || args.heading !== undefined;
839
- const modes = [dest, args.to_inbox === true, args.detach === true].filter(Boolean).length;
840
- if (modes !== 1) {
841
- return usage("pass exactly one destination: project/area/heading, to_inbox, or detach");
1287
+ const dests = [];
1288
+ if (args.to_heading !== undefined) {
1289
+ dests.push({
1290
+ kind: "heading",
1291
+ sel: args.to_heading,
1292
+ ...(args.to_project !== undefined && { project: containerRef(args.to_project) }),
1293
+ });
842
1294
  }
843
- return mutationResult(await getClient().write.moveTodo(args.uuid, {
844
- ...(args.project !== undefined && { project: containerRef(args.project) }),
845
- ...(args.area !== undefined && { area: containerRef(args.area) }),
846
- ...(args.heading !== undefined && { heading: args.heading }),
847
- ...(args.to_inbox === true && { inbox: true }),
848
- ...(args.detach === true && { detach: true }),
849
- }, {
850
- ...writeOptions(args),
851
- ...(args.to_inbox === true && { vector: "applescript" }),
852
- }));
1295
+ else if (args.to_project !== undefined) {
1296
+ dests.push({ kind: "project", ref: containerRef(args.to_project) });
1297
+ }
1298
+ if (args.to_area !== undefined)
1299
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1300
+ if (args.no_heading === true)
1301
+ dests.push({ kind: "no-heading" });
1302
+ if (args.loose === true)
1303
+ dests.push({ kind: "loose" });
1304
+ if (args.to_inbox === true)
1305
+ dests.push({ kind: "inbox" });
1306
+ if (dests.length > 1) {
1307
+ return usage("pass at most one destination (to_project/to_heading/to_area/no_heading/loose/to_inbox)");
1308
+ }
1309
+ const position = movePositionArgs(args);
1310
+ if (position === "conflict")
1311
+ return usage("pass at most one of first/last/before/after");
1312
+ const request = {
1313
+ uuids: args.uuids,
1314
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1315
+ ...(position !== undefined && { position }),
1316
+ };
1317
+ return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
853
1318
  }));
854
1319
  server.registerTool("set_tags", {
855
1320
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
@@ -861,6 +1326,7 @@ export function createThingsMcpServer(options = {}) {
861
1326
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
862
1327
  ...createTagsShape,
863
1328
  ...dryRunShape,
1329
+ ...opIdShape,
864
1330
  },
865
1331
  annotations: NON_DESTRUCTIVE,
866
1332
  }, async (args) => guard(async () => {
@@ -965,23 +1431,56 @@ export function createThingsMcpServer(options = {}) {
965
1431
  return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
966
1432
  }));
967
1433
  // ------------------------------------------------- to-dos AND projects
968
- server.registerTool("delete_item", {
969
- description: "Move a to-do or project to the Trash (recoverable via restore_item until the " +
970
- "Trash is emptied). Deleting a project sends its to-dos to the Trash with it. Not " +
971
- "available for repeating to-dos.",
972
- inputSchema: { uuid: z.string(), ...dryRunShape },
1434
+ server.registerTool("delete", {
1435
+ description: "Delete a to-do/project (kind item), an area, or a tag kind selects which. " +
1436
+ "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1437
+ "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1438
+ "repeating to-dos). kind area: PERMANENT — areas do not go to the Trash, so this cannot " +
1439
+ "be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
1440
+ "projects to the Trash, so a non-empty area is refused unless you pass " +
1441
+ "allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
1442
+ "dangerously_permanent; the tag is removed from every item, and if it has nested child " +
1443
+ "tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
1444
+ inputSchema: {
1445
+ kind: z.enum(["item", "area", "tag"]),
1446
+ uuid: z
1447
+ .string()
1448
+ .describe("The target — an item by uuid; an area or tag also accepts a unique name"),
1449
+ dangerously_permanent: z
1450
+ .boolean()
1451
+ .optional()
1452
+ .describe("kind area/tag: confirm permanent, unrecoverable deletion"),
1453
+ allow_non_empty_area: z
1454
+ .boolean()
1455
+ .optional()
1456
+ .describe("kind area: delete a non-empty area together with its contents (its to-dos and projects move to the Trash)"),
1457
+ acknowledge_tag_subtree: z
1458
+ .boolean()
1459
+ .optional()
1460
+ .describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
1461
+ ...dryRunShape,
1462
+ ...opIdShape,
1463
+ },
973
1464
  annotations: DESTRUCTIVE,
974
1465
  }, async (args) => guard(async () => {
975
1466
  const c = getClient();
976
- return mutationResult(itemType(args.uuid) === "to-do"
977
- ? await c.write.deleteTodo(args.uuid, writeOptions(args))
978
- : await c.write.deleteProject(args.uuid, writeOptions(args)));
1467
+ const opts = writeOptions(args);
1468
+ switch (args.kind) {
1469
+ case "item":
1470
+ return mutationResult(itemType(args.uuid) === "to-do"
1471
+ ? await c.write.deleteTodo(args.uuid, opts)
1472
+ : await c.write.deleteProject(args.uuid, opts));
1473
+ case "area":
1474
+ return mutationResult(await c.write.deleteArea(args.uuid, opts));
1475
+ case "tag":
1476
+ return mutationResult(await c.write.deleteTag(args.uuid, opts));
1477
+ }
979
1478
  }));
980
1479
  server.registerTool("restore_item", {
981
1480
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
982
1481
  "previous schedule or project/area. A project is restored in place: its schedule, " +
983
1482
  "area, and children come back exactly as they were.",
984
- inputSchema: { uuid: z.string(), ...dryRunShape },
1483
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
985
1484
  annotations: NON_DESTRUCTIVE,
986
1485
  }, async (args) => guard(async () => {
987
1486
  const c = getClient();
@@ -998,6 +1497,7 @@ export function createThingsMcpServer(options = {}) {
998
1497
  completion_date: z.string().optional().describe(DATE_FORMAT),
999
1498
  creation_date: z.string().optional().describe(DATE_FORMAT),
1000
1499
  ...dryRunShape,
1500
+ ...opIdShape,
1001
1501
  },
1002
1502
  annotations: NON_DESTRUCTIVE,
1003
1503
  }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
@@ -1014,6 +1514,7 @@ export function createThingsMcpServer(options = {}) {
1014
1514
  creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
1015
1515
  notes: z.string().optional(),
1016
1516
  ...dryRunShape,
1517
+ ...opIdShape,
1017
1518
  },
1018
1519
  annotations: NON_DESTRUCTIVE,
1019
1520
  }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
@@ -1022,78 +1523,192 @@ export function createThingsMcpServer(options = {}) {
1022
1523
  ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1023
1524
  ...(args.notes !== undefined && { notes: args.notes }),
1024
1525
  }, writeOptions(args)))));
1025
- server.registerTool("create_heading", {
1026
- description: "Create a heading inside an existing project; its uuid is returned. The project " +
1027
- "must name an existing project. Uses the Things proxy shortcuts set them up once " +
1028
- "with `things setup shortcuts`.",
1029
- inputSchema: {
1030
- project: z.string().describe(`Existing project (${REF_FORMAT})`),
1031
- title: z.string(),
1032
- ...dryRunShape,
1033
- },
1034
- annotations: NON_DESTRUCTIVE,
1035
- }, async (args) => guard(async () => mutationResult(await getClient().write.createHeading(containerRef(args.project), args.title, writeOptions(args)))));
1036
- server.registerTool("clear_reminder", {
1037
- description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1038
- "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
1039
- "to-do); otherwise a non-repeating dated to-do falls back to a URL re-schedule that " +
1040
- "briefly moves it to Today and back. Reversible with the undo tool.",
1041
- inputSchema: { uuid: z.string(), ...dryRunShape },
1042
- annotations: NON_DESTRUCTIVE,
1043
- }, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
1044
- server.registerTool("rename_heading", {
1045
- description: "Rename a heading in place (works on archived headings too).",
1046
- inputSchema: { uuid: z.string(), title: z.string(), ...dryRunShape },
1047
- annotations: NON_DESTRUCTIVE,
1048
- }, async (args) => guard(async () => mutationResult(await getClient().write.renameHeading(args.uuid, args.title, writeOptions(args)))));
1049
- server.registerTool("archive_heading", {
1050
- description: "Archive a heading — it leaves the active project view (reversible with " +
1051
- "unarchive_heading). The preferred way to retire a heading: row deletion only " +
1052
- "exists in the app's UI / Shortcuts behind a per-run consent dialog. With open " +
1053
- "children the children policy is required: complete or cancel resolve them with " +
1054
- "the heading in one cascade; reparent moves them to the project root first, " +
1055
- "keeping them open (a compound sequence that undo reverses as one unit).",
1526
+ server.registerTool("heading", {
1527
+ description: "Manage a project's headings action selects which; project is always required, and a " +
1528
+ "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
1529
+ "new heading in the project (project + title; returns its uuid; uses the Things proxy " +
1530
+ "shortcuts, set up once with `things setup shortcuts`); a placement flag positions it, " +
1531
+ "else it appends. rename_heading: rename in place (project + heading + title; works on " +
1532
+ "archived headings). archive_heading: retire a heading so it leaves the active project " +
1533
+ "view (reversible with unarchive_heading); with open children pass children — complete " +
1534
+ "or cancel resolve them with the heading in one cascade, reparent moves them to the " +
1535
+ "project root keeping them open. unarchive_heading: bring an archived heading back; " +
1536
+ "restore_children also reopens the children the archive resolved with it. " +
1537
+ "promote_heading: promote a heading into a new project — this REPLACES the heading and " +
1538
+ "cannot be undone (its to-dos move under the new project), and requires " +
1539
+ "dangerously_drive_gui. move_heading: reposition headings as an ordered block (children " +
1540
+ "follow); pass exactly one placement flag. Reordering headings needs allow-experimental. " +
1541
+ "move_heading_to_project: relocate ONE heading (with its to-dos) to a DIFFERENT project " +
1542
+ "(project + heading + to_project) — the cross-project move, distinct from move_heading's " +
1543
+ "within-project reorder; GUI-only (requires dangerously_drive_gui), fails closed on a " +
1544
+ "source-heading or destination-project title collision, and has no undo (move it back). " +
1545
+ "dissolve_heading: remove a heading but KEEP its to-dos as direct project children (NOT " +
1546
+ "trashed the opposite of a delete cascade); GUI-only (requires dangerously_drive_gui), " +
1547
+ "fails closed on a title collision, no undo.",
1056
1548
  inputSchema: {
1057
- uuid: z.string(),
1549
+ action: z.enum([
1550
+ "add_heading",
1551
+ "rename_heading",
1552
+ "archive_heading",
1553
+ "unarchive_heading",
1554
+ "promote_heading",
1555
+ "move_heading",
1556
+ "move_heading_to_project",
1557
+ "dissolve_heading",
1558
+ ]),
1559
+ project: z.string().describe(`the heading's project (${REF_FORMAT})`),
1560
+ heading: z
1561
+ .string()
1562
+ .optional()
1563
+ .describe("rename/archive/unarchive/promote: the heading selector (exact title or uuid)"),
1564
+ headings: z
1565
+ .array(z.string())
1566
+ .optional()
1567
+ .describe("move_heading: heading selectors in the order they should land"),
1568
+ to_project: z
1569
+ .string()
1570
+ .optional()
1571
+ .describe(`move_heading_to_project: the destination project (${REF_FORMAT})`),
1572
+ title: z
1573
+ .string()
1574
+ .optional()
1575
+ .describe("add_heading: the new heading; rename_heading: the new title"),
1058
1576
  children: z
1059
1577
  .enum(["complete", "cancel", "reparent"])
1060
1578
  .optional()
1061
- .describe("Required when the heading has open children"),
1579
+ .describe("archive_heading: required when the heading has open children"),
1580
+ restore_children: z
1581
+ .boolean()
1582
+ .optional()
1583
+ .describe("unarchive_heading: also reopen the children archived with the heading"),
1584
+ first: z
1585
+ .boolean()
1586
+ .optional()
1587
+ .describe("add/move: place first among the project's headings"),
1588
+ last: z.boolean().optional().describe("add/move: place last among the project's headings"),
1589
+ before_heading: z
1590
+ .string()
1591
+ .optional()
1592
+ .describe("add/move: place immediately before this heading (title or uuid)"),
1593
+ after_heading: z
1594
+ .string()
1595
+ .optional()
1596
+ .describe("add/move: place immediately after this heading (title or uuid)"),
1597
+ ...driveGuiShape,
1062
1598
  ...dryRunShape,
1063
1599
  },
1064
- annotations: NON_DESTRUCTIVE,
1600
+ annotations: DESTRUCTIVE,
1065
1601
  }, async (args) => guard(async () => {
1066
- const r = await getClient().write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, writeOptions(args));
1067
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1068
- ? jsonResult(r)
1069
- : mutationResult(r.heading);
1602
+ const c = getClient();
1603
+ const opts = writeOptions(args);
1604
+ const proj = c.resolve.project(args.project);
1605
+ const placementCount = [
1606
+ args.first === true,
1607
+ args.last === true,
1608
+ args.before_heading !== undefined,
1609
+ args.after_heading !== undefined,
1610
+ ].filter(Boolean).length;
1611
+ const placement = () => {
1612
+ if (args.first === true)
1613
+ return { position: "first" };
1614
+ if (args.last === true)
1615
+ return { position: "last" };
1616
+ if (args.before_heading !== undefined) {
1617
+ return { before: c.resolve.heading(proj.uuid, args.before_heading).uuid };
1618
+ }
1619
+ if (args.after_heading !== undefined) {
1620
+ return { after: c.resolve.heading(proj.uuid, args.after_heading).uuid };
1621
+ }
1622
+ return undefined;
1623
+ };
1624
+ switch (args.action) {
1625
+ case "add_heading": {
1626
+ if (args.title === undefined)
1627
+ return usage('action "add_heading" requires title');
1628
+ if (placementCount > 1) {
1629
+ return usage("pass at most one of first/last/before_heading/after_heading");
1630
+ }
1631
+ return mutationResult(await c.write.addHeading({ uuid: proj.uuid }, args.title, placement(), opts));
1632
+ }
1633
+ case "rename_heading": {
1634
+ if (args.heading === undefined || args.title === undefined) {
1635
+ return usage('action "rename_heading" requires heading and title');
1636
+ }
1637
+ const h = c.resolve.heading(proj.uuid, args.heading);
1638
+ return mutationResult(await c.write.renameHeading(h.uuid, args.title, opts));
1639
+ }
1640
+ case "archive_heading": {
1641
+ if (args.heading === undefined)
1642
+ return usage('action "archive_heading" requires heading');
1643
+ const h = c.resolve.heading(proj.uuid, args.heading);
1644
+ const r = await c.write.archiveHeading(h.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
1645
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1646
+ ? jsonResult(r)
1647
+ : mutationResult(r.heading);
1648
+ }
1649
+ case "unarchive_heading": {
1650
+ if (args.heading === undefined) {
1651
+ return usage('action "unarchive_heading" requires heading');
1652
+ }
1653
+ const h = c.resolve.heading(proj.uuid, args.heading);
1654
+ const r = await c.write.unarchiveHeading(h.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
1655
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1656
+ ? jsonResult(r)
1657
+ : mutationResult(r.heading);
1658
+ }
1659
+ case "promote_heading": {
1660
+ if (args.heading === undefined)
1661
+ return usage('action "promote_heading" requires heading');
1662
+ const h = c.resolve.heading(proj.uuid, args.heading);
1663
+ return mutationResult(await c.write.run("project.promote-heading", { uuid: h.uuid }, opts));
1664
+ }
1665
+ case "move_heading": {
1666
+ if (args.headings === undefined || args.headings.length === 0) {
1667
+ return usage('action "move_heading" requires headings');
1668
+ }
1669
+ if (placementCount !== 1) {
1670
+ return usage("move_heading requires exactly one of first/last/before_heading/after_heading");
1671
+ }
1672
+ const headings = args.headings.map((s) => c.resolve.heading(proj.uuid, s).uuid);
1673
+ return mutationResult(await c.write.moveHeading({ uuid: proj.uuid }, headings, placement(), opts));
1674
+ }
1675
+ case "move_heading_to_project": {
1676
+ if (args.heading === undefined || args.to_project === undefined) {
1677
+ return usage('action "move_heading_to_project" requires heading and to_project');
1678
+ }
1679
+ const dest = c.resolve.project(args.to_project);
1680
+ return mutationResult(await c.write.moveHeadingToProject({ uuid: proj.uuid }, args.heading, { uuid: dest.uuid }, opts));
1681
+ }
1682
+ case "dissolve_heading": {
1683
+ if (args.heading === undefined)
1684
+ return usage('action "dissolve_heading" requires heading');
1685
+ const h = c.resolve.heading(proj.uuid, args.heading);
1686
+ return mutationResult(await c.write.dissolveHeading(h.uuid, opts));
1687
+ }
1688
+ }
1070
1689
  }));
1071
- server.registerTool("unarchive_heading", {
1072
- description: "Un-archive a heading. restore_children also reopens the children the archive " +
1073
- "cascade resolved with it (matching resolution timestamps; someday state " +
1074
- "survives). Children resolved at other times are never touched.",
1690
+ server.registerTool("convert_to_project", {
1691
+ description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
1692
+ "are kept); the to-do's identity is gone and it cannot be undone. Requires " +
1693
+ "dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
1694
+ "use the heading tool's promote_heading action.)",
1075
1695
  inputSchema: {
1076
- uuid: z.string(),
1077
- restore_children: z.boolean().optional(),
1696
+ uuid: z.string().describe("the to-do's uuid"),
1697
+ ...driveGuiShape,
1078
1698
  ...dryRunShape,
1699
+ ...opIdShape,
1079
1700
  },
1701
+ annotations: DESTRUCTIVE,
1702
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
1703
+ server.registerTool("clear_reminder", {
1704
+ description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1705
+ "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
1706
+ "to-do); otherwise a non-repeating dated to-do falls back to a URL re-schedule that " +
1707
+ "briefly moves it to Today and back. Reversible with the undo tool.",
1708
+ inputSchema: { uuid: z.string(), ...dryRunShape },
1080
1709
  annotations: NON_DESTRUCTIVE,
1081
- }, async (args) => guard(async () => {
1082
- const r = await getClient().write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, writeOptions(args));
1083
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1084
- ? jsonResult(r)
1085
- : mutationResult(r.heading);
1086
- }));
1710
+ }, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
1087
1711
  // -------------------------------------------- GUI-driven (Accessibility)
1088
- const driveGuiShape = {
1089
- dangerously_drive_gui: z
1090
- .boolean()
1091
- .optional()
1092
- .describe("Required: this drives the local Things app through its accessibility interface to " +
1093
- "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
1094
- "on the machine running this server, and must be turned on first with `things config " +
1095
- "set ui-enabled true`. Intended for a dedicated always-on Mac."),
1096
- };
1097
1712
  const WEEKDAY_ENUM = z.enum([
1098
1713
  "sunday",
1099
1714
  "monday",
@@ -1103,7 +1718,7 @@ export function createThingsMcpServer(options = {}) {
1103
1718
  "friday",
1104
1719
  "saturday",
1105
1720
  ]);
1106
- // The base rule (also used by create_repeating_project, which stays minimal —
1721
+ // The base rule (also used by the repeat tool's create action, which stays minimal —
1107
1722
  // its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
1108
1723
  const baseRepeatShape = {
1109
1724
  frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
@@ -1171,182 +1786,114 @@ export function createThingsMcpServer(options = {}) {
1171
1786
  fields.startDaysEarlier = a.start_days_earlier;
1172
1787
  return fields;
1173
1788
  };
1174
- server.registerTool("make_repeating", {
1175
- description: "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new recurring " +
1176
- "seriesthe original disappears and a fresh repeating item takes its place, so it " +
1177
- "cannot be undone. Set the frequency and interval, and optionally the weekday set, " +
1178
- "monthly/yearly day, end bound, reminders, or deadline. Returns the new item's uuid.",
1179
- inputSchema: {
1180
- uuid: z.string().describe("The to-do to make repeating"),
1181
- ...repeatRuleShape,
1182
- ...driveGuiShape,
1183
- ...dryRunShape,
1184
- },
1185
- annotations: DESTRUCTIVE,
1186
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.make-repeating", {
1187
- uuid: args.uuid,
1188
- frequency: args.frequency,
1189
- interval: args.interval,
1190
- ...repeatExtras(args, args.frequency),
1191
- }, writeOptions(args)))));
1192
- server.registerTool("reschedule_repeat", {
1193
- description: "Change a repeating to-do's rule in place, keeping the same item. Set the frequency and " +
1194
- "interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, or " +
1195
- "deadline. This can be undone — it restores the previous rule.",
1789
+ server.registerTool("repeat", {
1790
+ description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
1791
+ "interfaceevery action needs dangerously_drive_gui. action start: turn a plain item " +
1792
+ "into a repeating one this REPLACES it with a new series and cannot be undone (give " +
1793
+ "frequency + interval and optionally the weekday set, monthly/yearly day, end bound, " +
1794
+ "reminder, or per-occurrence deadline); returns a repeating block with instanceUuid (the " +
1795
+ "visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
1796
+ "change a repeating item's rule in place, keeping the same item (undoable — it restores " +
1797
+ "the previous rule). action pause/resume: stop or restart its new occurrences, keeping " +
1798
+ "the rule. action add (scope project only): create a project and make it repeating in " +
1799
+ "one call — the project is created first and PERSISTS even if the make-repeating step " +
1800
+ "refuses; give an area to place it or omit it to create in Someday (only frequency and " +
1801
+ "interval are supported); returns the new project's uuid.",
1196
1802
  inputSchema: {
1197
- uuid: z.string().describe("The repeating to-do to reschedule"),
1198
- ...repeatRuleShape,
1199
- ...driveGuiShape,
1200
- ...dryRunShape,
1201
- },
1202
- annotations: NON_DESTRUCTIVE,
1203
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.reschedule-repeat", {
1204
- uuid: args.uuid,
1205
- frequency: args.frequency,
1206
- interval: args.interval,
1207
- ...repeatExtras(args, args.frequency),
1208
- }, writeOptions(args)))));
1209
- server.registerTool("set_repeat_state", {
1210
- description: "Pause or resume a repeating to-do. 'pause' stops it spawning new occurrences but keeps " +
1211
- "its rule; 'resume' starts it again. The two are inverses of each other.",
1212
- inputSchema: {
1213
- uuid: z.string().describe("The repeating to-do"),
1214
- state: z.enum(["pause", "resume"]),
1215
- ...driveGuiShape,
1216
- ...dryRunShape,
1217
- },
1218
- annotations: NON_DESTRUCTIVE,
1219
- }, async (args) => guard(async () => {
1220
- const op = args.state === "pause" ? "todo.pause-repeat" : "todo.resume-repeat";
1221
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1222
- }));
1223
- server.registerTool("reschedule_project_repeat", {
1224
- description: "Change a repeating project's rule in place, keeping the same project. Set the frequency " +
1225
- "and interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, " +
1226
- "or deadline. This can be undone — it restores the previous rule.",
1227
- inputSchema: {
1228
- uuid: z.string().describe(`The repeating project to reschedule (${REF_FORMAT})`),
1229
- ...repeatRuleShape,
1230
- ...driveGuiShape,
1231
- ...dryRunShape,
1232
- },
1233
- annotations: NON_DESTRUCTIVE,
1234
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("project.reschedule-repeat", {
1235
- uuid: args.uuid,
1236
- frequency: args.frequency,
1237
- interval: args.interval,
1238
- ...repeatExtras(args, args.frequency),
1239
- }, writeOptions(args)))));
1240
- server.registerTool("set_project_repeat_state", {
1241
- description: "Pause or resume a repeating project. 'pause' stops it spawning new occurrences but keeps " +
1242
- "its rule; 'resume' starts it again. The two are inverses of each other.",
1243
- inputSchema: {
1244
- uuid: z.string().describe(`The repeating project (${REF_FORMAT})`),
1245
- state: z.enum(["pause", "resume"]),
1246
- ...driveGuiShape,
1247
- ...dryRunShape,
1248
- },
1249
- annotations: NON_DESTRUCTIVE,
1250
- }, async (args) => guard(async () => {
1251
- const op = args.state === "pause" ? "project.pause-repeat" : "project.resume-repeat";
1252
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1253
- }));
1254
- server.registerTool("reorder_area", {
1255
- description: "Move an area to a new position in the area order. Give the area plus exactly one " +
1256
- "destination: before/after another area, or position first/last. The move is made by " +
1257
- "driving the Things window with the pointer — the app comes to the front and the " +
1258
- "sidebar may scroll while the area is dragged; the area's projects and to-dos are " +
1259
- "untouched. Area references are a uuid or a unique name.",
1260
- inputSchema: {
1261
- target: z.string().describe("The area to move (uuid or unique name)"),
1262
- before: z
1803
+ scope: z.enum(["todo", "project"]),
1804
+ action: z.enum(["start", "reschedule", "pause", "resume", "add"]),
1805
+ uuid: z
1263
1806
  .string()
1264
1807
  .optional()
1265
- .describe("Place it immediately above this area (uuid or unique name)"),
1266
- after: z
1808
+ .describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
1809
+ title: z.string().optional().describe("add (project): the new project's title"),
1810
+ notes: z.string().optional().describe("add (project): notes"),
1811
+ area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
1812
+ project_deadline: z
1267
1813
  .string()
1268
1814
  .optional()
1269
- .describe("Place it immediately below this area (uuid or unique name)"),
1270
- position: z
1271
- .enum(["first", "last"])
1815
+ .describe(`add (project): the project's due date ${DATE_FORMAT}`),
1816
+ todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
1817
+ frequency: z
1818
+ .enum(["daily", "weekly", "monthly", "yearly"])
1272
1819
  .optional()
1273
- .describe("Move it to the top or bottom of the area list"),
1274
- ...driveGuiShape,
1275
- ...dryRunShape,
1276
- },
1277
- annotations: NON_DESTRUCTIVE,
1278
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("area.reorder", {
1279
- target: args.target,
1280
- ...(args.before !== undefined && { before: args.before }),
1281
- ...(args.after !== undefined && { after: args.after }),
1282
- ...(args.position !== undefined && { position: args.position }),
1283
- }, writeOptions(args)))));
1284
- server.registerTool("make_project_repeating", {
1285
- description: "Turn an existing project into a repeating one. This REPLACES the project with a new " +
1286
- "recurring series — the original disappears and a fresh repeating project takes its place " +
1287
- "(its area is kept), so it cannot be undone. An area-less project scheduled for Anytime is " +
1288
- "moved to Someday first (a cleanup-free intermediate step, shown by dry_run). Set the " +
1289
- "frequency and interval, and optionally the weekday set, monthly/yearly day, end bound, " +
1290
- "reminders, or deadline. Returns the new project's uuid.",
1291
- inputSchema: {
1292
- uuid: z.string().describe(`The project to make repeating (${REF_FORMAT})`),
1293
- ...repeatRuleShape,
1294
- ...driveGuiShape,
1295
- ...dryRunShape,
1296
- },
1297
- annotations: DESTRUCTIVE,
1298
- }, async (args) => guard(async () => mutationResult(await getClient().write.makeRepeatingProject(args.uuid, {
1299
- frequency: args.frequency,
1300
- interval: args.interval,
1301
- ...repeatExtras(args, args.frequency),
1302
- }, writeOptions(args)))));
1303
- server.registerTool("create_repeating_project", {
1304
- description: "Create a project and make it repeating in one call. TWO operations: the project is " +
1305
- "created first and PERSISTS even if the make-repeating step refuses; then it is promoted " +
1306
- "(which drives the GUI). Give an area to place it, or omit it to create in Someday. Only a " +
1307
- "frequency and an interval are supported. Returns the new repeating project's uuid.",
1308
- inputSchema: {
1309
- title: z.string(),
1310
- notes: z.string().optional(),
1311
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1312
- deadline: z.string().optional().describe(DATE_FORMAT),
1313
- todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1314
- ...baseRepeatShape,
1315
- ...driveGuiShape,
1316
- ...dryRunShape,
1317
- },
1318
- annotations: DESTRUCTIVE,
1319
- }, async (args) => guard(async () => mutationResult(await getClient().write.createRepeatingProject({
1320
- title: args.title,
1321
- ...(args.notes !== undefined && { notes: args.notes }),
1322
- ...(args.area !== undefined && { area: containerRef(args.area) }),
1323
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1324
- ...(args.todos !== undefined && { todos: args.todos }),
1325
- frequency: args.frequency,
1326
- interval: args.interval,
1327
- }, writeOptions(args)))));
1328
- server.registerTool("convert_to_project", {
1329
- description: "Convert a to-do or a heading into a project. This REPLACES the original with a new " +
1330
- "project (a converted to-do keeps its notes; a converted heading is promoted alongside " +
1331
- "its project and its to-dos move under the new project). The original is gone and this " +
1332
- "cannot be undone. Returns the new project's uuid.",
1333
- inputSchema: {
1334
- uuid: z.string().describe("The to-do or heading to convert"),
1820
+ .describe("start/reschedule/add: how often it repeats"),
1821
+ interval: z
1822
+ .number()
1823
+ .int()
1824
+ .min(1)
1825
+ .max(99)
1826
+ .optional()
1827
+ .describe("start/reschedule/add: every N units (1–99)"),
1828
+ after_completion: repeatRuleShape.after_completion,
1829
+ weekdays: repeatRuleShape.weekdays,
1830
+ monthly_day: repeatRuleShape.monthly_day,
1831
+ monthly_weekday: repeatRuleShape.monthly_weekday,
1832
+ monthly_ordinal: repeatRuleShape.monthly_ordinal,
1833
+ yearly_month: repeatRuleShape.yearly_month,
1834
+ ends_after: repeatRuleShape.ends_after,
1835
+ ends_on: repeatRuleShape.ends_on,
1836
+ reminder: repeatRuleShape.reminder,
1837
+ deadline: repeatRuleShape.deadline,
1838
+ start_days_earlier: repeatRuleShape.start_days_earlier,
1335
1839
  ...driveGuiShape,
1336
1840
  ...dryRunShape,
1337
1841
  },
1338
1842
  annotations: DESTRUCTIVE,
1339
1843
  }, async (args) => guard(async () => {
1340
- const item = getClient().read.byUuid(args.uuid);
1341
- if (item === null)
1342
- throw new RangeError(`no item with uuid ${args.uuid}`);
1343
- const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
1344
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1844
+ const c = getClient();
1845
+ const opts = writeOptions(args);
1846
+ const { frequency, interval } = args;
1847
+ if (args.action === "add") {
1848
+ if (args.scope !== "project")
1849
+ return usage('action "add" requires scope "project"');
1850
+ if (args.title === undefined)
1851
+ return usage('action "add" requires title');
1852
+ if (frequency === undefined || interval === undefined) {
1853
+ return usage('action "add" requires frequency and interval');
1854
+ }
1855
+ return mutationResult(await c.write.addRepeatingProject({
1856
+ title: args.title,
1857
+ ...(args.notes !== undefined && { notes: args.notes }),
1858
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1859
+ ...(args.project_deadline !== undefined && { deadline: args.project_deadline }),
1860
+ ...(args.todos !== undefined && { todos: args.todos }),
1861
+ frequency,
1862
+ interval,
1863
+ }, opts));
1864
+ }
1865
+ if (args.uuid === undefined)
1866
+ return usage(`action "${args.action}" requires uuid`);
1867
+ if (args.action === "pause" || args.action === "resume") {
1868
+ const op = args.scope === "todo"
1869
+ ? args.action === "pause"
1870
+ ? "todo.pause-repeat"
1871
+ : "todo.resume-repeat"
1872
+ : args.action === "pause"
1873
+ ? "project.pause-repeat"
1874
+ : "project.resume-repeat";
1875
+ return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
1876
+ }
1877
+ // start | reschedule
1878
+ if (frequency === undefined || interval === undefined) {
1879
+ return usage(`action "${args.action}" requires frequency and interval`);
1880
+ }
1881
+ const extras = repeatExtras(args, frequency);
1882
+ if (args.scope === "todo") {
1883
+ const op = args.action === "start" ? "todo.make-repeating" : "todo.reschedule-repeat";
1884
+ return mutationResult(await c.write.run(op, { uuid: args.uuid, frequency, interval, ...extras }, opts));
1885
+ }
1886
+ // scope project
1887
+ if (args.action === "reschedule") {
1888
+ return mutationResult(await c.write.run("project.reschedule-repeat", { uuid: args.uuid, frequency, interval, ...extras }, opts));
1889
+ }
1890
+ // scope project, action start → the dedicated make-repeating method
1891
+ return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
1345
1892
  }));
1346
1893
  server.registerTool("duplicate_item", {
1347
1894
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
1348
1895
  "includes its children. Not available for repeating items.",
1349
- inputSchema: { uuid: z.string(), ...dryRunShape },
1896
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
1350
1897
  annotations: NON_DESTRUCTIVE,
1351
1898
  }, async (args) => guard(async () => {
1352
1899
  const c = getClient();
@@ -1365,127 +1912,56 @@ export function createThingsMcpServer(options = {}) {
1365
1912
  when: whenSchema,
1366
1913
  deadline: z.string().optional().describe(DATE_FORMAT),
1367
1914
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1915
+ ...tzShape,
1368
1916
  ...dryRunShape,
1369
- },
1370
- annotations: NON_DESTRUCTIVE,
1371
- }, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
1372
- title: args.title,
1373
- ...(args.notes !== undefined && { notes: args.notes }),
1374
- ...(args.area !== undefined && { area: containerRef(args.area) }),
1375
- ...(args.when !== undefined && { when: args.when }),
1376
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1377
- ...(args.todos !== undefined && { todos: args.todos }),
1378
- }, writeOptions(args)))));
1379
- server.registerTool("update_project", {
1380
- description: "Update a project's title, notes, schedule, reminder, or deadline. " +
1381
- "append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
1382
- "Changing the schedule keeps an existing reminder unless the call sets a new one. " +
1383
- "clear_reminder works while the project is scheduled for today or this evening; a " +
1384
- "reminder on a future date can only be changed, not cleared.",
1385
- inputSchema: {
1386
- uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
1387
- title: z.string().optional(),
1388
- notes: z.string().optional().describe("Replaces the whole notes body"),
1389
- append_notes: z.string().optional(),
1390
- prepend_notes: z.string().optional(),
1391
- when: whenSchema,
1392
- reminder: z.string().optional().describe(REMINDER_FORMAT),
1393
- clear_reminder: z.boolean().optional(),
1394
- deadline: z.string().optional().describe(DATE_FORMAT),
1395
- clear_deadline: z.boolean().optional(),
1396
- ...dryRunShape,
1917
+ ...opIdShape,
1397
1918
  },
1398
1919
  annotations: NON_DESTRUCTIVE,
1399
1920
  }, async (args) => guard(async () => {
1400
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
1401
- if (notesModes.length > 1) {
1402
- return usage("notes, append_notes, prepend_notes are exclusive");
1403
- }
1404
- if (args.reminder !== undefined && args.clear_reminder === true) {
1405
- return usage("pass at most one of reminder / clear_reminder");
1406
- }
1407
- if (args.deadline !== undefined && args.clear_deadline === true) {
1408
- return usage("pass at most one of deadline / clear_deadline");
1409
- }
1410
- return mutationResult(await getClient().write.updateProject(args.uuid, {
1411
- ...(args.title !== undefined && { title: args.title }),
1921
+ const badZone = badTz(args.tz);
1922
+ if (badZone !== null)
1923
+ return badZone;
1924
+ return mutationResult(await getClient().write.addProject({
1925
+ title: args.title,
1412
1926
  ...(args.notes !== undefined && { notes: args.notes }),
1413
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1414
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1927
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1415
1928
  ...(args.when !== undefined && { when: args.when }),
1416
- ...(args.reminder !== undefined && { reminder: args.reminder }),
1417
- ...(args.clear_reminder === true && { reminder: null }),
1418
1929
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1419
- ...(args.clear_deadline === true && { deadline: null }),
1930
+ ...(args.todos !== undefined && { todos: args.todos }),
1420
1931
  }, writeOptions(args)));
1421
1932
  }));
1422
- server.registerTool("set_project_status", {
1423
- description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
1424
- "children policy: 'require-resolved' errors if open to-dos remain; " +
1425
- "'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
1426
- "never alters already-completed children). status 'open' reopens a completed or " +
1427
- "canceled project; its children stay completed/canceled unless restore_children " +
1428
- "also reopens the ones that were resolved together with the project.",
1429
- inputSchema: {
1430
- uuid: z.string().describe(`The project (${REF_FORMAT})`),
1431
- status: z.enum(["completed", "canceled", "open"]),
1432
- children: z
1433
- .enum(["require-resolved", "auto-complete", "auto-cancel"])
1434
- .optional()
1435
- .describe("Required for completed/canceled: what to do with the project's open to-dos"),
1436
- restore_children: z
1437
- .boolean()
1438
- .optional()
1439
- .describe("open only: also reopen the to-dos that were resolved with the project"),
1440
- ...dryRunShape,
1441
- },
1442
- annotations: NON_DESTRUCTIVE,
1443
- }, async (args) => guard(async () => {
1444
- const c = getClient();
1445
- if (args.status !== "open" && args.restore_children !== undefined) {
1446
- return usage("restore_children applies only to status 'open'");
1447
- }
1448
- if (args.status === "completed") {
1449
- if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1450
- return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1451
- }
1452
- return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
1453
- }
1454
- if (args.status === "canceled") {
1455
- if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1456
- return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1457
- }
1458
- return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
1459
- }
1460
- if (args.children !== undefined) {
1461
- return usage("children applies only to status 'completed' or 'canceled'");
1462
- }
1463
- const outcome = await c.write.reopenProject(args.uuid, {
1464
- ...writeOptions(args),
1465
- ...(args.restore_children === true && { restoreChildren: true }),
1466
- });
1467
- return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
1468
- ? jsonResult(outcome)
1469
- : mutationResult(outcome.project);
1470
- }));
1471
1933
  server.registerTool("move_project", {
1472
- description: "Move a project into an area, or detach it from its current area. Pass exactly " +
1473
- "one of area / detach. The project's status and schedule are unaffected.",
1934
+ description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
1935
+ "destination: to_area, or no_area (leave the area a project's complete detach). To " +
1936
+ "REORDER projects among their siblings without changing area, pass a position " +
1937
+ "(first/last/before/after) and NO destination. An anchor positions but never migrates. " +
1938
+ "The project's status and schedule are unaffected; the result's placementClass states " +
1939
+ "whether top-of-bucket placement was guaranteed.",
1474
1940
  inputSchema: {
1475
- uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
1476
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1477
- detach: z.boolean().optional().describe("Remove the current area assignment"),
1941
+ uuids: z.array(z.string()).describe(`The projects to move (${REF_FORMAT}), in order`),
1942
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1943
+ no_area: z.boolean().optional().describe("Leave the current area (a project's detach)"),
1944
+ ...positionShape,
1478
1945
  ...dryRunShape,
1479
1946
  },
1480
1947
  annotations: NON_DESTRUCTIVE,
1481
1948
  }, async (args) => guard(async () => {
1482
- if ((args.detach === true) === (args.area !== undefined)) {
1483
- return usage("pass exactly one of area / detach");
1484
- }
1485
- const c = getClient();
1486
- return mutationResult(args.detach === true
1487
- ? await c.write.detachProject(args.uuid, writeOptions(args))
1488
- : await c.write.moveProject(args.uuid, containerRef(args.area), writeOptions(args)));
1949
+ const dests = [];
1950
+ if (args.to_area !== undefined)
1951
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1952
+ if (args.no_area === true)
1953
+ dests.push({ kind: "no-area" });
1954
+ if (dests.length > 1)
1955
+ return usage("pass at most one of to_area / no_area");
1956
+ const position = movePositionArgs(args);
1957
+ if (position === "conflict")
1958
+ return usage("pass at most one of first/last/before/after");
1959
+ const request = {
1960
+ uuids: args.uuids,
1961
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1962
+ ...(position !== undefined && { position }),
1963
+ };
1964
+ return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
1489
1965
  }));
1490
1966
  // ----------------------------------------------------------------- areas
1491
1967
  server.registerTool("add_area", {
@@ -1495,46 +1971,10 @@ export function createThingsMcpServer(options = {}) {
1495
1971
  tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
1496
1972
  ...createTagsShape,
1497
1973
  ...dryRunShape,
1974
+ ...opIdShape,
1498
1975
  },
1499
1976
  annotations: NON_DESTRUCTIVE,
1500
1977
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
1501
- server.registerTool("update_area", {
1502
- description: "Rename an area and/or replace its tags (the full set). Tags must exist unless " +
1503
- "create_tags is set.",
1504
- inputSchema: {
1505
- target: z.string().describe(`Area to update (${REF_FORMAT})`),
1506
- title: z.string().optional().describe("New name"),
1507
- tags: z
1508
- .array(z.string())
1509
- .optional()
1510
- .describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
1511
- ...createTagsShape,
1512
- ...dryRunShape,
1513
- },
1514
- annotations: NON_DESTRUCTIVE,
1515
- }, async (args) => guard(async () => {
1516
- if (args.title === undefined && args.tags === undefined) {
1517
- return usage("pass title and/or tags");
1518
- }
1519
- return mutationResult(await getClient().write.updateArea(args.target, {
1520
- ...(args.title !== undefined && { title: args.title }),
1521
- ...(args.tags !== undefined && { tags: args.tags }),
1522
- }, writeOptions(args)));
1523
- }));
1524
- server.registerTool("delete_area", {
1525
- description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
1526
- "undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
1527
- "projects remain, no longer assigned to any area.",
1528
- inputSchema: {
1529
- target: z.string().describe(`Area to delete (${REF_FORMAT})`),
1530
- dangerously_permanent: z
1531
- .boolean()
1532
- .optional()
1533
- .describe("Confirm permanent, unrecoverable deletion"),
1534
- ...dryRunShape,
1535
- },
1536
- annotations: DESTRUCTIVE,
1537
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
1538
1978
  // ------------------------------------------------------------------ tags
1539
1979
  server.registerTool("add_tag", {
1540
1980
  description: "Create a tag, optionally nested under an existing parent tag.",
@@ -1542,64 +1982,10 @@ export function createThingsMcpServer(options = {}) {
1542
1982
  title: z.string(),
1543
1983
  parent: z.string().optional().describe("Existing parent tag name"),
1544
1984
  ...dryRunShape,
1985
+ ...opIdShape,
1545
1986
  },
1546
1987
  annotations: NON_DESTRUCTIVE,
1547
1988
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
1548
- server.registerTool("update_tag", {
1549
- description: "Rename a tag (existing assignments follow the rename), nest it under another " +
1550
- "existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
1551
- "parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
1552
- inputSchema: {
1553
- target: z.string().describe(`Tag to update (${REF_FORMAT})`),
1554
- title: z.string().optional().describe("New name"),
1555
- parent: z.string().optional().describe("Existing tag to nest under"),
1556
- unnest: z.boolean().optional().describe("Move the tag to the top level"),
1557
- shortcut: z.string().optional().describe("Keyboard shortcut character"),
1558
- clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
1559
- ...dryRunShape,
1560
- },
1561
- annotations: NON_DESTRUCTIVE,
1562
- }, async (args) => guard(async () => {
1563
- if (args.title === undefined &&
1564
- args.parent === undefined &&
1565
- args.unnest === undefined &&
1566
- args.shortcut === undefined &&
1567
- args.clear_shortcut === undefined) {
1568
- return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
1569
- }
1570
- if (args.parent !== undefined && args.unnest === true) {
1571
- return usage("parent and unnest are exclusive");
1572
- }
1573
- if (args.shortcut !== undefined && args.clear_shortcut === true) {
1574
- return usage("shortcut and clear_shortcut are exclusive");
1575
- }
1576
- return mutationResult(await getClient().write.updateTag(args.target, {
1577
- ...(args.title !== undefined && { title: args.title }),
1578
- ...(args.parent !== undefined && { parent: args.parent }),
1579
- ...(args.unnest === true && { unnest: true }),
1580
- ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
1581
- ...(args.clear_shortcut === true && { clearShortcut: true }),
1582
- }, writeOptions(args)));
1583
- }));
1584
- server.registerTool("delete_tag", {
1585
- description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
1586
- "requires dangerously_permanent. The tag is removed from every item. If the tag " +
1587
- "has nested child tags they are ALL permanently deleted with it — pass " +
1588
- "acknowledge_tag_subtree to confirm.",
1589
- inputSchema: {
1590
- target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
1591
- dangerously_permanent: z
1592
- .boolean()
1593
- .optional()
1594
- .describe("Confirm permanent, unrecoverable deletion"),
1595
- acknowledge_tag_subtree: z
1596
- .boolean()
1597
- .optional()
1598
- .describe("Confirm permanent deletion of ALL nested child tags too"),
1599
- ...dryRunShape,
1600
- },
1601
- annotations: DESTRUCTIVE,
1602
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
1603
1989
  // -------------------------------------------------- generic + discovery
1604
1990
  server.registerTool("run_operation", {
1605
1991
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
@@ -1620,25 +2006,48 @@ export function createThingsMcpServer(options = {}) {
1620
2006
  .boolean()
1621
2007
  .optional()
1622
2008
  .describe("Required for area/tag delete and trash.empty (PERMANENT, no Trash)"),
2009
+ allow_non_empty_area: z
2010
+ .boolean()
2011
+ .optional()
2012
+ .describe("area.delete: delete a non-empty area together with its contents"),
1623
2013
  ...dryRunShape,
2014
+ ...opIdShape,
1624
2015
  },
1625
2016
  annotations: DESTRUCTIVE,
1626
2017
  }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
1627
2018
  server.registerTool("batch", {
1628
2019
  description: "Run several operations in order, each independently — there are no transactions, " +
1629
2020
  "and a failure does not roll back earlier operations. Per-operation results return " +
1630
- "in order; fail_fast skips the remainder after the first failure.",
2021
+ "in order; fail_fast skips the remainder after the first failure. " +
2022
+ "CHAINING: an operation that creates something may carry temp_id (a handle); a LATER " +
2023
+ 'operation references that new uuid as "$handle" in any id/container field (dotted ' +
2024
+ '"$handle.instance"/"$handle.replaced" reach a repeating op\'s spawned instance / ' +
2025
+ "replaced source). A temp_id is valid only on a creating operation (not tag.add — " +
2026
+ "reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
2027
+ "safe — an operation matching an earlier success is reported already-applied, not " +
2028
+ "re-created. The result adds temp_id_mapping (handle → uuid) and undo_token, which " +
2029
+ "reverses the whole batch as one unit via the undo tool.",
1631
2030
  inputSchema: {
1632
2031
  ops: z
1633
2032
  .array(z.object({
1634
2033
  op: z.enum(OPERATION_KINDS),
1635
2034
  params: z.record(z.string(), z.unknown()),
2035
+ temp_id: z
2036
+ .string()
2037
+ .optional()
2038
+ .describe('Handle for this op\'s new uuid, referenced later as "$handle"'),
2039
+ op_id: z
2040
+ .string()
2041
+ .optional()
2042
+ .describe("Idempotency id — a resubmitted op matching an earlier success is skipped"),
1636
2043
  options: z
1637
2044
  .object({
1638
2045
  acknowledge_checklist_reset: z.boolean().optional(),
1639
2046
  acknowledge_project_reopen: z.boolean().optional(),
1640
2047
  dangerously_permanent: z.boolean().optional(),
1641
2048
  acknowledge_tag_subtree: z.boolean().optional(),
2049
+ allow_non_empty_area: z.boolean().optional(),
2050
+ dangerously_drive_gui: z.boolean().optional(),
1642
2051
  })
1643
2052
  .optional(),
1644
2053
  }))
@@ -1659,58 +2068,122 @@ export function createThingsMcpServer(options = {}) {
1659
2068
  ...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
1660
2069
  ...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
1661
2070
  ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
2071
+ ...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
2072
+ ...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
1662
2073
  ...(ceiling !== undefined && { maxDisruption: ceiling }),
1663
2074
  };
1664
2075
  return {
1665
2076
  op: op.op,
1666
2077
  params: op.params,
2078
+ ...(op.temp_id !== undefined && { tempId: op.temp_id }),
2079
+ ...(op.op_id !== undefined && { opId: op.op_id }),
1667
2080
  ...(Object.keys(opts).length > 0 && { options: opts }),
1668
2081
  };
1669
2082
  });
1670
- const results = await getClient().write.batch(ops, {
2083
+ const batchResult = await getClient().write.batch(ops, {
1671
2084
  ...(args.dry_run === true && { dryRun: true }),
1672
2085
  ...(args.fail_fast === true && { failFast: true }),
1673
2086
  actor: mcpActor(),
1674
2087
  });
1675
- return jsonResult(results);
2088
+ // First block: the per-op results, each FLATTENED to the wire shape (a
2089
+ // string `outcome` with the variant fields hoisted — parity with the CLI
2090
+ // JSONL stream, docs/contract.md). Second block (additive): the batch
2091
+ // chaining/undo summary, present only when the batch minted a token or
2092
+ // bound temp ids.
2093
+ const lines = batchResult.results.map(flattenBatchLine);
2094
+ const hasSummary = batchResult.undoToken !== undefined || Object.keys(batchResult.tempIdMapping).length > 0;
2095
+ if (!hasSummary)
2096
+ return jsonResult(lines);
2097
+ return {
2098
+ content: [
2099
+ { type: "text", text: JSON.stringify(lines) },
2100
+ {
2101
+ type: "text",
2102
+ text: JSON.stringify({
2103
+ tempIdMapping: batchResult.tempIdMapping,
2104
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
2105
+ }),
2106
+ },
2107
+ ],
2108
+ };
1676
2109
  }));
1677
2110
  server.registerTool("reorder", {
1678
- description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1679
- "area-less someday projects one kind per call), a " +
1680
- "project's to-dos, a project's headings (scope=headings children move with " +
1681
- "their heading), an area, or the top-level projects (scope=projects " +
1682
- "each project takes a brief someday/anytime round-trip) the given uuids move " +
1683
- "to the TOP in the given order; unlisted items keep their relative order below. " +
1684
- "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
1685
- "via `things config set allow-experimental true`. This Evening and " +
1686
- `scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
1687
- "to-dos and projects are ordered separately one kind per call.",
2111
+ description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
2112
+ "share this REARRANGES, never changes what an item belongs to (to change membership " +
2113
+ "use move_todo / move_project). The refs order is the resulting order; unmentioned " +
2114
+ "siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
2115
+ "EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
2116
+ "position the block instead. Refs that span different containers or buckets are refused. " +
2117
+ "A Today or This Evening member also holds a slot in its own container, so a set that is " +
2118
+ "coherent on BOTH axes is ambiguous — pass `in` to name the axis (the refusal names both " +
2119
+ "choices). The project rows the Today/Evening/day lists intermix with to-dos may be " +
2120
+ "reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
2121
+ "to-dos, or an area must first be enabled once via `things config set allow-experimental " +
2122
+ "true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
2123
+ "move_heading action; to reorder sidebar AREAS use reorder_areas.",
1688
2124
  inputSchema: {
1689
- scope: z.enum([
1690
- "today",
1691
- "evening",
1692
- "inbox",
1693
- "someday",
1694
- "project",
1695
- "headings",
1696
- "area",
1697
- "projects",
1698
- ]),
1699
- container: z
2125
+ refs: z
2126
+ .array(z.string())
2127
+ .describe("The items to rearrange, in the order they should land (may be a subset)"),
2128
+ ...positionShape,
2129
+ in: z
1700
2130
  .string()
1701
2131
  .optional()
1702
- .describe(`Project/area (${REF_FORMAT}) required for those scopes`),
1703
- uuids: z.array(z.string()).describe("Desired order, top first (may be a subset)"),
1704
- strategy: z.enum(["native", "bounce"]).optional(),
2132
+ .describe("Disambiguate the axis of a Today/This Evening set: today | evening | anytime | " +
2133
+ "someday | inbox, or a project/area/heading ref (uuid or unique title)"),
1705
2134
  ...dryRunShape,
1706
2135
  },
1707
2136
  annotations: NON_DESTRUCTIVE,
1708
- }, async (args) => guard(async () => mutationResult(await getClient().write.reorder({
1709
- scope: args.scope,
1710
- uuids: args.uuids,
1711
- ...(args.container !== undefined && { container: containerRef(args.container) }),
1712
- ...(args.strategy !== undefined && { strategy: args.strategy }),
1713
- }, writeOptions(args)))));
2137
+ }, async (args) => guard(async () => {
2138
+ const position = movePositionArgs(args);
2139
+ if (position === "conflict")
2140
+ return usage("pass at most one of first/last/before/after");
2141
+ const request = {
2142
+ uuids: args.refs,
2143
+ ...(position !== undefined && { position }),
2144
+ ...(args.in !== undefined && { in: args.in }),
2145
+ };
2146
+ return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
2147
+ }));
2148
+ server.registerTool("reorder_areas", {
2149
+ description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
2150
+ "name). Pass exactly one destination: before/after another area, or first/last. This " +
2151
+ "visibly drives the local Things app (the window comes forward and the sidebar may " +
2152
+ "scroll) and must be turned on first with `things config set ui-enabled true`; the " +
2153
+ "area's projects and to-dos are untouched.",
2154
+ inputSchema: {
2155
+ target: z.string().describe(`the area to move (${REF_FORMAT})`),
2156
+ before: z
2157
+ .string()
2158
+ .optional()
2159
+ .describe(`place it immediately above this area (${REF_FORMAT})`),
2160
+ after: z
2161
+ .string()
2162
+ .optional()
2163
+ .describe(`place it immediately below this area (${REF_FORMAT})`),
2164
+ first: z.boolean().optional().describe("move it to the top of the area list"),
2165
+ last: z.boolean().optional().describe("move it to the bottom of the area list"),
2166
+ ...driveGuiShape,
2167
+ ...dryRunShape,
2168
+ },
2169
+ annotations: NON_DESTRUCTIVE,
2170
+ }, async (args) => guard(async () => {
2171
+ const chosen = [
2172
+ args.before !== undefined,
2173
+ args.after !== undefined,
2174
+ args.first === true,
2175
+ args.last === true,
2176
+ ].filter(Boolean).length;
2177
+ if (chosen !== 1)
2178
+ return usage("pass exactly one of before / after / first / last");
2179
+ return mutationResult(await getClient().write.run("area.reorder", {
2180
+ target: args.target,
2181
+ ...(args.before !== undefined && { before: args.before }),
2182
+ ...(args.after !== undefined && { after: args.after }),
2183
+ ...(args.first === true && { position: "first" }),
2184
+ ...(args.last === true && { position: "last" }),
2185
+ }, writeOptions(args)));
2186
+ }));
1714
2187
  server.registerTool("undo", {
1715
2188
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +
1716
2189
  "cannot be undone here). By default this undoes only changes made through THIS " +