things-api 0.11.0 → 0.13.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 (208) hide show
  1. package/README.md +16 -5
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/canonical-ref.d.ts +21 -0
  5. package/dist/cli/canonical-ref.js +23 -0
  6. package/dist/cli/canonical-ref.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +81 -77
  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 +19 -4
  14. package/dist/cli/commands/install-skill.js +130 -52
  15. package/dist/cli/commands/install-skill.js.map +1 -1
  16. package/dist/cli/commands/mcp.js +21 -1
  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 +92 -48
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +5 -1
  22. package/dist/cli/commands/reads.js +110 -31
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +11 -4
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +75 -36
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +2 -2
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +795 -144
  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 +44 -7
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/dry-run.d.ts +55 -0
  36. package/dist/cli/dry-run.js +92 -0
  37. package/dist/cli/dry-run.js.map +1 -0
  38. package/dist/cli/glyphs.d.ts +16 -2
  39. package/dist/cli/glyphs.js +60 -12
  40. package/dist/cli/glyphs.js.map +1 -1
  41. package/dist/cli/help.js +69 -13
  42. package/dist/cli/help.js.map +1 -1
  43. package/dist/cli/main.js +8 -1
  44. package/dist/cli/main.js.map +1 -1
  45. package/dist/cli/move-hint.d.ts +3 -2
  46. package/dist/cli/move-hint.js +2 -2
  47. package/dist/cli/move-hint.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +53 -5
  49. package/dist/cli/read-driver.js +82 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/ref-render.d.ts +14 -0
  52. package/dist/cli/ref-render.js +9 -0
  53. package/dist/cli/ref-render.js.map +1 -0
  54. package/dist/cli/render.d.ts +40 -14
  55. package/dist/cli/render.js +128 -55
  56. package/dist/cli/render.js.map +1 -1
  57. package/dist/cli/resolve-invocation.d.ts +7 -5
  58. package/dist/cli/resolve-invocation.js +16 -7
  59. package/dist/cli/resolve-invocation.js.map +1 -1
  60. package/dist/cli/skill-check.d.ts +12 -6
  61. package/dist/cli/skill-check.js +31 -14
  62. package/dist/cli/skill-check.js.map +1 -1
  63. package/dist/cli/skill.d.ts +8 -0
  64. package/dist/cli/skill.js +10 -0
  65. package/dist/cli/skill.js.map +1 -1
  66. package/dist/cli/verb-hint.js +1 -1
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/client.d.ts +158 -26
  69. package/dist/client.js +254 -38
  70. package/dist/client.js.map +1 -1
  71. package/dist/config.d.ts +66 -2
  72. package/dist/config.js +120 -12
  73. package/dist/config.js.map +1 -1
  74. package/dist/contracts.d.ts +187 -28
  75. package/dist/contracts.js +22 -1
  76. package/dist/contracts.js.map +1 -1
  77. package/dist/db/baselines/db-v26.js +4 -1
  78. package/dist/db/baselines/db-v26.js.map +1 -1
  79. package/dist/diagnose.d.ts +34 -2
  80. package/dist/diagnose.js +36 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +21 -8
  83. package/dist/index.js +26 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -0
  86. package/dist/mcp/server.js +790 -322
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +57 -4
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +2 -0
  91. package/dist/model/mappers.js +44 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/recurrence.d.ts +8 -1
  94. package/dist/model/recurrence.js.map +1 -1
  95. package/dist/model/serialize.js +15 -3
  96. package/dist/model/serialize.js.map +1 -1
  97. package/dist/read/area-filter.d.ts +76 -0
  98. package/dist/read/area-filter.js +58 -0
  99. package/dist/read/area-filter.js.map +1 -0
  100. package/dist/read/area-view.d.ts +22 -11
  101. package/dist/read/area-view.js +54 -28
  102. package/dist/read/area-view.js.map +1 -1
  103. package/dist/read/detail.js +15 -8
  104. package/dist/read/detail.js.map +1 -1
  105. package/dist/read/predicates.d.ts +18 -0
  106. package/dist/read/predicates.js +19 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +91 -15
  109. package/dist/read/project-view.js +135 -37
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/pseudo-area.d.ts +29 -0
  112. package/dist/read/pseudo-area.js +27 -0
  113. package/dist/read/pseudo-area.js.map +1 -0
  114. package/dist/read/queries.d.ts +175 -19
  115. package/dist/read/queries.js +376 -32
  116. package/dist/read/queries.js.map +1 -1
  117. package/dist/read/scope.d.ts +126 -0
  118. package/dist/read/scope.js +161 -0
  119. package/dist/read/scope.js.map +1 -0
  120. package/dist/read/search-rank.d.ts +2 -5
  121. package/dist/read/shape.d.ts +181 -0
  122. package/dist/read/shape.js +737 -0
  123. package/dist/read/shape.js.map +1 -0
  124. package/dist/read/show-target.d.ts +9 -1
  125. package/dist/read/show-target.js +77 -17
  126. package/dist/read/show-target.js.map +1 -1
  127. package/dist/read/stage.d.ts +199 -0
  128. package/dist/read/stage.js +125 -0
  129. package/dist/read/stage.js.map +1 -0
  130. package/dist/read/truncation.d.ts +7 -12
  131. package/dist/read/truncation.js +56 -21
  132. package/dist/read/truncation.js.map +1 -1
  133. package/dist/read/views.d.ts +68 -16
  134. package/dist/read/views.js +206 -52
  135. package/dist/read/views.js.map +1 -1
  136. package/dist/surface-copy.d.ts +16 -0
  137. package/dist/surface-copy.js +16 -0
  138. package/dist/surface-copy.js.map +1 -1
  139. package/dist/write/batch.d.ts +49 -10
  140. package/dist/write/batch.js +423 -71
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/commands.js +268 -55
  143. package/dist/write/commands.js.map +1 -1
  144. package/dist/write/guards.d.ts +1 -1
  145. package/dist/write/guards.js +71 -10
  146. package/dist/write/guards.js.map +1 -1
  147. package/dist/write/heading.d.ts +10 -1
  148. package/dist/write/heading.js +35 -5
  149. package/dist/write/heading.js.map +1 -1
  150. package/dist/write/make-repeating-project.d.ts +2 -2
  151. package/dist/write/make-repeating-project.js +9 -9
  152. package/dist/write/make-repeating-project.js.map +1 -1
  153. package/dist/write/move.d.ts +134 -0
  154. package/dist/write/move.js +1874 -0
  155. package/dist/write/move.js.map +1 -0
  156. package/dist/write/operations.d.ts +105 -19
  157. package/dist/write/operations.js +56 -7
  158. package/dist/write/operations.js.map +1 -1
  159. package/dist/write/opid.d.ts +31 -0
  160. package/dist/write/opid.js +30 -0
  161. package/dist/write/opid.js.map +1 -0
  162. package/dist/write/pipeline.d.ts +70 -3
  163. package/dist/write/pipeline.js +198 -33
  164. package/dist/write/pipeline.js.map +1 -1
  165. package/dist/write/pre-state.d.ts +172 -8
  166. package/dist/write/pre-state.js +467 -25
  167. package/dist/write/pre-state.js.map +1 -1
  168. package/dist/write/reorder.d.ts +23 -3
  169. package/dist/write/reorder.js +2106 -86
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/reversibility.js +19 -7
  172. package/dist/write/reversibility.js.map +1 -1
  173. package/dist/write/scope-guard.d.ts +31 -0
  174. package/dist/write/scope-guard.js +162 -0
  175. package/dist/write/scope-guard.js.map +1 -0
  176. package/dist/write/undo.js +120 -13
  177. package/dist/write/undo.js.map +1 -1
  178. package/dist/write/vectors/applescript.js +16 -7
  179. package/dist/write/vectors/applescript.js.map +1 -1
  180. package/dist/write/vectors/shortcuts.js +1 -1
  181. package/dist/write/vectors/shortcuts.js.map +1 -1
  182. package/dist/write/vectors/simulator.js +83 -27
  183. package/dist/write/vectors/simulator.js.map +1 -1
  184. package/dist/write/vectors/types.d.ts +11 -0
  185. package/dist/write/vectors/ui-certification.d.ts +3 -3
  186. package/dist/write/vectors/ui-certification.js +41 -5
  187. package/dist/write/vectors/ui-certification.js.map +1 -1
  188. package/dist/write/vectors/ui-drag.d.ts +21 -1
  189. package/dist/write/vectors/ui-drag.js +138 -27
  190. package/dist/write/vectors/ui-drag.js.map +1 -1
  191. package/dist/write/vectors/ui-recipes.d.ts +21 -1
  192. package/dist/write/vectors/ui-recipes.js +166 -7
  193. package/dist/write/vectors/ui-recipes.js.map +1 -1
  194. package/dist/write/vectors/ui.d.ts +25 -1
  195. package/dist/write/vectors/ui.js +142 -33
  196. package/dist/write/vectors/ui.js.map +1 -1
  197. package/dist/write/verify/delta.d.ts +17 -1
  198. package/dist/write/verify/delta.js +106 -29
  199. package/dist/write/verify/delta.js.map +1 -1
  200. package/package.json +7 -1
  201. package/schema/envelope.schema.json +397 -0
  202. package/skills/things-cli/SKILL.md +36 -17
  203. package/skills/things-cli/references/banner.md +35 -0
  204. package/skills/things-cli/references/contracts.md +32 -9
  205. package/skills/things-cli/references/errors.md +57 -0
  206. package/skills/things-cli/references/gui.md +1 -1
  207. package/skills/things-cli/references/model.md +18 -7
  208. package/skills/things-cli/references/ordering.md +88 -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, isValidTimeZone, 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, REF_RULE_NOTE, 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
  }
@@ -29,32 +32,39 @@ function readResult(data) {
29
32
  /**
30
33
  * A read result carrying truncation metadata: the data (already limited) in
31
34
  * the first content block, and a second block with the {@link Truncation}
32
- * numbers plus a one-line note the agent can read when rows were dropped.
35
+ * numbers plus a one-line note the agent can read when rows were dropped. `meta`
36
+ * merges extra whole-view metadata into that second block — the today view's
37
+ * `counts` aggregate (the analog of the CLI envelope's `meta.counts`), so `data`
38
+ * stays pure domain rows.
33
39
  */
34
- function truncatedResult(data, truncation) {
40
+ function truncatedResult(data, truncation, meta) {
35
41
  const note = truncation.truncated
36
42
  ? `showing ${truncation.shown} of ${truncation.total} items — pass limit (or all: true) to see more`
37
43
  : undefined;
38
44
  return {
39
45
  content: [
40
46
  { type: "text", text: JSON.stringify(omitEmpty(data)) },
41
- { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
47
+ {
48
+ type: "text",
49
+ text: JSON.stringify({ truncation, ...meta, ...(note !== undefined && { note }) }),
50
+ },
42
51
  ],
43
52
  };
44
53
  }
45
54
  /**
46
55
  * 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.
56
+ * a second block carrying the unified {@link Truncation} counts (its `blocks`
57
+ * hold the per-block nesting) and, when anything was hidden, a one-line note the
58
+ * agent can read.
49
59
  */
50
- function groupedResult(data, grouped) {
51
- const note = grouped.truncated
60
+ function groupedResult(data, truncation) {
61
+ const note = truncation.truncated
52
62
  ? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
53
63
  : undefined;
54
64
  return {
55
65
  content: [
56
66
  { type: "text", text: JSON.stringify(omitEmpty(data)) },
57
- { type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
67
+ { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
58
68
  ],
59
69
  };
60
70
  }
@@ -112,12 +122,20 @@ function errorResult(error) {
112
122
  function usage(message) {
113
123
  return errorResult({ code: "usage", message });
114
124
  }
115
- /** Map a mutation outcome to an MCP result (errors carry remediation). */
125
+ /**
126
+ * Map a mutation outcome to an MCP result (errors carry remediation). Framing
127
+ * mirrors the CLI wire (docs/contract.md, phase-2 alignment): a SUCCESS drops
128
+ * the internal success discriminator via {@link mutationWireData} (call success
129
+ * is the tool result's own not-an-error signal — the payload carries no `kind`),
130
+ * and a dry-run returns the bare plan (the CLI's `mutation-plan` data). Failures
131
+ * stay tool errors carrying the machine-readable code.
132
+ */
116
133
  function mutationResult(result) {
117
134
  switch (result.kind) {
118
135
  case "ok":
136
+ return jsonResult(mutationWireData(result));
119
137
  case "dry-run":
120
- return jsonResult(result);
138
+ return jsonResult(result.plan);
121
139
  case "blocked":
122
140
  return errorResult({
123
141
  code: blockedCode(result),
@@ -146,6 +164,93 @@ function mutationResult(result) {
146
164
  });
147
165
  }
148
166
  }
167
+ /**
168
+ * Map a move/reorder orchestrator outcome to an MCP result (spec §4/§7). Framing
169
+ * mirrors {@link mutationResult}: a `move-ok` drops the internal discriminator
170
+ * (the placement-honesty `note` and every `membership`/`placement` field — with
171
+ * their own `touched`/`warnings` disclosures — pass through), and a dry-run
172
+ * returns the bare plan.
173
+ */
174
+ function moveResult(result) {
175
+ switch (result.kind) {
176
+ case "move-ok":
177
+ return jsonResult(mutationWireData(result));
178
+ case "move-dry-run":
179
+ return jsonResult(result.plan);
180
+ case "move-refused":
181
+ return errorResult({
182
+ // A hoisted placement block names its hazard — surface the canonical
183
+ // `blocked:<hazard>` code, matching a direct reorder hazard block.
184
+ code: result.hazard !== undefined
185
+ ? blockedCode({ hazard: result.hazard, reason: "hazard" })
186
+ : result.refusal,
187
+ message: result.detail,
188
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
189
+ ...(result.candidates !== undefined && { details: { candidates: result.candidates } }),
190
+ });
191
+ case "move-leg-failed":
192
+ return errorResult({
193
+ code: "verify-failed",
194
+ message: result.detail,
195
+ remediation: `completed ${result.completed.length} leg(s) before the failure`,
196
+ });
197
+ }
198
+ }
199
+ /**
200
+ * Flatten one batch line to the wire shape (docs/contract.md — the JSONL grammar
201
+ * shared with the CLI): the internal `outcome` union object collapses to one
202
+ * level. `outcome` becomes its tag as a plain string (`"ok"`, `"blocked"`,
203
+ * `"dry-run"`, `"already-applied"`, …), and every variant field (uuid, detail,
204
+ * plan, expected, observed, …) sits as a sibling of the line-level keys. The
205
+ * outcome's own `op` duplicates the line-level `op`, so it is dropped — no
206
+ * variant field name collides with a line-level key.
207
+ */
208
+ function flattenBatchLine(r) {
209
+ const { index, op, outcome, tempId, boundUuid, opId } = r;
210
+ const { kind, op: _outcomeOp, ...variant } = outcome;
211
+ return {
212
+ index,
213
+ op,
214
+ outcome: kind,
215
+ ...variant,
216
+ ...(tempId !== undefined && { tempId }),
217
+ ...(boundUuid !== undefined && { boundUuid }),
218
+ ...(opId !== undefined && { opId }),
219
+ };
220
+ }
221
+ /** Build a MovePosition from the shared MCP position args (null when none). */
222
+ function movePositionArgs(args) {
223
+ const chosen = [
224
+ args.first === true,
225
+ args.last === true,
226
+ args.before !== undefined,
227
+ args.after !== undefined,
228
+ ].filter(Boolean).length;
229
+ if (chosen > 1)
230
+ return "conflict";
231
+ if (args.first === true)
232
+ return { at: "first" };
233
+ if (args.last === true)
234
+ return { at: "last" };
235
+ if (args.before !== undefined)
236
+ return { before: args.before };
237
+ if (args.after !== undefined)
238
+ return { after: args.after };
239
+ return undefined;
240
+ }
241
+ /** The shared position input schema for the move tools. */
242
+ const positionShape = {
243
+ first: z.boolean().optional().describe("place the block at the top of its bucket"),
244
+ last: z.boolean().optional().describe("place the block at the bottom of its bucket"),
245
+ before: z
246
+ .string()
247
+ .optional()
248
+ .describe("place the block immediately before this item (same bucket)"),
249
+ after: z
250
+ .string()
251
+ .optional()
252
+ .describe("place the block immediately after this item (same bucket)"),
253
+ };
149
254
  const READ_ONLY = { readOnlyHint: true };
150
255
  const NON_DESTRUCTIVE = { destructiveHint: false };
151
256
  const DESTRUCTIVE = { destructiveHint: true };
@@ -201,10 +306,25 @@ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
201
306
  const dryRunShape = {
202
307
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
203
308
  };
309
+ /**
310
+ * The per-call idempotency key for a single write tool — the analogue of a batch
311
+ * line's op_id. A resubmission carrying the same key is recognized as already
312
+ * applied (a prior verified change with that key) and is not re-run. Spread into
313
+ * the single-mutation write tools (not the variadic move/reorder tools, whose
314
+ * idempotency is the batch-shaped per-line op_id).
315
+ */
316
+ const opIdShape = {
317
+ op_id: z
318
+ .string()
319
+ .optional()
320
+ .describe("Idempotency key: a resubmission with the same key is recognized as already applied " +
321
+ "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)"),
322
+ };
204
323
  /**
205
324
  * The per-call opt-in for the tools that reach a change only by driving the
206
325
  * local Things app's accessibility interface. Shared by `repeat`, the
207
- * `convert_to_project` heading action, and the `areas` reorder scope.
326
+ * `convert_to_project` tool, the heading tool's `promote_heading` action, and
327
+ * the `areas` reorder scope.
208
328
  */
209
329
  const driveGuiShape = {
210
330
  dangerously_drive_gui: z
@@ -226,6 +346,20 @@ const createTagsShape = {
226
346
  "instead of stopping on an unknown tag"),
227
347
  };
228
348
  const containerRef = (ref) => ({ uuid: ref, title: ref });
349
+ /**
350
+ * The `loose` reserved-word disclosure for the area-filtered read tools
351
+ * (read_view, search — the same advisory the CLI surfaces, #333/#346). `loose`
352
+ * ALWAYS addresses the null area; when a real area named "Loose" shadows the
353
+ * reserved word this names it (by uuid) so it stays targetable. Returns
354
+ * undefined when the ref is not the reserved word, or nothing shadows it.
355
+ * (get_area reads its disclosure from the area-view result's own `notice`.)
356
+ */
357
+ function looseAreaWarnings(c, areaRef) {
358
+ if (areaRef === undefined || !isLooseRef(areaRef))
359
+ return undefined;
360
+ const shadow = c.read.areas().find((a) => isLooseRef(a.title));
361
+ return shadow !== undefined ? [looseShadowNotice(shadow.uuid)] : undefined;
362
+ }
229
363
  /** Cap on project titles inlined into the server instructions. */
230
364
  const INSTRUCTIONS_MAX_PROJECTS = 100;
231
365
  /** A tag's display label: nested tags show `parent > child`. */
@@ -258,14 +392,23 @@ function buildInstructions(getClient) {
258
392
  "confirmation parameter named in their description; refused calls return an error saying " +
259
393
  "what to pass.",
260
394
  "- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
261
- "tags inherited from its containing project and area. todaySection appears only for an item " +
262
- "in Today, naming its section there (today or evening); an unscheduled start=active item is " +
263
- "in Anytime and omits the field. Completing an item makes it findable in Logbook.",
395
+ "tags inherited from its containing project and area. An item's when is today or evening for " +
396
+ "a Today member (evening implies today), a future date for a scheduled item, else absent; an " +
397
+ "unscheduled start=active item is in Anytime and carries no when. Completing an item makes it " +
398
+ "findable in Logbook.",
264
399
  "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
265
400
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
266
401
  ];
267
402
  try {
268
403
  const c = getClient();
404
+ // Under a container scope the inventory below is already limited to in-scope
405
+ // containers (the client filters areas/projects), so out-of-scope names are
406
+ // never embedded here. State the scope plainly so the agent knows its bounds.
407
+ if (c.scope !== undefined) {
408
+ lines.push("", `Scope: this server is limited to the ${c.scope.kind} "${c.scope.title}". Only items ` +
409
+ "within it are readable, and every change is confined to it; a reference naming anything " +
410
+ "outside it returns not-found, exactly as a nonexistent reference does.");
411
+ }
269
412
  const areas = c.read.areas();
270
413
  const tags = c.read.tags();
271
414
  const projects = c.read.projects();
@@ -306,6 +449,13 @@ function deriveMcpActor(clientName) {
306
449
  .replaceAll(/-+$/g, "");
307
450
  return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
308
451
  }
452
+ /** Validate an op_id (throwing RangeError → mapped to a `usage` tool error by `guard`). */
453
+ function assertOpId(opId) {
454
+ if (!OP_ID_RE.test(opId)) {
455
+ throw new RangeError("op_id must match [A-Za-z0-9_-] and be 1-64 characters");
456
+ }
457
+ return opId;
458
+ }
309
459
  export function createThingsMcpServer(options = {}) {
310
460
  // One lazily-opened client for the server's lifetime; SQLite read
311
461
  // snapshots are per-statement, so fresh reads see external commits.
@@ -313,6 +463,7 @@ export function createThingsMcpServer(options = {}) {
313
463
  const getClient = () => {
314
464
  client ??= openThings({
315
465
  ...(options.dbPath !== undefined && { dbPath: options.dbPath }),
466
+ ...(options.scope !== undefined && { scope: options.scope }),
316
467
  ...options.openOptions,
317
468
  });
318
469
  return client;
@@ -333,9 +484,11 @@ export function createThingsMcpServer(options = {}) {
333
484
  ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
334
485
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
335
486
  ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
487
+ ...(args.allow_non_empty_area === true && { allowNonEmptyArea: true }),
336
488
  ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
337
489
  ...(args.create_tags === true && { createTags: true }),
338
490
  ...(args.tz !== undefined && { zone: args.tz }),
491
+ ...(args.op_id !== undefined && { opId: assertOpId(args.op_id) }),
339
492
  });
340
493
  /** Run a handler, mapping environment/usage throws to tool errors. */
341
494
  const guard = async (fn) => {
@@ -364,8 +517,13 @@ export function createThingsMcpServer(options = {}) {
364
517
  * same note the CLI prints), so a consumer sees when the Things database no
365
518
  * longer matches the validated schema and its data may be incomplete. No
366
519
  * block is added when the schema checks out or the read itself errored.
520
+ *
521
+ * `extraWarnings` folds in the read's own advisories (the `loose` reserved-word
522
+ * shadow disclosure, an area-view's placement `notice`) so they reach the
523
+ * consumer in the SAME `meta.warnings` array the CLI surfaces — schema warnings
524
+ * first, then the read-specific notices.
367
525
  */
368
- const readGuard = async (fn, tz) => {
526
+ const readGuard = async (fn, tz, extraMeta, extraWarnings) => {
369
527
  const result = await guard(fn);
370
528
  if (result.isError === true)
371
529
  return result;
@@ -382,9 +540,23 @@ export function createThingsMcpServer(options = {}) {
382
540
  catch {
383
541
  warnings = [];
384
542
  }
543
+ // Read-specific advisories the handler resolved (loose-shadow / area notice)
544
+ // — merged AFTER the schema warnings, defensive against a DB read throwing.
545
+ let readNotices = [];
546
+ try {
547
+ readNotices = extraWarnings?.() ?? [];
548
+ }
549
+ catch {
550
+ readNotices = [];
551
+ }
552
+ const allWarnings = [...warnings, ...readNotices];
553
+ // Extra additive meta the handler resolved during the read (e.g. the active
554
+ // `area` filter) — evaluated AFTER fn ran, so a value it populated is seen.
555
+ const extra = extraMeta?.() ?? {};
385
556
  const meta = {
386
- ...(warnings.length > 0 && { warnings }),
557
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
387
558
  ...(clock !== undefined && { clock }),
559
+ ...extra,
388
560
  };
389
561
  if (Object.keys(meta).length === 0)
390
562
  return result;
@@ -405,8 +577,9 @@ export function createThingsMcpServer(options = {}) {
405
577
  };
406
578
  // ------------------------------------------------------------------ reads
407
579
  server.registerTool("read_view", {
408
- description: "Read a Things list as the app presents it: today (split into Today and This " +
409
- "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
580
+ description: "Read a Things list as the app presents it: today (one flat list of Today " +
581
+ "members, each item's when marking Today vs This Evening), inbox, anytime, " +
582
+ "upcoming, someday, logbook, or trash. For upcoming, " +
410
583
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
411
584
  "anytime/someday return sections in canonical order (area + items; null area = the " +
412
585
  "top-level block); children of someday/future-scheduled projects are excluded " +
@@ -417,12 +590,21 @@ export function createThingsMcpServer(options = {}) {
417
590
  `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
418
591
  `project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
419
592
  "all: true lifts every cap; the result's second block reports the counts. " +
420
- OMIT_EMPTY_NOTE,
593
+ OMIT_EMPTY_NOTE +
594
+ " " +
595
+ REF_RULE_NOTE,
421
596
  inputSchema: {
422
597
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
423
598
  ...tagFilterShape,
424
599
  ...tzShape,
425
- evening: z.boolean().optional().describe("today only: show only the This Evening section"),
600
+ area: z
601
+ .string()
602
+ .optional()
603
+ .describe(`today/anytime/someday/upcoming/logbook only: ${AREA_FILTER_DESC}`),
604
+ evening: z
605
+ .boolean()
606
+ .optional()
607
+ .describe("today only: show only This-Evening members (when = evening)"),
426
608
  show_active_project_items: z
427
609
  .union([z.boolean(), z.number().int().min(1)])
428
610
  .optional()
@@ -462,116 +644,148 @@ export function createThingsMcpServer(options = {}) {
462
644
  .optional()
463
645
  .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
464
646
  ALL_WINS_NOTE),
647
+ full: z.boolean().optional().describe(FULL_DESC),
465
648
  },
466
649
  annotations: READ_ONLY,
467
- }, async (args) => readGuard(() => {
468
- const badZone = badTz(args.tz);
469
- if (badZone !== null)
470
- return badZone;
471
- // Tag-conflict AND overdue-applicability both derive from the shared
472
- // contract: read_view honors overdue only on today/inbox/anytime/someday
473
- // (the current-work views), matching FILTER_CONTRACT.
474
- const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
475
- untaggedConflict: MCP_UNTAGGED_CONFLICT,
476
- overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
477
- overdueStatusWiden: "",
478
- });
479
- if (!validated.ok)
480
- return usage(validated.message);
481
- // show_active_project_items is the preferred name; active_project_items
482
- // stays accepted as a compatibility alias.
483
- const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
484
- const isGrouped = args.view === "anytime" || args.view === "someday";
485
- if (isGrouped && args.limit !== undefined) {
486
- return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
487
- `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
488
- }
489
- if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
490
- return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
491
- }
492
- if (args.view !== "someday" && showActiveProjectItems !== undefined) {
493
- return usage("show_active_project_items applies only to someday");
494
- }
495
- if (args.view !== "today" && args.evening === true) {
496
- return usage(`evening applies only to today, not ${args.view}`);
497
- }
498
- if (args.view === "someday" && args.project_limit !== undefined) {
499
- return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
500
- "to cap that section's project lists");
501
- }
502
- const limit = resolveLimit(args);
503
- const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
504
- const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
505
- if (areaLimit === "conflict" || projectLimit === "conflict") {
506
- return usage("pass at most one of area_limit/project_limit / all");
507
- }
508
- const c = getClient();
509
- const filter = validated.filter;
510
- const zone = args.tz !== undefined ? { zone: args.tz } : {};
511
- switch (args.view) {
512
- case "today": {
513
- const { view, truncation } = c.read.today({
514
- ...filter,
515
- ...zone,
516
- ...(args.evening === true && { eveningOnly: true }),
517
- limit,
518
- });
519
- return truncatedResult(view, truncation);
650
+ }, async (args) => {
651
+ // The active `area` scope, resolved during the read and surfaced as the
652
+ // additive `meta.filter` (the readGuard extra-meta thunk reads it after fn).
653
+ let filterMeta;
654
+ const full = args.full === true;
655
+ return readGuard(() => {
656
+ const badZone = badTz(args.tz);
657
+ if (badZone !== null)
658
+ return badZone;
659
+ // Tag-conflict AND overdue-applicability both derive from the shared
660
+ // contract: read_view honors overdue only on today/inbox/anytime/someday
661
+ // (the current-work views), matching FILTER_CONTRACT.
662
+ const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
663
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
664
+ overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
665
+ overdueStatusWiden: "",
666
+ });
667
+ if (!validated.ok)
668
+ return usage(validated.message);
669
+ // show_active_project_items is the preferred name; active_project_items
670
+ // stays accepted as a compatibility alias.
671
+ const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
672
+ const isGrouped = args.view === "anytime" || args.view === "someday";
673
+ if (isGrouped && args.limit !== undefined) {
674
+ return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
675
+ `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
520
676
  }
521
- case "inbox": {
522
- const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
523
- return truncatedResult(items, truncation);
677
+ if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
678
+ return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
524
679
  }
525
- case "anytime": {
526
- const { view, grouped } = c.read.anytime({
527
- ...filter,
528
- ...zone,
529
- areaLimit,
530
- projectLimit,
531
- });
532
- return groupedResult(view, grouped);
680
+ if (args.view !== "someday" && showActiveProjectItems !== undefined) {
681
+ return usage("show_active_project_items applies only to someday");
533
682
  }
534
- case "upcoming": {
535
- const { items, truncation } = c.read.upcoming({
536
- ...filter,
537
- ...zone,
538
- ...(args.horizon !== undefined && { horizon: args.horizon }),
539
- limit,
540
- });
541
- return truncatedResult(items, truncation);
683
+ if (args.view !== "today" && args.evening === true) {
684
+ return usage(`evening applies only to today, not ${args.view}`);
542
685
  }
543
- case "someday": {
544
- const active = showActiveProjectItems;
545
- if (typeof active === "number" && args.all === true) {
546
- return usage("pass at most one of a numeric show_active_project_items / all");
547
- }
548
- const { view, grouped } = c.read.someday({
549
- ...filter,
550
- ...zone,
551
- ...((active === true || typeof active === "number") && {
552
- activeProjectItems: true,
553
- }),
554
- areaLimit,
555
- // true = every item per project; a number caps each list.
556
- projectLimit: typeof active === "number" ? active : null,
557
- });
558
- return groupedResult(view, grouped);
686
+ // The `area` filter applies to the area-carrying views only; inbox
687
+ // (area-less captures) and trash have no area to scope by.
688
+ if ((args.view === "inbox" || args.view === "trash") && args.area !== undefined) {
689
+ return usage(`area applies to today/anytime/someday/upcoming/logbook, not ${args.view}`);
559
690
  }
560
- case "logbook": {
561
- const { items, truncation } = c.read.logbook({ ...filter, ...zone, limit });
562
- return truncatedResult(items, truncation);
691
+ if (args.view === "someday" && args.project_limit !== undefined) {
692
+ return usage("project_limit does not apply to someday pass a number as show_active_project_items " +
693
+ "to cap that section's project lists");
563
694
  }
564
- case "trash": {
565
- const { items, truncation } = c.read.trash({ ...zone, limit });
566
- return truncatedResult(items, truncation);
695
+ const limit = resolveLimit(args);
696
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
697
+ const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
698
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
699
+ return usage("pass at most one of area_limit/project_limit / all");
567
700
  }
568
- }
569
- }, args.tz));
701
+ const c = getClient();
702
+ const filter = validated.filter;
703
+ const zone = args.tz !== undefined ? { zone: args.tz } : {};
704
+ // The `area` scope, threaded into the area-carrying views; the client
705
+ // resolves it, applies the post-filter, and hands back the target for
706
+ // the `meta.filter` annotation (captured into `filterMeta`).
707
+ const area = args.area !== undefined ? { area: args.area } : {};
708
+ switch (args.view) {
709
+ case "today": {
710
+ const { view, truncation, filter: fm, } = c.read.today({
711
+ ...filter,
712
+ ...zone,
713
+ ...area,
714
+ ...(args.evening === true && { eveningOnly: true }),
715
+ limit,
716
+ });
717
+ filterMeta = fm;
718
+ return truncatedResult(shapeReadPayload("today", view, full, c.refPromoter()), truncation, { counts: view.counts });
719
+ }
720
+ case "inbox": {
721
+ const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
722
+ return truncatedResult(shapeReadPayload("inbox", items, full, c.refPromoter()), truncation);
723
+ }
724
+ case "anytime": {
725
+ const { view, truncation, filter: fm, } = c.read.anytime({
726
+ ...filter,
727
+ ...zone,
728
+ ...area,
729
+ areaLimit,
730
+ projectLimit,
731
+ });
732
+ filterMeta = fm;
733
+ return groupedResult(shapeReadPayload("anytime", view, full, c.refPromoter()), truncation);
734
+ }
735
+ case "upcoming": {
736
+ const { items, truncation, filter: fm, } = c.read.upcoming({
737
+ ...filter,
738
+ ...zone,
739
+ ...area,
740
+ ...(args.horizon !== undefined && { horizon: args.horizon }),
741
+ limit,
742
+ });
743
+ filterMeta = fm;
744
+ return truncatedResult(shapeReadPayload("upcoming", items, full, c.refPromoter()), truncation);
745
+ }
746
+ case "someday": {
747
+ const active = showActiveProjectItems;
748
+ if (typeof active === "number" && args.all === true) {
749
+ return usage("pass at most one of a numeric show_active_project_items / all");
750
+ }
751
+ const { view, truncation, filter: fm, } = c.read.someday({
752
+ ...filter,
753
+ ...zone,
754
+ ...area,
755
+ ...((active === true || typeof active === "number") && {
756
+ activeProjectItems: true,
757
+ }),
758
+ areaLimit,
759
+ // true = every item per project; a number caps each list.
760
+ projectLimit: typeof active === "number" ? active : null,
761
+ });
762
+ filterMeta = fm;
763
+ return groupedResult(shapeReadPayload("someday", view, full, c.refPromoter()), truncation);
764
+ }
765
+ case "logbook": {
766
+ const { items, truncation, filter: fm, } = c.read.logbook({
767
+ ...filter,
768
+ ...zone,
769
+ ...area,
770
+ limit,
771
+ });
772
+ filterMeta = fm;
773
+ return truncatedResult(shapeReadPayload("logbook", items, full, c.refPromoter()), truncation);
774
+ }
775
+ case "trash": {
776
+ const { items, truncation } = c.read.trash({ ...zone, limit });
777
+ return truncatedResult(shapeReadPayload("trash", items, full, c.refPromoter()), truncation);
778
+ }
779
+ }
780
+ }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
781
+ });
570
782
  server.registerTool("search", {
571
783
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
572
784
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
573
785
  "references must name existing items. " +
574
- OMIT_EMPTY_NOTE,
786
+ OMIT_EMPTY_NOTE +
787
+ " " +
788
+ REF_RULE_NOTE,
575
789
  inputSchema: {
576
790
  query: z.string(),
577
791
  ...tagFilterShape,
@@ -583,7 +797,7 @@ export function createThingsMcpServer(options = {}) {
583
797
  area: z
584
798
  .string()
585
799
  .optional()
586
- .describe(`Restrict to one area's direct members (${REF_FORMAT})`),
800
+ .describe(`Restrict to one area's subtree — its direct items plus its projects' children (${REF_FORMAT}, or \`loose\` for area-less)`),
587
801
  type: z.enum(["to-do", "project"]).optional(),
588
802
  logged: z.boolean().optional().describe("Also include completed/canceled items"),
589
803
  trashed: z.boolean().optional().describe("Also include trashed items"),
@@ -592,6 +806,7 @@ export function createThingsMcpServer(options = {}) {
592
806
  .optional()
593
807
  .describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
594
808
  limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
809
+ full: z.boolean().optional().describe(FULL_DESC),
595
810
  },
596
811
  annotations: READ_ONLY,
597
812
  }, async (args) => readGuard(() => {
@@ -625,17 +840,20 @@ export function createThingsMcpServer(options = {}) {
625
840
  ...(args.trashed === true && { trashed: true }),
626
841
  ...(args.all === true && { all: true }),
627
842
  });
628
- return truncatedResult(items, truncation);
629
- }, args.tz));
843
+ return truncatedResult(shapeReadPayload("search", items, args.full === true, getClient().refPromoter()), truncation);
844
+ }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
630
845
  server.registerTool("changes_since", {
631
846
  description: "List items created or modified since a moment — including trashed, logged, and " +
632
847
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
633
848
  "areas, and checklist items do not mark the containing item as modified. " +
634
- OMIT_EMPTY_NOTE,
849
+ OMIT_EMPTY_NOTE +
850
+ " " +
851
+ REF_RULE_NOTE,
635
852
  inputSchema: {
636
853
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
637
854
  ...limitShape,
638
855
  ...tzShape,
856
+ full: z.boolean().optional().describe(FULL_DESC),
639
857
  },
640
858
  annotations: READ_ONLY,
641
859
  }, async (args) => readGuard(() => {
@@ -652,26 +870,34 @@ export function createThingsMcpServer(options = {}) {
652
870
  limit,
653
871
  ...(args.tz !== undefined && { zone: args.tz }),
654
872
  });
655
- return truncatedResult(items, truncation);
873
+ return truncatedResult(shapeReadPayload("changes", items, args.full === true, getClient().refPromoter()), truncation);
656
874
  }, args.tz));
657
875
  server.registerTool("get_item", {
658
876
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
659
877
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
660
878
  "project/area/heading. " +
661
- OMIT_EMPTY_NOTE,
879
+ OMIT_EMPTY_NOTE +
880
+ " " +
881
+ REF_RULE_NOTE,
662
882
  inputSchema: { uuid: z.string() },
663
883
  annotations: READ_ONLY,
664
884
  }, async (args) => readGuard(() => {
665
885
  const item = getClient().read.byUuid(args.uuid);
666
886
  return item === null
667
887
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
668
- : readResult(item);
888
+ : readResult(shapeReadPayload("detail", item, false, getClient().refPromoter()));
669
889
  }));
670
890
  server.registerTool("get_project", {
671
- description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
672
- "The tag filters keep only the child to-dos matching by their own tags (a heading left " +
673
- "with none is dropped). " +
674
- OMIT_EMPTY_NOTE,
891
+ description: "One project's full contents: metadata, its live child to-dos as one flat list (items, " +
892
+ "in index order each carrying its stage, when, and, when it lives under a heading, " +
893
+ "its heading ref), the heading catalog (headings — every heading in index order, " +
894
+ "carrying archived once archived), and its logged children (logbook, most recent " +
895
+ "first, each carrying its heading ref). Reconstruct a heading's members by filtering " +
896
+ "items (or logbook) on the heading ref. The tag filters keep only the child to-dos " +
897
+ "matching by their own tags (a heading left with none is dropped). " +
898
+ OMIT_EMPTY_NOTE +
899
+ " " +
900
+ REF_RULE_NOTE,
675
901
  inputSchema: {
676
902
  uuid: z.string().describe("Project uuid or unique name"),
677
903
  ...tagOnlyShape,
@@ -680,6 +906,7 @@ export function createThingsMcpServer(options = {}) {
680
906
  .boolean()
681
907
  .optional()
682
908
  .describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
909
+ full: z.boolean().optional().describe(FULL_DESC),
683
910
  },
684
911
  annotations: READ_ONLY,
685
912
  }, async (args) => readGuard(() => {
@@ -688,19 +915,23 @@ export function createThingsMcpServer(options = {}) {
688
915
  return badZone;
689
916
  if (tagFlagConflict(tagPresence(args)))
690
917
  return usage(MCP_UNTAGGED_CONFLICT);
691
- return readResult(getClient().read.projectView(args.uuid, {
918
+ return readResult(shapeReadPayload("project-view", getClient().read.projectView(args.uuid, {
692
919
  overdue: args.overdue === true,
693
920
  ...tagFilterFields(tagPresence(args)),
694
921
  ...(args.tz !== undefined && { zone: args.tz }),
695
- }));
922
+ }), args.full === true, getClient().refPromoter()));
696
923
  }, args.tz));
697
924
  server.registerTool("get_area", {
698
- description: "One area's contents: metadata plus its direct to-dos (active first), its " +
699
- "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
700
- `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
925
+ description: "One area's contents: metadata plus its direct to-dos as one flat list (items, in " +
926
+ "index order each row carrying its stage and when) and its projects in canonical " +
927
+ "order (projects). The area logbook is not returned here read it with read_view " +
928
+ "logbook + area; trashed rows live in read_view trash. " +
929
+ `The project rows and the open/current direct to-dos are capped at ${AREA_PREVIEW_LIMIT} each ` +
701
930
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
702
931
  "second result block reports the counts. " +
703
- OMIT_EMPTY_NOTE,
932
+ OMIT_EMPTY_NOTE +
933
+ " " +
934
+ REF_RULE_NOTE,
704
935
  inputSchema: {
705
936
  ref: z.string().describe("Area uuid or unique name"),
706
937
  ...tagOnlyShape,
@@ -722,33 +953,43 @@ export function createThingsMcpServer(options = {}) {
722
953
  .optional()
723
954
  .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"),
724
955
  all: z.boolean().optional().describe("return both sections in full (no caps)"),
956
+ full: z.boolean().optional().describe(FULL_DESC),
725
957
  },
726
958
  annotations: READ_ONLY,
727
- }, async (args) => readGuard(() => {
728
- const badZone = badTz(args.tz);
729
- if (badZone !== null)
730
- return badZone;
731
- if (tagFlagConflict(tagPresence(args)))
732
- return usage(MCP_UNTAGGED_CONFLICT);
733
- const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
734
- const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
735
- if (areaLimit === "conflict" || projectLimit === "conflict") {
736
- return usage("pass at most one of area_limit/project_limit / all");
737
- }
738
- const { view, grouped } = getClient().read.areaView(args.ref, {
739
- overdue: args.overdue === true,
740
- ...tagFilterFields(tagPresence(args)),
741
- ...(args.tz !== undefined && { zone: args.tz }),
742
- areaLimit,
743
- projectLimit,
744
- });
745
- return groupedResult(view, grouped);
746
- }, args.tz));
959
+ }, async (args) => {
960
+ // The `loose` pseudo-area shadow disclosure the area-view read resolves —
961
+ // captured after fn runs and folded into meta.warnings (parity with the
962
+ // CLI's `area show loose` notice).
963
+ let areaNotice;
964
+ return readGuard(() => {
965
+ const badZone = badTz(args.tz);
966
+ if (badZone !== null)
967
+ return badZone;
968
+ if (tagFlagConflict(tagPresence(args)))
969
+ return usage(MCP_UNTAGGED_CONFLICT);
970
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
971
+ const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
972
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
973
+ return usage("pass at most one of area_limit/project_limit / all");
974
+ }
975
+ const { view, truncation, notice } = getClient().read.areaView(args.ref, {
976
+ overdue: args.overdue === true,
977
+ ...tagFilterFields(tagPresence(args)),
978
+ ...(args.tz !== undefined && { zone: args.tz }),
979
+ areaLimit,
980
+ projectLimit,
981
+ });
982
+ areaNotice = notice;
983
+ return groupedResult(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), truncation);
984
+ }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
985
+ });
747
986
  server.registerTool("list_collections", {
748
987
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
749
988
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
750
989
  "the projects list by each project's own tags (areas/tags reject them). " +
751
- OMIT_EMPTY_NOTE,
990
+ OMIT_EMPTY_NOTE +
991
+ " " +
992
+ REF_RULE_NOTE,
752
993
  inputSchema: {
753
994
  kind: z.enum(["projects", "areas", "tags"]),
754
995
  ...tagOnlyShape,
@@ -757,6 +998,7 @@ export function createThingsMcpServer(options = {}) {
757
998
  .boolean()
758
999
  .optional()
759
1000
  .describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
1001
+ full: z.boolean().optional().describe(`projects only: ${FULL_DESC}`),
760
1002
  },
761
1003
  annotations: READ_ONLY,
762
1004
  }, async (args) => readGuard(() => {
@@ -784,7 +1026,7 @@ export function createThingsMcpServer(options = {}) {
784
1026
  }
785
1027
  if (tagFlagConflict(tagPresence(args)))
786
1028
  return usage(MCP_UNTAGGED_CONFLICT);
787
- return readResult(args.kind === "projects"
1029
+ return readResult(shapeReadPayload(args.kind, args.kind === "projects"
788
1030
  ? c.read.projects({
789
1031
  overdue: args.overdue === true,
790
1032
  ...tagFilterFields(tagPresence(args)),
@@ -792,7 +1034,7 @@ export function createThingsMcpServer(options = {}) {
792
1034
  })
793
1035
  : args.kind === "areas"
794
1036
  ? c.read.areas()
795
- : c.read.tags());
1037
+ : c.read.tags(), args.full === true, c.refPromoter()));
796
1038
  }, args.tz));
797
1039
  // ---------------------------------------------------------------- to-dos
798
1040
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
@@ -820,6 +1062,7 @@ export function createThingsMcpServer(options = {}) {
820
1062
  ...createTagsShape,
821
1063
  ...tzShape,
822
1064
  ...dryRunShape,
1065
+ ...opIdShape,
823
1066
  },
824
1067
  annotations: NON_DESTRUCTIVE,
825
1068
  }, async (args) => guard(async () => {
@@ -881,6 +1124,7 @@ export function createThingsMcpServer(options = {}) {
881
1124
  ...createTagsShape,
882
1125
  ...tzShape,
883
1126
  ...dryRunShape,
1127
+ ...opIdShape,
884
1128
  },
885
1129
  annotations: NON_DESTRUCTIVE,
886
1130
  }, async (args) => guard(async () => {
@@ -983,6 +1227,7 @@ export function createThingsMcpServer(options = {}) {
983
1227
  .optional()
984
1228
  .describe("scope project, open only: also reopen the to-dos resolved with the project"),
985
1229
  ...dryRunShape,
1230
+ ...opIdShape,
986
1231
  },
987
1232
  annotations: NON_DESTRUCTIVE,
988
1233
  }, async (args) => guard(async () => {
@@ -1017,6 +1262,13 @@ export function createThingsMcpServer(options = {}) {
1017
1262
  if (args.children !== undefined) {
1018
1263
  return usage("children applies only to status 'completed' or 'canceled'");
1019
1264
  }
1265
+ // Reopening a project is a multi-leg compound — single-op idempotency
1266
+ // (op_id) does not apply to it in phase 1 (every other set_status path
1267
+ // is a single mutation). Refuse rather than silently drop the key.
1268
+ if (args.op_id !== undefined) {
1269
+ return usage("op_id is not available when reopening a project (a multi-leg compound in phase 1) — " +
1270
+ "use the batch tool with a per-line op_id");
1271
+ }
1020
1272
  const outcome = await c.write.reopenProject(args.uuid, {
1021
1273
  ...opts,
1022
1274
  ...(args.restore_children === true && { restoreChildren: true }),
@@ -1026,21 +1278,37 @@ export function createThingsMcpServer(options = {}) {
1026
1278
  : mutationResult(outcome.project);
1027
1279
  }));
1028
1280
  server.registerTool("move_todo", {
1029
- description: "Move a to-do. Pass exactly one destination: a project or area (optionally an " +
1030
- "existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
1031
- "removes any schedule; detach removes the project/area/heading assignment while " +
1032
- "keeping the schedule. Moving into a completed or canceled project reopens that " +
1033
- "project pass acknowledge_project_reopen to confirm.",
1281
+ description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
1282
+ "belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
1283
+ "without changing membership, call this with a position (first/last/before/after) and " +
1284
+ "NO destination that is an in-place reorder, anchored at the earliest movee's slot, " +
1285
+ "and unmentioned siblings keep their order. The uuids order is the order they land " +
1286
+ "(name them backwards to reverse). Pass at most one destination: to_project, to_heading " +
1287
+ "(within to_project, or the movees' shared project), to_area, no_heading (leave the " +
1288
+ "heading, stay in the project), loose (leave heading, project, AND area), or to_inbox. " +
1289
+ "An anchor (before/after) positions but never migrates — an anchor-only move that would " +
1290
+ "cross containers is refused, as is a --before/--after whose movees span buckets. " +
1291
+ "Membership always succeeds; top-of-bucket placement is guaranteed only where a reorder " +
1292
+ "protocol exists (the result's placementClass states which). Moving into a " +
1293
+ "completed/canceled project reopens it — pass acknowledge_project_reopen.",
1034
1294
  inputSchema: {
1035
- uuid: z.string(),
1036
- project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
1037
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1038
- heading: z.string().optional().describe("Existing heading in the destination project"),
1039
- to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
1040
- detach: z
1295
+ uuids: z.array(z.string()).describe("The to-dos to move, in the order they should land"),
1296
+ to_project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
1297
+ to_heading: z
1298
+ .string()
1299
+ .optional()
1300
+ .describe("Destination heading (exact title or uuid; within to_project or the shared project)"),
1301
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1302
+ no_heading: z
1303
+ .boolean()
1304
+ .optional()
1305
+ .describe("Leave the heading but stay in the current project (unheaded block)"),
1306
+ loose: z
1041
1307
  .boolean()
1042
1308
  .optional()
1043
- .describe("Remove the project/area/heading assignment, keeping the schedule"),
1309
+ .describe("Detach from heading, project, AND area, keeping the schedule"),
1310
+ to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
1311
+ ...positionShape,
1044
1312
  acknowledge_project_reopen: z
1045
1313
  .boolean()
1046
1314
  .optional()
@@ -1049,21 +1317,37 @@ export function createThingsMcpServer(options = {}) {
1049
1317
  },
1050
1318
  annotations: NON_DESTRUCTIVE,
1051
1319
  }, async (args) => guard(async () => {
1052
- const dest = args.project !== undefined || args.area !== undefined || args.heading !== undefined;
1053
- const modes = [dest, args.to_inbox === true, args.detach === true].filter(Boolean).length;
1054
- if (modes !== 1) {
1055
- return usage("pass exactly one destination: project/area/heading, to_inbox, or detach");
1320
+ const dests = [];
1321
+ if (args.to_heading !== undefined) {
1322
+ dests.push({
1323
+ kind: "heading",
1324
+ sel: args.to_heading,
1325
+ ...(args.to_project !== undefined && { project: containerRef(args.to_project) }),
1326
+ });
1056
1327
  }
1057
- return mutationResult(await getClient().write.moveTodo(args.uuid, {
1058
- ...(args.project !== undefined && { project: containerRef(args.project) }),
1059
- ...(args.area !== undefined && { area: containerRef(args.area) }),
1060
- ...(args.heading !== undefined && { heading: args.heading }),
1061
- ...(args.to_inbox === true && { inbox: true }),
1062
- ...(args.detach === true && { detach: true }),
1063
- }, {
1064
- ...writeOptions(args),
1065
- ...(args.to_inbox === true && { vector: "applescript" }),
1066
- }));
1328
+ else if (args.to_project !== undefined) {
1329
+ dests.push({ kind: "project", ref: containerRef(args.to_project) });
1330
+ }
1331
+ if (args.to_area !== undefined)
1332
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1333
+ if (args.no_heading === true)
1334
+ dests.push({ kind: "no-heading" });
1335
+ if (args.loose === true)
1336
+ dests.push({ kind: "loose" });
1337
+ if (args.to_inbox === true)
1338
+ dests.push({ kind: "inbox" });
1339
+ if (dests.length > 1) {
1340
+ return usage("pass at most one destination (to_project/to_heading/to_area/no_heading/loose/to_inbox)");
1341
+ }
1342
+ const position = movePositionArgs(args);
1343
+ if (position === "conflict")
1344
+ return usage("pass at most one of first/last/before/after");
1345
+ const request = {
1346
+ uuids: args.uuids,
1347
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1348
+ ...(position !== undefined && { position }),
1349
+ };
1350
+ return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
1067
1351
  }));
1068
1352
  server.registerTool("set_tags", {
1069
1353
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
@@ -1075,6 +1359,7 @@ export function createThingsMcpServer(options = {}) {
1075
1359
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
1076
1360
  ...createTagsShape,
1077
1361
  ...dryRunShape,
1362
+ ...opIdShape,
1078
1363
  },
1079
1364
  annotations: NON_DESTRUCTIVE,
1080
1365
  }, async (args) => guard(async () => {
@@ -1184,8 +1469,9 @@ export function createThingsMcpServer(options = {}) {
1184
1469
  "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1185
1470
  "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1186
1471
  "repeating to-dos). kind area: PERMANENT — areas do not go to the Trash, so this cannot " +
1187
- "be undone and requires dangerously_permanent; the area's to-dos move to the Trash and " +
1188
- "its projects remain, no longer assigned to any area. kind tag: PERMANENT requires " +
1472
+ "be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
1473
+ "projects to the Trash, so a non-empty area is refused unless you pass " +
1474
+ "allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
1189
1475
  "dangerously_permanent; the tag is removed from every item, and if it has nested child " +
1190
1476
  "tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
1191
1477
  inputSchema: {
@@ -1197,11 +1483,16 @@ export function createThingsMcpServer(options = {}) {
1197
1483
  .boolean()
1198
1484
  .optional()
1199
1485
  .describe("kind area/tag: confirm permanent, unrecoverable deletion"),
1486
+ allow_non_empty_area: z
1487
+ .boolean()
1488
+ .optional()
1489
+ .describe("kind area: delete a non-empty area together with its contents (its to-dos and projects move to the Trash)"),
1200
1490
  acknowledge_tag_subtree: z
1201
1491
  .boolean()
1202
1492
  .optional()
1203
1493
  .describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
1204
1494
  ...dryRunShape,
1495
+ ...opIdShape,
1205
1496
  },
1206
1497
  annotations: DESTRUCTIVE,
1207
1498
  }, async (args) => guard(async () => {
@@ -1222,7 +1513,7 @@ export function createThingsMcpServer(options = {}) {
1222
1513
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
1223
1514
  "previous schedule or project/area. A project is restored in place: its schedule, " +
1224
1515
  "area, and children come back exactly as they were.",
1225
- inputSchema: { uuid: z.string(), ...dryRunShape },
1516
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
1226
1517
  annotations: NON_DESTRUCTIVE,
1227
1518
  }, async (args) => guard(async () => {
1228
1519
  const c = getClient();
@@ -1239,6 +1530,7 @@ export function createThingsMcpServer(options = {}) {
1239
1530
  completion_date: z.string().optional().describe(DATE_FORMAT),
1240
1531
  creation_date: z.string().optional().describe(DATE_FORMAT),
1241
1532
  ...dryRunShape,
1533
+ ...opIdShape,
1242
1534
  },
1243
1535
  annotations: NON_DESTRUCTIVE,
1244
1536
  }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
@@ -1255,6 +1547,7 @@ export function createThingsMcpServer(options = {}) {
1255
1547
  creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
1256
1548
  notes: z.string().optional(),
1257
1549
  ...dryRunShape,
1550
+ ...opIdShape,
1258
1551
  },
1259
1552
  annotations: NON_DESTRUCTIVE,
1260
1553
  }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
@@ -1264,33 +1557,76 @@ export function createThingsMcpServer(options = {}) {
1264
1557
  ...(args.notes !== undefined && { notes: args.notes }),
1265
1558
  }, writeOptions(args)))));
1266
1559
  server.registerTool("heading", {
1267
- description: "Manage a project heading — action selects which. create: a new heading in an existing " +
1268
- "project (project + title; returns its uuid; uses the Things proxy shortcuts, set up " +
1269
- "once with `things setup shortcuts`). rename: rename in place (uuid + title; works on " +
1270
- "archived headings). archive: retire a heading so it leaves the active project view " +
1271
- "(reversible with action unarchive); with open children pass children complete or " +
1272
- "cancel resolve them with the heading in one cascade, reparent moves them to the project " +
1273
- "root keeping them open. unarchive: bring an archived heading back; restore_children also " +
1274
- "reopens the children the archive resolved with it. convert_to_project: promote a to-do " +
1275
- "or heading into a new project this REPLACES the original and cannot be undone (a " +
1276
- "converted heading's to-dos move under the new project), and requires " +
1277
- "dangerously_drive_gui.",
1560
+ description: "Manage a project's headings — action selects which; project is always required, and a " +
1561
+ "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
1562
+ "new heading in the project (project + title; returns its uuid; uses the Things proxy " +
1563
+ "shortcuts, set up once with `things setup shortcuts`); a placement flag positions it, " +
1564
+ "else it appends. rename_heading: rename in place (project + heading + title; works on " +
1565
+ "archived headings). archive_heading: retire a heading so it leaves the active project " +
1566
+ "view (reversible with unarchive_heading); with open children pass children complete " +
1567
+ "or cancel resolve them with the heading in one cascade, reparent moves them to the " +
1568
+ "project root keeping them open. unarchive_heading: bring an archived heading back; " +
1569
+ "restore_children also reopens the children the archive resolved with it. " +
1570
+ "promote_heading: promote a heading into a new project — this REPLACES the heading and " +
1571
+ "cannot be undone (its to-dos move under the new project), and requires " +
1572
+ "dangerously_drive_gui. move_heading: reposition headings as an ordered block (children " +
1573
+ "follow); pass exactly one placement flag. Reordering headings needs allow-experimental. " +
1574
+ "move_heading_to_project: relocate ONE heading (with its to-dos) to a DIFFERENT project " +
1575
+ "(project + heading + to_project) — the cross-project move, distinct from move_heading's " +
1576
+ "within-project reorder; GUI-only (requires dangerously_drive_gui), fails closed on a " +
1577
+ "source-heading or destination-project title collision, and has no undo (move it back). " +
1578
+ "dissolve_heading: remove a heading but KEEP its to-dos as direct project children (NOT " +
1579
+ "trashed — the opposite of a delete cascade); GUI-only (requires dangerously_drive_gui), " +
1580
+ "fails closed on a title collision, no undo.",
1278
1581
  inputSchema: {
1279
- action: z.enum(["create", "rename", "archive", "unarchive", "convert_to_project"]),
1280
- project: z.string().optional().describe(`create: existing project (${REF_FORMAT})`),
1281
- uuid: z
1582
+ action: z.enum([
1583
+ "add_heading",
1584
+ "rename_heading",
1585
+ "archive_heading",
1586
+ "unarchive_heading",
1587
+ "promote_heading",
1588
+ "move_heading",
1589
+ "move_heading_to_project",
1590
+ "dissolve_heading",
1591
+ ]),
1592
+ project: z.string().describe(`the heading's project (${REF_FORMAT})`),
1593
+ heading: z
1594
+ .string()
1595
+ .optional()
1596
+ .describe("rename/archive/unarchive/promote: the heading selector (exact title or uuid)"),
1597
+ headings: z
1598
+ .array(z.string())
1599
+ .optional()
1600
+ .describe("move_heading: heading selectors in the order they should land"),
1601
+ to_project: z
1282
1602
  .string()
1283
1603
  .optional()
1284
- .describe("rename/archive/unarchive/convert_to_project: the target's uuid"),
1285
- title: z.string().optional().describe("create: the new heading; rename: the new title"),
1604
+ .describe(`move_heading_to_project: the destination project (${REF_FORMAT})`),
1605
+ title: z
1606
+ .string()
1607
+ .optional()
1608
+ .describe("add_heading: the new heading; rename_heading: the new title"),
1286
1609
  children: z
1287
1610
  .enum(["complete", "cancel", "reparent"])
1288
1611
  .optional()
1289
- .describe("archive: required when the heading has open children"),
1612
+ .describe("archive_heading: required when the heading has open children"),
1290
1613
  restore_children: z
1291
1614
  .boolean()
1292
1615
  .optional()
1293
- .describe("unarchive: also reopen the children archived with the heading"),
1616
+ .describe("unarchive_heading: also reopen the children archived with the heading"),
1617
+ first: z
1618
+ .boolean()
1619
+ .optional()
1620
+ .describe("add/move: place first among the project's headings"),
1621
+ last: z.boolean().optional().describe("add/move: place last among the project's headings"),
1622
+ before_heading: z
1623
+ .string()
1624
+ .optional()
1625
+ .describe("add/move: place immediately before this heading (title or uuid)"),
1626
+ after_heading: z
1627
+ .string()
1628
+ .optional()
1629
+ .describe("add/move: place immediately after this heading (title or uuid)"),
1294
1630
  ...driveGuiShape,
1295
1631
  ...dryRunShape,
1296
1632
  },
@@ -1298,45 +1634,105 @@ export function createThingsMcpServer(options = {}) {
1298
1634
  }, async (args) => guard(async () => {
1299
1635
  const c = getClient();
1300
1636
  const opts = writeOptions(args);
1637
+ const proj = c.resolve.project(args.project);
1638
+ const placementCount = [
1639
+ args.first === true,
1640
+ args.last === true,
1641
+ args.before_heading !== undefined,
1642
+ args.after_heading !== undefined,
1643
+ ].filter(Boolean).length;
1644
+ const placement = () => {
1645
+ if (args.first === true)
1646
+ return { position: "first" };
1647
+ if (args.last === true)
1648
+ return { position: "last" };
1649
+ if (args.before_heading !== undefined) {
1650
+ return { before: c.resolve.heading(proj.uuid, args.before_heading).uuid };
1651
+ }
1652
+ if (args.after_heading !== undefined) {
1653
+ return { after: c.resolve.heading(proj.uuid, args.after_heading).uuid };
1654
+ }
1655
+ return undefined;
1656
+ };
1301
1657
  switch (args.action) {
1302
- case "create":
1303
- if (args.project === undefined || args.title === undefined) {
1304
- return usage('action "create" requires project and title');
1658
+ case "add_heading": {
1659
+ if (args.title === undefined)
1660
+ return usage('action "add_heading" requires title');
1661
+ if (placementCount > 1) {
1662
+ return usage("pass at most one of first/last/before_heading/after_heading");
1305
1663
  }
1306
- return mutationResult(await c.write.createHeading(containerRef(args.project), args.title, opts));
1307
- case "rename":
1308
- if (args.uuid === undefined || args.title === undefined) {
1309
- return usage('action "rename" requires uuid and title');
1664
+ return mutationResult(await c.write.addHeading({ uuid: proj.uuid }, args.title, placement(), opts));
1665
+ }
1666
+ case "rename_heading": {
1667
+ if (args.heading === undefined || args.title === undefined) {
1668
+ return usage('action "rename_heading" requires heading and title');
1310
1669
  }
1311
- return mutationResult(await c.write.renameHeading(args.uuid, args.title, opts));
1312
- case "archive": {
1313
- if (args.uuid === undefined)
1314
- return usage('action "archive" requires uuid');
1315
- const r = await c.write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
1670
+ const h = c.resolve.heading(proj.uuid, args.heading);
1671
+ return mutationResult(await c.write.renameHeading(h.uuid, args.title, opts));
1672
+ }
1673
+ case "archive_heading": {
1674
+ if (args.heading === undefined)
1675
+ return usage('action "archive_heading" requires heading');
1676
+ const h = c.resolve.heading(proj.uuid, args.heading);
1677
+ const r = await c.write.archiveHeading(h.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
1316
1678
  return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1317
1679
  ? jsonResult(r)
1318
1680
  : mutationResult(r.heading);
1319
1681
  }
1320
- case "unarchive": {
1321
- if (args.uuid === undefined)
1322
- return usage('action "unarchive" requires uuid');
1323
- const r = await c.write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
1682
+ case "unarchive_heading": {
1683
+ if (args.heading === undefined) {
1684
+ return usage('action "unarchive_heading" requires heading');
1685
+ }
1686
+ const h = c.resolve.heading(proj.uuid, args.heading);
1687
+ const r = await c.write.unarchiveHeading(h.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
1324
1688
  return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1325
1689
  ? jsonResult(r)
1326
1690
  : mutationResult(r.heading);
1327
1691
  }
1328
- case "convert_to_project": {
1329
- if (args.uuid === undefined) {
1330
- return usage('action "convert_to_project" requires uuid');
1692
+ case "promote_heading": {
1693
+ if (args.heading === undefined)
1694
+ return usage('action "promote_heading" requires heading');
1695
+ const h = c.resolve.heading(proj.uuid, args.heading);
1696
+ return mutationResult(await c.write.run("project.promote-heading", { uuid: h.uuid }, opts));
1697
+ }
1698
+ case "move_heading": {
1699
+ if (args.headings === undefined || args.headings.length === 0) {
1700
+ return usage('action "move_heading" requires headings');
1701
+ }
1702
+ if (placementCount !== 1) {
1703
+ return usage("move_heading requires exactly one of first/last/before_heading/after_heading");
1704
+ }
1705
+ const headings = args.headings.map((s) => c.resolve.heading(proj.uuid, s).uuid);
1706
+ return mutationResult(await c.write.moveHeading({ uuid: proj.uuid }, headings, placement(), opts));
1707
+ }
1708
+ case "move_heading_to_project": {
1709
+ if (args.heading === undefined || args.to_project === undefined) {
1710
+ return usage('action "move_heading_to_project" requires heading and to_project');
1331
1711
  }
1332
- const item = c.read.byUuid(args.uuid);
1333
- if (item === null)
1334
- throw new RangeError(`no item with uuid ${args.uuid}`);
1335
- const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
1336
- return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
1712
+ const dest = c.resolve.project(args.to_project);
1713
+ return mutationResult(await c.write.moveHeadingToProject({ uuid: proj.uuid }, args.heading, { uuid: dest.uuid }, opts));
1714
+ }
1715
+ case "dissolve_heading": {
1716
+ if (args.heading === undefined)
1717
+ return usage('action "dissolve_heading" requires heading');
1718
+ const h = c.resolve.heading(proj.uuid, args.heading);
1719
+ return mutationResult(await c.write.dissolveHeading(h.uuid, opts));
1337
1720
  }
1338
1721
  }
1339
1722
  }));
1723
+ server.registerTool("convert_to_project", {
1724
+ description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
1725
+ "are kept); the to-do's identity is gone and it cannot be undone. Requires " +
1726
+ "dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
1727
+ "use the heading tool's promote_heading action.)",
1728
+ inputSchema: {
1729
+ uuid: z.string().describe("the to-do's uuid"),
1730
+ ...driveGuiShape,
1731
+ ...dryRunShape,
1732
+ ...opIdShape,
1733
+ },
1734
+ annotations: DESTRUCTIVE,
1735
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
1340
1736
  server.registerTool("clear_reminder", {
1341
1737
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1342
1738
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
@@ -1355,7 +1751,7 @@ export function createThingsMcpServer(options = {}) {
1355
1751
  "friday",
1356
1752
  "saturday",
1357
1753
  ]);
1358
- // The base rule (also used by create_repeating_project, which stays minimal —
1754
+ // The base rule (also used by the repeat tool's create action, which stays minimal —
1359
1755
  // its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
1360
1756
  const baseRepeatShape = {
1361
1757
  frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
@@ -1432,39 +1828,36 @@ export function createThingsMcpServer(options = {}) {
1432
1828
  "visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
1433
1829
  "change a repeating item's rule in place, keeping the same item (undoable — it restores " +
1434
1830
  "the previous rule). action pause/resume: stop or restart its new occurrences, keeping " +
1435
- "the rule. action create (scope project only): create a project and make it repeating in " +
1831
+ "the rule. action add (scope project only): create a project and make it repeating in " +
1436
1832
  "one call — the project is created first and PERSISTS even if the make-repeating step " +
1437
1833
  "refuses; give an area to place it or omit it to create in Someday (only frequency and " +
1438
1834
  "interval are supported); returns the new project's uuid.",
1439
1835
  inputSchema: {
1440
1836
  scope: z.enum(["todo", "project"]),
1441
- action: z.enum(["start", "reschedule", "pause", "resume", "create"]),
1837
+ action: z.enum(["start", "reschedule", "pause", "resume", "add"]),
1442
1838
  uuid: z
1443
1839
  .string()
1444
1840
  .optional()
1445
1841
  .describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
1446
- title: z.string().optional().describe("create (project): the new project's title"),
1447
- notes: z.string().optional().describe("create (project): notes"),
1448
- area: z.string().optional().describe(`create (project): destination area (${REF_FORMAT})`),
1842
+ title: z.string().optional().describe("add (project): the new project's title"),
1843
+ notes: z.string().optional().describe("add (project): notes"),
1844
+ area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
1449
1845
  project_deadline: z
1450
1846
  .string()
1451
1847
  .optional()
1452
- .describe(`create (project): the project's due date — ${DATE_FORMAT}`),
1453
- todos: z
1454
- .array(z.string())
1455
- .optional()
1456
- .describe("create (project): initial child to-do titles"),
1848
+ .describe(`add (project): the project's due date — ${DATE_FORMAT}`),
1849
+ todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
1457
1850
  frequency: z
1458
1851
  .enum(["daily", "weekly", "monthly", "yearly"])
1459
1852
  .optional()
1460
- .describe("start/reschedule/create: how often it repeats"),
1853
+ .describe("start/reschedule/add: how often it repeats"),
1461
1854
  interval: z
1462
1855
  .number()
1463
1856
  .int()
1464
1857
  .min(1)
1465
1858
  .max(99)
1466
1859
  .optional()
1467
- .describe("start/reschedule/create: every N units (1–99)"),
1860
+ .describe("start/reschedule/add: every N units (1–99)"),
1468
1861
  after_completion: repeatRuleShape.after_completion,
1469
1862
  weekdays: repeatRuleShape.weekdays,
1470
1863
  monthly_day: repeatRuleShape.monthly_day,
@@ -1484,15 +1877,15 @@ export function createThingsMcpServer(options = {}) {
1484
1877
  const c = getClient();
1485
1878
  const opts = writeOptions(args);
1486
1879
  const { frequency, interval } = args;
1487
- if (args.action === "create") {
1880
+ if (args.action === "add") {
1488
1881
  if (args.scope !== "project")
1489
- return usage('action "create" requires scope "project"');
1882
+ return usage('action "add" requires scope "project"');
1490
1883
  if (args.title === undefined)
1491
- return usage('action "create" requires title');
1884
+ return usage('action "add" requires title');
1492
1885
  if (frequency === undefined || interval === undefined) {
1493
- return usage('action "create" requires frequency and interval');
1886
+ return usage('action "add" requires frequency and interval');
1494
1887
  }
1495
- return mutationResult(await c.write.createRepeatingProject({
1888
+ return mutationResult(await c.write.addRepeatingProject({
1496
1889
  title: args.title,
1497
1890
  ...(args.notes !== undefined && { notes: args.notes }),
1498
1891
  ...(args.area !== undefined && { area: containerRef(args.area) }),
@@ -1533,7 +1926,7 @@ export function createThingsMcpServer(options = {}) {
1533
1926
  server.registerTool("duplicate_item", {
1534
1927
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
1535
1928
  "includes its children. Not available for repeating items.",
1536
- inputSchema: { uuid: z.string(), ...dryRunShape },
1929
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
1537
1930
  annotations: NON_DESTRUCTIVE,
1538
1931
  }, async (args) => guard(async () => {
1539
1932
  const c = getClient();
@@ -1554,6 +1947,7 @@ export function createThingsMcpServer(options = {}) {
1554
1947
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1555
1948
  ...tzShape,
1556
1949
  ...dryRunShape,
1950
+ ...opIdShape,
1557
1951
  },
1558
1952
  annotations: NON_DESTRUCTIVE,
1559
1953
  }, async (args) => guard(async () => {
@@ -1570,23 +1964,37 @@ export function createThingsMcpServer(options = {}) {
1570
1964
  }, writeOptions(args)));
1571
1965
  }));
1572
1966
  server.registerTool("move_project", {
1573
- description: "Move a project into an area, or detach it from its current area. Pass exactly " +
1574
- "one of area / detach. The project's status and schedule are unaffected.",
1967
+ description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
1968
+ "destination: to_area, or no_area (leave the area a project's complete detach). To " +
1969
+ "REORDER projects among their siblings without changing area, pass a position " +
1970
+ "(first/last/before/after) and NO destination. An anchor positions but never migrates. " +
1971
+ "The project's status and schedule are unaffected; the result's placementClass states " +
1972
+ "whether top-of-bucket placement was guaranteed.",
1575
1973
  inputSchema: {
1576
- uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
1577
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1578
- detach: z.boolean().optional().describe("Remove the current area assignment"),
1974
+ uuids: z.array(z.string()).describe(`The projects to move (${REF_FORMAT}), in order`),
1975
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1976
+ no_area: z.boolean().optional().describe("Leave the current area (a project's detach)"),
1977
+ ...positionShape,
1579
1978
  ...dryRunShape,
1580
1979
  },
1581
1980
  annotations: NON_DESTRUCTIVE,
1582
1981
  }, async (args) => guard(async () => {
1583
- if ((args.detach === true) === (args.area !== undefined)) {
1584
- return usage("pass exactly one of area / detach");
1585
- }
1586
- const c = getClient();
1587
- return mutationResult(args.detach === true
1588
- ? await c.write.detachProject(args.uuid, writeOptions(args))
1589
- : await c.write.moveProject(args.uuid, containerRef(args.area), writeOptions(args)));
1982
+ const dests = [];
1983
+ if (args.to_area !== undefined)
1984
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1985
+ if (args.no_area === true)
1986
+ dests.push({ kind: "no-area" });
1987
+ if (dests.length > 1)
1988
+ return usage("pass at most one of to_area / no_area");
1989
+ const position = movePositionArgs(args);
1990
+ if (position === "conflict")
1991
+ return usage("pass at most one of first/last/before/after");
1992
+ const request = {
1993
+ uuids: args.uuids,
1994
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1995
+ ...(position !== undefined && { position }),
1996
+ };
1997
+ return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
1590
1998
  }));
1591
1999
  // ----------------------------------------------------------------- areas
1592
2000
  server.registerTool("add_area", {
@@ -1596,6 +2004,7 @@ export function createThingsMcpServer(options = {}) {
1596
2004
  tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
1597
2005
  ...createTagsShape,
1598
2006
  ...dryRunShape,
2007
+ ...opIdShape,
1599
2008
  },
1600
2009
  annotations: NON_DESTRUCTIVE,
1601
2010
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
@@ -1606,6 +2015,7 @@ export function createThingsMcpServer(options = {}) {
1606
2015
  title: z.string(),
1607
2016
  parent: z.string().optional().describe("Existing parent tag name"),
1608
2017
  ...dryRunShape,
2018
+ ...opIdShape,
1609
2019
  },
1610
2020
  annotations: NON_DESTRUCTIVE,
1611
2021
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
@@ -1629,25 +2039,48 @@ export function createThingsMcpServer(options = {}) {
1629
2039
  .boolean()
1630
2040
  .optional()
1631
2041
  .describe("Required for area/tag delete and trash.empty (PERMANENT, no Trash)"),
2042
+ allow_non_empty_area: z
2043
+ .boolean()
2044
+ .optional()
2045
+ .describe("area.delete: delete a non-empty area together with its contents"),
1632
2046
  ...dryRunShape,
2047
+ ...opIdShape,
1633
2048
  },
1634
2049
  annotations: DESTRUCTIVE,
1635
2050
  }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
1636
2051
  server.registerTool("batch", {
1637
2052
  description: "Run several operations in order, each independently — there are no transactions, " +
1638
2053
  "and a failure does not roll back earlier operations. Per-operation results return " +
1639
- "in order; fail_fast skips the remainder after the first failure.",
2054
+ "in order; fail_fast skips the remainder after the first failure. " +
2055
+ "CHAINING: an operation that creates something may carry temp_id (a handle); a LATER " +
2056
+ 'operation references that new uuid as "$handle" in any id/container field (dotted ' +
2057
+ '"$handle.instance"/"$handle.replaced" reach a repeating op\'s spawned instance / ' +
2058
+ "replaced source). A temp_id is valid only on a creating operation (not tag.add — " +
2059
+ "reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
2060
+ "safe — an operation matching an earlier success is reported already-applied, not " +
2061
+ "re-created. The result adds temp_id_mapping (handle → uuid) and undo_token, which " +
2062
+ "reverses the whole batch as one unit via the undo tool.",
1640
2063
  inputSchema: {
1641
2064
  ops: z
1642
2065
  .array(z.object({
1643
2066
  op: z.enum(OPERATION_KINDS),
1644
2067
  params: z.record(z.string(), z.unknown()),
2068
+ temp_id: z
2069
+ .string()
2070
+ .optional()
2071
+ .describe('Handle for this op\'s new uuid, referenced later as "$handle"'),
2072
+ op_id: z
2073
+ .string()
2074
+ .optional()
2075
+ .describe("Idempotency id — a resubmitted op matching an earlier success is skipped"),
1645
2076
  options: z
1646
2077
  .object({
1647
2078
  acknowledge_checklist_reset: z.boolean().optional(),
1648
2079
  acknowledge_project_reopen: z.boolean().optional(),
1649
2080
  dangerously_permanent: z.boolean().optional(),
1650
2081
  acknowledge_tag_subtree: z.boolean().optional(),
2082
+ allow_non_empty_area: z.boolean().optional(),
2083
+ dangerously_drive_gui: z.boolean().optional(),
1651
2084
  })
1652
2085
  .optional(),
1653
2086
  }))
@@ -1668,91 +2101,126 @@ export function createThingsMcpServer(options = {}) {
1668
2101
  ...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
1669
2102
  ...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
1670
2103
  ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
2104
+ ...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
2105
+ ...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
1671
2106
  ...(ceiling !== undefined && { maxDisruption: ceiling }),
1672
2107
  };
1673
2108
  return {
1674
2109
  op: op.op,
1675
2110
  params: op.params,
2111
+ ...(op.temp_id !== undefined && { tempId: op.temp_id }),
2112
+ ...(op.op_id !== undefined && { opId: op.op_id }),
1676
2113
  ...(Object.keys(opts).length > 0 && { options: opts }),
1677
2114
  };
1678
2115
  });
1679
- const results = await getClient().write.batch(ops, {
2116
+ const batchResult = await getClient().write.batch(ops, {
1680
2117
  ...(args.dry_run === true && { dryRun: true }),
1681
2118
  ...(args.fail_fast === true && { failFast: true }),
1682
2119
  actor: mcpActor(),
1683
2120
  });
1684
- return jsonResult(results);
2121
+ // First block: the per-op results, each FLATTENED to the wire shape (a
2122
+ // string `outcome` with the variant fields hoisted — parity with the CLI
2123
+ // JSONL stream, docs/contract.md). Second block (additive): the batch
2124
+ // chaining/undo summary, present only when the batch minted a token or
2125
+ // bound temp ids.
2126
+ const lines = batchResult.results.map(flattenBatchLine);
2127
+ const hasSummary = batchResult.undoToken !== undefined || Object.keys(batchResult.tempIdMapping).length > 0;
2128
+ if (!hasSummary)
2129
+ return jsonResult(lines);
2130
+ return {
2131
+ content: [
2132
+ { type: "text", text: JSON.stringify(lines) },
2133
+ {
2134
+ type: "text",
2135
+ text: JSON.stringify({
2136
+ tempIdMapping: batchResult.tempIdMapping,
2137
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
2138
+ }),
2139
+ },
2140
+ ],
2141
+ };
1685
2142
  }));
1686
2143
  server.registerTool("reorder", {
1687
- description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1688
- "area-less someday projects one kind per call), a " +
1689
- "project's to-dos, a project's headings (scope=headings children move with " +
1690
- "their heading), an area, or the top-level projects (scope=projects " +
1691
- "each project takes a brief someday/anytime round-trip) the given uuids move " +
1692
- "to the TOP in the given order; unlisted items keep their relative order below. " +
1693
- "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
1694
- "via `things config set allow-experimental true`. This Evening and " +
1695
- `scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
1696
- "to-dos and projects are ordered separately one kind per call. " +
1697
- "scope=areas instead moves the sidebar areas themselves: give target plus exactly " +
1698
- "one of before/after/position, and pass dangerously_drive_gui (it drives the local " +
1699
- "Things app). Every other scope takes uuids.",
2144
+ description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
2145
+ "share this REARRANGES, never changes what an item belongs to (to change membership " +
2146
+ "use move_todo / move_project). The refs order is the resulting order; unmentioned " +
2147
+ "siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
2148
+ "EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
2149
+ "position the block instead. Refs that span different containers or buckets are refused. " +
2150
+ "A Today or This Evening member also holds a slot in its own container, so a set that is " +
2151
+ "coherent on BOTH axes is ambiguous — pass `in` to name the axis (the refusal names both " +
2152
+ "choices). A deadline-forecast set sharing one Upcoming day is dual-axis the same way (the " +
2153
+ "day-block vs its container order); `in: upcoming` or `in: <YYYY-MM-DD>` names the day-block. " +
2154
+ "The project rows the Today/Evening/day lists intermix with to-dos may be " +
2155
+ "reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
2156
+ "to-dos, or an area must first be enabled once via `things config set allow-experimental " +
2157
+ "true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
2158
+ "move_heading action; to reorder sidebar AREAS use reorder_areas.",
1700
2159
  inputSchema: {
1701
- scope: z.enum([
1702
- "today",
1703
- "evening",
1704
- "inbox",
1705
- "someday",
1706
- "project",
1707
- "headings",
1708
- "area",
1709
- "projects",
1710
- "areas",
1711
- ]),
1712
- container: z
1713
- .string()
1714
- .optional()
1715
- .describe(`Project/area (${REF_FORMAT}) — required for those scopes`),
1716
- uuids: z
2160
+ refs: z
1717
2161
  .array(z.string())
2162
+ .describe("The items to rearrange, in the order they should land (may be a subset)"),
2163
+ ...positionShape,
2164
+ in: z
2165
+ .string()
1718
2166
  .optional()
1719
- .describe("Desired order, top first (may be a subset) required for every scope but areas"),
1720
- strategy: z.enum(["native", "bounce"]).optional(),
1721
- target: z.string().optional().describe(`scope areas: the area to move (${REF_FORMAT})`),
2167
+ .describe("Name the axis to reorder on: today | evening | anytime | someday | inbox, a project/" +
2168
+ "area/heading ref (uuid or unique title), upcoming (the one future day the set " +
2169
+ "shares), or a YYYY-MM-DD day-block. A stage-list or container axis sorts one KIND " +
2170
+ "at a time — a mixed to-do+project set is refused (even sharing a container); only " +
2171
+ "today | evening | upcoming | a day-block intermix both kinds. Reorder a project's " +
2172
+ "headings with the heading tool's move_heading action."),
2173
+ ...dryRunShape,
2174
+ },
2175
+ annotations: NON_DESTRUCTIVE,
2176
+ }, async (args) => guard(async () => {
2177
+ const position = movePositionArgs(args);
2178
+ if (position === "conflict")
2179
+ return usage("pass at most one of first/last/before/after");
2180
+ const request = {
2181
+ uuids: args.refs,
2182
+ ...(position !== undefined && { position }),
2183
+ ...(args.in !== undefined && { in: args.in }),
2184
+ };
2185
+ return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
2186
+ }));
2187
+ server.registerTool("reorder_areas", {
2188
+ description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
2189
+ "name). Pass exactly one destination: before/after another area, or first/last. This " +
2190
+ "visibly drives the local Things app (the window comes forward and the sidebar may " +
2191
+ "scroll) and must be turned on first with `things config set ui-enabled true`; the " +
2192
+ "area's projects and to-dos are untouched.",
2193
+ inputSchema: {
2194
+ target: z.string().describe(`the area to move (${REF_FORMAT})`),
1722
2195
  before: z
1723
2196
  .string()
1724
2197
  .optional()
1725
- .describe(`scope areas: place it immediately above this area (${REF_FORMAT})`),
2198
+ .describe(`place it immediately above this area (${REF_FORMAT})`),
1726
2199
  after: z
1727
2200
  .string()
1728
2201
  .optional()
1729
- .describe(`scope areas: place it immediately below this area (${REF_FORMAT})`),
1730
- position: z
1731
- .enum(["first", "last"])
1732
- .optional()
1733
- .describe("scope areas: move it to the top or bottom of the area list"),
2202
+ .describe(`place it immediately below this area (${REF_FORMAT})`),
2203
+ first: z.boolean().optional().describe("move it to the top of the area list"),
2204
+ last: z.boolean().optional().describe("move it to the bottom of the area list"),
1734
2205
  ...driveGuiShape,
1735
2206
  ...dryRunShape,
1736
2207
  },
1737
2208
  annotations: NON_DESTRUCTIVE,
1738
2209
  }, async (args) => guard(async () => {
1739
- if (args.scope === "areas") {
1740
- if (args.target === undefined)
1741
- return usage('scope "areas" requires target');
1742
- return mutationResult(await getClient().write.run("area.reorder", {
1743
- target: args.target,
1744
- ...(args.before !== undefined && { before: args.before }),
1745
- ...(args.after !== undefined && { after: args.after }),
1746
- ...(args.position !== undefined && { position: args.position }),
1747
- }, writeOptions(args)));
1748
- }
1749
- if (args.uuids === undefined)
1750
- return usage(`scope "${args.scope}" requires uuids`);
1751
- return mutationResult(await getClient().write.reorder({
1752
- scope: args.scope,
1753
- uuids: args.uuids,
1754
- ...(args.container !== undefined && { container: containerRef(args.container) }),
1755
- ...(args.strategy !== undefined && { strategy: args.strategy }),
2210
+ const chosen = [
2211
+ args.before !== undefined,
2212
+ args.after !== undefined,
2213
+ args.first === true,
2214
+ args.last === true,
2215
+ ].filter(Boolean).length;
2216
+ if (chosen !== 1)
2217
+ return usage("pass exactly one of before / after / first / last");
2218
+ return mutationResult(await getClient().write.run("area.reorder", {
2219
+ target: args.target,
2220
+ ...(args.before !== undefined && { before: args.before }),
2221
+ ...(args.after !== undefined && { after: args.after }),
2222
+ ...(args.first === true && { position: "first" }),
2223
+ ...(args.last === true && { position: "last" }),
1756
2224
  }, writeOptions(args)));
1757
2225
  }));
1758
2226
  server.registerTool("undo", {