things-api 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) 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/commands/area.d.ts +18 -11
  5. package/dist/cli/commands/area.js +55 -67
  6. package/dist/cli/commands/area.js.map +1 -1
  7. package/dist/cli/commands/doctor.d.ts +1 -2
  8. package/dist/cli/commands/doctor.js +11 -1
  9. package/dist/cli/commands/doctor.js.map +1 -1
  10. package/dist/cli/commands/install-skill.d.ts +19 -4
  11. package/dist/cli/commands/install-skill.js +101 -52
  12. package/dist/cli/commands/install-skill.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +9 -0
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +1 -0
  16. package/dist/cli/commands/project.js +20 -28
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +100 -28
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/show.js +32 -8
  21. package/dist/cli/commands/show.js.map +1 -1
  22. package/dist/cli/commands/todo.js +1 -1
  23. package/dist/cli/commands/todo.js.map +1 -1
  24. package/dist/cli/commands/writes.js +769 -143
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/did-you-mean.d.ts +14 -3
  27. package/dist/cli/did-you-mean.js +26 -3
  28. package/dist/cli/did-you-mean.js.map +1 -1
  29. package/dist/cli/glyphs.d.ts +16 -2
  30. package/dist/cli/glyphs.js +60 -12
  31. package/dist/cli/glyphs.js.map +1 -1
  32. package/dist/cli/help.js +67 -12
  33. package/dist/cli/help.js.map +1 -1
  34. package/dist/cli/main.js +1 -1
  35. package/dist/cli/main.js.map +1 -1
  36. package/dist/cli/move-hint.d.ts +3 -2
  37. package/dist/cli/move-hint.js +2 -2
  38. package/dist/cli/move-hint.js.map +1 -1
  39. package/dist/cli/read-driver.d.ts +43 -5
  40. package/dist/cli/read-driver.js +81 -15
  41. package/dist/cli/read-driver.js.map +1 -1
  42. package/dist/cli/render.d.ts +19 -8
  43. package/dist/cli/render.js +59 -25
  44. package/dist/cli/render.js.map +1 -1
  45. package/dist/cli/resolve-invocation.d.ts +2 -2
  46. package/dist/cli/resolve-invocation.js +3 -3
  47. package/dist/cli/resolve-invocation.js.map +1 -1
  48. package/dist/cli/skill-check.d.ts +12 -6
  49. package/dist/cli/skill-check.js +31 -14
  50. package/dist/cli/skill-check.js.map +1 -1
  51. package/dist/cli/skill.d.ts +8 -0
  52. package/dist/cli/skill.js +10 -0
  53. package/dist/cli/skill.js.map +1 -1
  54. package/dist/cli/verb-hint.js +1 -1
  55. package/dist/cli/verb-hint.js.map +1 -1
  56. package/dist/client.d.ts +140 -19
  57. package/dist/client.js +253 -38
  58. package/dist/client.js.map +1 -1
  59. package/dist/config.d.ts +66 -2
  60. package/dist/config.js +120 -12
  61. package/dist/config.js.map +1 -1
  62. package/dist/contracts.d.ts +176 -28
  63. package/dist/contracts.js +22 -1
  64. package/dist/contracts.js.map +1 -1
  65. package/dist/diagnose.d.ts +34 -2
  66. package/dist/diagnose.js +36 -1
  67. package/dist/diagnose.js.map +1 -1
  68. package/dist/index.d.ts +19 -8
  69. package/dist/index.js +19 -3
  70. package/dist/index.js.map +1 -1
  71. package/dist/mcp/server.d.ts +9 -0
  72. package/dist/mcp/server.js +733 -304
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/model/entities.d.ts +46 -3
  75. package/dist/model/entities.js.map +1 -1
  76. package/dist/model/mappers.d.ts +2 -0
  77. package/dist/model/mappers.js +41 -2
  78. package/dist/model/mappers.js.map +1 -1
  79. package/dist/model/recurrence.d.ts +8 -1
  80. package/dist/model/recurrence.js.map +1 -1
  81. package/dist/read/area-filter.d.ts +76 -0
  82. package/dist/read/area-filter.js +59 -0
  83. package/dist/read/area-filter.js.map +1 -0
  84. package/dist/read/area-view.d.ts +11 -11
  85. package/dist/read/area-view.js +50 -28
  86. package/dist/read/area-view.js.map +1 -1
  87. package/dist/read/detail.js +15 -8
  88. package/dist/read/detail.js.map +1 -1
  89. package/dist/read/predicates.d.ts +18 -0
  90. package/dist/read/predicates.js +19 -0
  91. package/dist/read/predicates.js.map +1 -1
  92. package/dist/read/project-view.d.ts +31 -15
  93. package/dist/read/project-view.js +72 -39
  94. package/dist/read/project-view.js.map +1 -1
  95. package/dist/read/pseudo-area.d.ts +29 -0
  96. package/dist/read/pseudo-area.js +27 -0
  97. package/dist/read/pseudo-area.js.map +1 -0
  98. package/dist/read/queries.d.ts +105 -18
  99. package/dist/read/queries.js +185 -30
  100. package/dist/read/queries.js.map +1 -1
  101. package/dist/read/scope.d.ts +126 -0
  102. package/dist/read/scope.js +162 -0
  103. package/dist/read/scope.js.map +1 -0
  104. package/dist/read/search-rank.d.ts +2 -5
  105. package/dist/read/shape.d.ts +162 -0
  106. package/dist/read/shape.js +686 -0
  107. package/dist/read/shape.js.map +1 -0
  108. package/dist/read/show-target.d.ts +9 -1
  109. package/dist/read/show-target.js +45 -4
  110. package/dist/read/show-target.js.map +1 -1
  111. package/dist/read/stage.d.ts +199 -0
  112. package/dist/read/stage.js +125 -0
  113. package/dist/read/stage.js.map +1 -0
  114. package/dist/read/truncation.d.ts +7 -5
  115. package/dist/read/truncation.js +33 -8
  116. package/dist/read/truncation.js.map +1 -1
  117. package/dist/read/views.d.ts +49 -9
  118. package/dist/read/views.js +193 -40
  119. package/dist/read/views.js.map +1 -1
  120. package/dist/surface-copy.d.ts +9 -0
  121. package/dist/surface-copy.js +9 -0
  122. package/dist/surface-copy.js.map +1 -1
  123. package/dist/write/batch.d.ts +49 -10
  124. package/dist/write/batch.js +423 -71
  125. package/dist/write/batch.js.map +1 -1
  126. package/dist/write/commands.js +266 -55
  127. package/dist/write/commands.js.map +1 -1
  128. package/dist/write/guards.d.ts +1 -1
  129. package/dist/write/guards.js +71 -10
  130. package/dist/write/guards.js.map +1 -1
  131. package/dist/write/heading.d.ts +10 -1
  132. package/dist/write/heading.js +35 -5
  133. package/dist/write/heading.js.map +1 -1
  134. package/dist/write/make-repeating-project.d.ts +2 -2
  135. package/dist/write/make-repeating-project.js +9 -9
  136. package/dist/write/make-repeating-project.js.map +1 -1
  137. package/dist/write/move.d.ts +130 -0
  138. package/dist/write/move.js +1533 -0
  139. package/dist/write/move.js.map +1 -0
  140. package/dist/write/operations.d.ts +105 -19
  141. package/dist/write/operations.js +56 -7
  142. package/dist/write/operations.js.map +1 -1
  143. package/dist/write/opid.d.ts +31 -0
  144. package/dist/write/opid.js +30 -0
  145. package/dist/write/opid.js.map +1 -0
  146. package/dist/write/pipeline.d.ts +70 -3
  147. package/dist/write/pipeline.js +198 -33
  148. package/dist/write/pipeline.js.map +1 -1
  149. package/dist/write/pre-state.d.ts +162 -8
  150. package/dist/write/pre-state.js +385 -20
  151. package/dist/write/pre-state.js.map +1 -1
  152. package/dist/write/reorder.d.ts +23 -3
  153. package/dist/write/reorder.js +1871 -85
  154. package/dist/write/reorder.js.map +1 -1
  155. package/dist/write/reversibility.js +19 -7
  156. package/dist/write/reversibility.js.map +1 -1
  157. package/dist/write/scope-guard.d.ts +31 -0
  158. package/dist/write/scope-guard.js +162 -0
  159. package/dist/write/scope-guard.js.map +1 -0
  160. package/dist/write/undo.js +120 -13
  161. package/dist/write/undo.js.map +1 -1
  162. package/dist/write/vectors/applescript.js +16 -7
  163. package/dist/write/vectors/applescript.js.map +1 -1
  164. package/dist/write/vectors/shortcuts.js +1 -1
  165. package/dist/write/vectors/shortcuts.js.map +1 -1
  166. package/dist/write/vectors/simulator.js +83 -27
  167. package/dist/write/vectors/simulator.js.map +1 -1
  168. package/dist/write/vectors/types.d.ts +11 -0
  169. package/dist/write/vectors/ui-certification.d.ts +3 -3
  170. package/dist/write/vectors/ui-certification.js +41 -5
  171. package/dist/write/vectors/ui-certification.js.map +1 -1
  172. package/dist/write/vectors/ui-drag.d.ts +21 -1
  173. package/dist/write/vectors/ui-drag.js +138 -27
  174. package/dist/write/vectors/ui-drag.js.map +1 -1
  175. package/dist/write/vectors/ui-recipes.d.ts +21 -1
  176. package/dist/write/vectors/ui-recipes.js +166 -7
  177. package/dist/write/vectors/ui-recipes.js.map +1 -1
  178. package/dist/write/vectors/ui.d.ts +25 -1
  179. package/dist/write/vectors/ui.js +142 -33
  180. package/dist/write/vectors/ui.js.map +1 -1
  181. package/dist/write/verify/delta.d.ts +17 -1
  182. package/dist/write/verify/delta.js +106 -29
  183. package/dist/write/verify/delta.js.map +1 -1
  184. package/package.json +7 -1
  185. package/schema/envelope.schema.json +383 -0
  186. package/skills/things-cli/SKILL.md +34 -17
  187. package/skills/things-cli/references/banner.md +35 -0
  188. package/skills/things-cli/references/contracts.md +30 -9
  189. package/skills/things-cli/references/errors.md +49 -0
  190. package/skills/things-cli/references/gui.md +1 -1
  191. package/skills/things-cli/references/model.md +15 -6
  192. package/skills/things-cli/references/ordering.md +71 -0
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * MCP surface over ThingsClient — the third thin layer (CLI, library, MCP),
3
- * all consuming the same client. Tools return the SAME JSON objects the
4
- * library returns (and the CLI wraps in --json envelopes); mutation failures
5
- * surface as MCP tool errors carrying the machine-readable code + the
6
- * remediation text the guards produce. Nothing here contains Things logic.
3
+ * all consuming the same client. Read tools return the library's JSON objects
4
+ * verbatim; mutation/move tools frame their result the same way the CLI's wire
5
+ * envelope does (docs/contract.md) a success drops the internal discriminator
6
+ * (via mutationWireData), a dry-run emits the bare plan, and batch lines flatten
7
+ * to the string-`outcome` JSONL shape. Mutation failures surface as MCP tool
8
+ * errors carrying the machine-readable code + the remediation text the guards
9
+ * produce. Nothing here contains Things logic.
7
10
  *
8
11
  * Tool descriptions and the server instructions follow the consumer-voice
9
12
  * contract in docs/design/surface-copy.md: behavior and side effects only,
@@ -13,7 +16,7 @@
13
16
  */
14
17
  import { z } from "zod";
15
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
- import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, 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, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, shapeReadPayload, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
17
20
  function jsonResult(data) {
18
21
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
19
22
  }
@@ -44,17 +47,18 @@ function truncatedResult(data, truncation) {
44
47
  }
45
48
  /**
46
49
  * Grouped read result (anytime/someday): the per-block-truncated sections plus
47
- * a second block carrying the {@link GroupedTruncation} counts and, when
48
- * anything was hidden, a one-line note the agent can read.
50
+ * a second block carrying the unified {@link Truncation} counts (its `blocks`
51
+ * hold the per-block nesting) and, when anything was hidden, a one-line note the
52
+ * agent can read.
49
53
  */
50
- function groupedResult(data, grouped) {
51
- const note = grouped.truncated
54
+ function groupedResult(data, truncation) {
55
+ const note = truncation.truncated
52
56
  ? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
53
57
  : undefined;
54
58
  return {
55
59
  content: [
56
60
  { type: "text", text: JSON.stringify(omitEmpty(data)) },
57
- { type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
61
+ { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
58
62
  ],
59
63
  };
60
64
  }
@@ -112,12 +116,20 @@ function errorResult(error) {
112
116
  function usage(message) {
113
117
  return errorResult({ code: "usage", message });
114
118
  }
115
- /** Map a mutation outcome to an MCP result (errors carry remediation). */
119
+ /**
120
+ * Map a mutation outcome to an MCP result (errors carry remediation). Framing
121
+ * mirrors the CLI wire (docs/contract.md, phase-2 alignment): a SUCCESS drops
122
+ * the internal success discriminator via {@link mutationWireData} (call success
123
+ * is the tool result's own not-an-error signal — the payload carries no `kind`),
124
+ * and a dry-run returns the bare plan (the CLI's `mutation-plan` data). Failures
125
+ * stay tool errors carrying the machine-readable code.
126
+ */
116
127
  function mutationResult(result) {
117
128
  switch (result.kind) {
118
129
  case "ok":
130
+ return jsonResult(mutationWireData(result));
119
131
  case "dry-run":
120
- return jsonResult(result);
132
+ return jsonResult(result.plan);
121
133
  case "blocked":
122
134
  return errorResult({
123
135
  code: blockedCode(result),
@@ -146,6 +158,89 @@ function mutationResult(result) {
146
158
  });
147
159
  }
148
160
  }
161
+ /**
162
+ * Map a move/reorder orchestrator outcome to an MCP result (spec §4/§7). Framing
163
+ * mirrors {@link mutationResult}: a `move-ok` drops the internal discriminator
164
+ * (the placement-honesty `note` and every `membership`/`placement` field — with
165
+ * their own `touched`/`warnings` disclosures — pass through), and a dry-run
166
+ * returns the bare plan.
167
+ */
168
+ function moveResult(result) {
169
+ switch (result.kind) {
170
+ case "move-ok":
171
+ return jsonResult(mutationWireData(result));
172
+ case "move-dry-run":
173
+ return jsonResult(result.plan);
174
+ case "move-refused":
175
+ return errorResult({
176
+ code: result.refusal,
177
+ message: result.detail,
178
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
179
+ ...(result.candidates !== undefined && { details: { candidates: result.candidates } }),
180
+ });
181
+ case "move-leg-failed":
182
+ return errorResult({
183
+ code: "verify-failed",
184
+ message: result.detail,
185
+ remediation: `completed ${result.completed.length} leg(s) before the failure`,
186
+ });
187
+ }
188
+ }
189
+ /**
190
+ * Flatten one batch line to the wire shape (docs/contract.md — the JSONL grammar
191
+ * shared with the CLI): the internal `outcome` union object collapses to one
192
+ * level. `outcome` becomes its tag as a plain string (`"ok"`, `"blocked"`,
193
+ * `"dry-run"`, `"already-applied"`, …), and every variant field (uuid, detail,
194
+ * plan, expected, observed, …) sits as a sibling of the line-level keys. The
195
+ * outcome's own `op` duplicates the line-level `op`, so it is dropped — no
196
+ * variant field name collides with a line-level key.
197
+ */
198
+ function flattenBatchLine(r) {
199
+ const { index, op, outcome, tempId, boundUuid, opId } = r;
200
+ const { kind, op: _outcomeOp, ...variant } = outcome;
201
+ return {
202
+ index,
203
+ op,
204
+ outcome: kind,
205
+ ...variant,
206
+ ...(tempId !== undefined && { tempId }),
207
+ ...(boundUuid !== undefined && { boundUuid }),
208
+ ...(opId !== undefined && { opId }),
209
+ };
210
+ }
211
+ /** Build a MovePosition from the shared MCP position args (null when none). */
212
+ function movePositionArgs(args) {
213
+ const chosen = [
214
+ args.first === true,
215
+ args.last === true,
216
+ args.before !== undefined,
217
+ args.after !== undefined,
218
+ ].filter(Boolean).length;
219
+ if (chosen > 1)
220
+ return "conflict";
221
+ if (args.first === true)
222
+ return { at: "first" };
223
+ if (args.last === true)
224
+ return { at: "last" };
225
+ if (args.before !== undefined)
226
+ return { before: args.before };
227
+ if (args.after !== undefined)
228
+ return { after: args.after };
229
+ return undefined;
230
+ }
231
+ /** The shared position input schema for the move tools. */
232
+ const positionShape = {
233
+ first: z.boolean().optional().describe("place the block at the top of its bucket"),
234
+ last: z.boolean().optional().describe("place the block at the bottom of its bucket"),
235
+ before: z
236
+ .string()
237
+ .optional()
238
+ .describe("place the block immediately before this item (same bucket)"),
239
+ after: z
240
+ .string()
241
+ .optional()
242
+ .describe("place the block immediately after this item (same bucket)"),
243
+ };
149
244
  const READ_ONLY = { readOnlyHint: true };
150
245
  const NON_DESTRUCTIVE = { destructiveHint: false };
151
246
  const DESTRUCTIVE = { destructiveHint: true };
@@ -201,10 +296,25 @@ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
201
296
  const dryRunShape = {
202
297
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
203
298
  };
299
+ /**
300
+ * The per-call idempotency key for a single write tool — the analogue of a batch
301
+ * line's op_id. A resubmission carrying the same key is recognized as already
302
+ * applied (a prior verified change with that key) and is not re-run. Spread into
303
+ * the single-mutation write tools (not the variadic move/reorder tools, whose
304
+ * idempotency is the batch-shaped per-line op_id).
305
+ */
306
+ const opIdShape = {
307
+ op_id: z
308
+ .string()
309
+ .optional()
310
+ .describe("Idempotency key: a resubmission with the same key is recognized as already applied " +
311
+ "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)"),
312
+ };
204
313
  /**
205
314
  * The per-call opt-in for the tools that reach a change only by driving the
206
315
  * local Things app's accessibility interface. Shared by `repeat`, the
207
- * `convert_to_project` heading action, and the `areas` reorder scope.
316
+ * `convert_to_project` tool, the heading tool's `promote_heading` action, and
317
+ * the `areas` reorder scope.
208
318
  */
209
319
  const driveGuiShape = {
210
320
  dangerously_drive_gui: z
@@ -226,6 +336,20 @@ const createTagsShape = {
226
336
  "instead of stopping on an unknown tag"),
227
337
  };
228
338
  const containerRef = (ref) => ({ uuid: ref, title: ref });
339
+ /**
340
+ * The `loose` reserved-word disclosure for the area-filtered read tools
341
+ * (read_view, search — the same advisory the CLI surfaces, #333/#346). `loose`
342
+ * ALWAYS addresses the null area; when a real area named "Loose" shadows the
343
+ * reserved word this names it (by uuid) so it stays targetable. Returns
344
+ * undefined when the ref is not the reserved word, or nothing shadows it.
345
+ * (get_area reads its disclosure from the area-view result's own `notice`.)
346
+ */
347
+ function looseAreaWarnings(c, areaRef) {
348
+ if (areaRef === undefined || !isLooseRef(areaRef))
349
+ return undefined;
350
+ const shadow = c.read.areas().find((a) => isLooseRef(a.title));
351
+ return shadow !== undefined ? [looseShadowNotice(shadow.uuid)] : undefined;
352
+ }
229
353
  /** Cap on project titles inlined into the server instructions. */
230
354
  const INSTRUCTIONS_MAX_PROJECTS = 100;
231
355
  /** A tag's display label: nested tags show `parent > child`. */
@@ -258,14 +382,22 @@ function buildInstructions(getClient) {
258
382
  "confirmation parameter named in their description; refused calls return an error saying " +
259
383
  "what to pass.",
260
384
  "- 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.",
385
+ "tags inherited from its containing project and area. The today and evening markers are " +
386
+ "present only for an item in Today (evening implies today); an unscheduled start=active item " +
387
+ "is in Anytime and carries neither. Completing an item makes it findable in Logbook.",
264
388
  "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
265
389
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
266
390
  ];
267
391
  try {
268
392
  const c = getClient();
393
+ // Under a container scope the inventory below is already limited to in-scope
394
+ // containers (the client filters areas/projects), so out-of-scope names are
395
+ // never embedded here. State the scope plainly so the agent knows its bounds.
396
+ if (c.scope !== undefined) {
397
+ lines.push("", `Scope: this server is limited to the ${c.scope.kind} "${c.scope.title}". Only items ` +
398
+ "within it are readable, and every change is confined to it; a reference naming anything " +
399
+ "outside it returns not-found, exactly as a nonexistent reference does.");
400
+ }
269
401
  const areas = c.read.areas();
270
402
  const tags = c.read.tags();
271
403
  const projects = c.read.projects();
@@ -306,6 +438,13 @@ function deriveMcpActor(clientName) {
306
438
  .replaceAll(/-+$/g, "");
307
439
  return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
308
440
  }
441
+ /** Validate an op_id (throwing RangeError → mapped to a `usage` tool error by `guard`). */
442
+ function assertOpId(opId) {
443
+ if (!OP_ID_RE.test(opId)) {
444
+ throw new RangeError("op_id must match [A-Za-z0-9_-] and be 1-64 characters");
445
+ }
446
+ return opId;
447
+ }
309
448
  export function createThingsMcpServer(options = {}) {
310
449
  // One lazily-opened client for the server's lifetime; SQLite read
311
450
  // snapshots are per-statement, so fresh reads see external commits.
@@ -313,6 +452,7 @@ export function createThingsMcpServer(options = {}) {
313
452
  const getClient = () => {
314
453
  client ??= openThings({
315
454
  ...(options.dbPath !== undefined && { dbPath: options.dbPath }),
455
+ ...(options.scope !== undefined && { scope: options.scope }),
316
456
  ...options.openOptions,
317
457
  });
318
458
  return client;
@@ -333,9 +473,11 @@ export function createThingsMcpServer(options = {}) {
333
473
  ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
334
474
  ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
335
475
  ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
476
+ ...(args.allow_non_empty_area === true && { allowNonEmptyArea: true }),
336
477
  ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
337
478
  ...(args.create_tags === true && { createTags: true }),
338
479
  ...(args.tz !== undefined && { zone: args.tz }),
480
+ ...(args.op_id !== undefined && { opId: assertOpId(args.op_id) }),
339
481
  });
340
482
  /** Run a handler, mapping environment/usage throws to tool errors. */
341
483
  const guard = async (fn) => {
@@ -364,8 +506,13 @@ export function createThingsMcpServer(options = {}) {
364
506
  * same note the CLI prints), so a consumer sees when the Things database no
365
507
  * longer matches the validated schema and its data may be incomplete. No
366
508
  * block is added when the schema checks out or the read itself errored.
509
+ *
510
+ * `extraWarnings` folds in the read's own advisories (the `loose` reserved-word
511
+ * shadow disclosure, an area-view's placement `notice`) so they reach the
512
+ * consumer in the SAME `meta.warnings` array the CLI surfaces — schema warnings
513
+ * first, then the read-specific notices.
367
514
  */
368
- const readGuard = async (fn, tz) => {
515
+ const readGuard = async (fn, tz, extraMeta, extraWarnings) => {
369
516
  const result = await guard(fn);
370
517
  if (result.isError === true)
371
518
  return result;
@@ -382,9 +529,23 @@ export function createThingsMcpServer(options = {}) {
382
529
  catch {
383
530
  warnings = [];
384
531
  }
532
+ // Read-specific advisories the handler resolved (loose-shadow / area notice)
533
+ // — merged AFTER the schema warnings, defensive against a DB read throwing.
534
+ let readNotices = [];
535
+ try {
536
+ readNotices = extraWarnings?.() ?? [];
537
+ }
538
+ catch {
539
+ readNotices = [];
540
+ }
541
+ const allWarnings = [...warnings, ...readNotices];
542
+ // Extra additive meta the handler resolved during the read (e.g. the active
543
+ // `area` filter) — evaluated AFTER fn ran, so a value it populated is seen.
544
+ const extra = extraMeta?.() ?? {};
385
545
  const meta = {
386
- ...(warnings.length > 0 && { warnings }),
546
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
387
547
  ...(clock !== undefined && { clock }),
548
+ ...extra,
388
549
  };
389
550
  if (Object.keys(meta).length === 0)
390
551
  return result;
@@ -422,6 +583,10 @@ export function createThingsMcpServer(options = {}) {
422
583
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
423
584
  ...tagFilterShape,
424
585
  ...tzShape,
586
+ area: z
587
+ .string()
588
+ .optional()
589
+ .describe(`today/anytime/someday/upcoming/logbook only: ${AREA_FILTER_DESC}`),
425
590
  evening: z.boolean().optional().describe("today only: show only the This Evening section"),
426
591
  show_active_project_items: z
427
592
  .union([z.boolean(), z.number().int().min(1)])
@@ -462,111 +627,141 @@ export function createThingsMcpServer(options = {}) {
462
627
  .optional()
463
628
  .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
464
629
  ALL_WINS_NOTE),
630
+ full: z.boolean().optional().describe(FULL_DESC),
465
631
  },
466
632
  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);
633
+ }, async (args) => {
634
+ // The active `area` scope, resolved during the read and surfaced as the
635
+ // additive `meta.filter` (the readGuard extra-meta thunk reads it after fn).
636
+ let filterMeta;
637
+ const full = args.full === true;
638
+ return readGuard(() => {
639
+ const badZone = badTz(args.tz);
640
+ if (badZone !== null)
641
+ return badZone;
642
+ // Tag-conflict AND overdue-applicability both derive from the shared
643
+ // contract: read_view honors overdue only on today/inbox/anytime/someday
644
+ // (the current-work views), matching FILTER_CONTRACT.
645
+ const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
646
+ untaggedConflict: MCP_UNTAGGED_CONFLICT,
647
+ overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
648
+ overdueStatusWiden: "",
649
+ });
650
+ if (!validated.ok)
651
+ return usage(validated.message);
652
+ // show_active_project_items is the preferred name; active_project_items
653
+ // stays accepted as a compatibility alias.
654
+ const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
655
+ const isGrouped = args.view === "anytime" || args.view === "someday";
656
+ if (isGrouped && args.limit !== undefined) {
657
+ return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
658
+ `${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
520
659
  }
521
- case "inbox": {
522
- const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
523
- return truncatedResult(items, truncation);
660
+ if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
661
+ return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
524
662
  }
525
- case "anytime": {
526
- const { view, grouped } = c.read.anytime({
527
- ...filter,
528
- ...zone,
529
- areaLimit,
530
- projectLimit,
531
- });
532
- return groupedResult(view, grouped);
663
+ if (args.view !== "someday" && showActiveProjectItems !== undefined) {
664
+ return usage("show_active_project_items applies only to someday");
533
665
  }
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);
666
+ if (args.view !== "today" && args.evening === true) {
667
+ return usage(`evening applies only to today, not ${args.view}`);
542
668
  }
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);
669
+ // The `area` filter applies to the area-carrying views only; inbox
670
+ // (area-less captures) and trash have no area to scope by.
671
+ if ((args.view === "inbox" || args.view === "trash") && args.area !== undefined) {
672
+ return usage(`area applies to today/anytime/someday/upcoming/logbook, not ${args.view}`);
559
673
  }
560
- case "logbook": {
561
- const { items, truncation } = c.read.logbook({ ...filter, ...zone, limit });
562
- return truncatedResult(items, truncation);
674
+ if (args.view === "someday" && args.project_limit !== undefined) {
675
+ return usage("project_limit does not apply to someday pass a number as show_active_project_items " +
676
+ "to cap that section's project lists");
563
677
  }
564
- case "trash": {
565
- const { items, truncation } = c.read.trash({ ...zone, limit });
566
- return truncatedResult(items, truncation);
678
+ const limit = resolveLimit(args);
679
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
680
+ const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
681
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
682
+ return usage("pass at most one of area_limit/project_limit / all");
567
683
  }
568
- }
569
- }, args.tz));
684
+ const c = getClient();
685
+ const filter = validated.filter;
686
+ const zone = args.tz !== undefined ? { zone: args.tz } : {};
687
+ // The `area` scope, threaded into the area-carrying views; the client
688
+ // resolves it, applies the post-filter, and hands back the target for
689
+ // the `meta.filter` annotation (captured into `filterMeta`).
690
+ const area = args.area !== undefined ? { area: args.area } : {};
691
+ switch (args.view) {
692
+ case "today": {
693
+ const { view, truncation, filter: fm, } = c.read.today({
694
+ ...filter,
695
+ ...zone,
696
+ ...area,
697
+ ...(args.evening === true && { eveningOnly: true }),
698
+ limit,
699
+ });
700
+ filterMeta = fm;
701
+ return truncatedResult(shapeReadPayload("today", view, full), truncation);
702
+ }
703
+ case "inbox": {
704
+ const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
705
+ return truncatedResult(shapeReadPayload("inbox", items, full), truncation);
706
+ }
707
+ case "anytime": {
708
+ const { view, truncation, filter: fm, } = c.read.anytime({
709
+ ...filter,
710
+ ...zone,
711
+ ...area,
712
+ areaLimit,
713
+ projectLimit,
714
+ });
715
+ filterMeta = fm;
716
+ return groupedResult(shapeReadPayload("anytime", view, full), truncation);
717
+ }
718
+ case "upcoming": {
719
+ const { items, truncation, filter: fm, } = c.read.upcoming({
720
+ ...filter,
721
+ ...zone,
722
+ ...area,
723
+ ...(args.horizon !== undefined && { horizon: args.horizon }),
724
+ limit,
725
+ });
726
+ filterMeta = fm;
727
+ return truncatedResult(shapeReadPayload("upcoming", items, full), truncation);
728
+ }
729
+ case "someday": {
730
+ const active = showActiveProjectItems;
731
+ if (typeof active === "number" && args.all === true) {
732
+ return usage("pass at most one of a numeric show_active_project_items / all");
733
+ }
734
+ const { view, truncation, filter: fm, } = c.read.someday({
735
+ ...filter,
736
+ ...zone,
737
+ ...area,
738
+ ...((active === true || typeof active === "number") && {
739
+ activeProjectItems: true,
740
+ }),
741
+ areaLimit,
742
+ // true = every item per project; a number caps each list.
743
+ projectLimit: typeof active === "number" ? active : null,
744
+ });
745
+ filterMeta = fm;
746
+ return groupedResult(shapeReadPayload("someday", view, full), truncation);
747
+ }
748
+ case "logbook": {
749
+ const { items, truncation, filter: fm, } = c.read.logbook({
750
+ ...filter,
751
+ ...zone,
752
+ ...area,
753
+ limit,
754
+ });
755
+ filterMeta = fm;
756
+ return truncatedResult(shapeReadPayload("logbook", items, full), truncation);
757
+ }
758
+ case "trash": {
759
+ const { items, truncation } = c.read.trash({ ...zone, limit });
760
+ return truncatedResult(shapeReadPayload("trash", items, full), truncation);
761
+ }
762
+ }
763
+ }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
764
+ });
570
765
  server.registerTool("search", {
571
766
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
572
767
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
@@ -583,7 +778,7 @@ export function createThingsMcpServer(options = {}) {
583
778
  area: z
584
779
  .string()
585
780
  .optional()
586
- .describe(`Restrict to one area's direct members (${REF_FORMAT})`),
781
+ .describe(`Restrict to one area's subtree — its direct items plus its projects' children (${REF_FORMAT}, or \`loose\` for area-less)`),
587
782
  type: z.enum(["to-do", "project"]).optional(),
588
783
  logged: z.boolean().optional().describe("Also include completed/canceled items"),
589
784
  trashed: z.boolean().optional().describe("Also include trashed items"),
@@ -592,6 +787,7 @@ export function createThingsMcpServer(options = {}) {
592
787
  .optional()
593
788
  .describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
594
789
  limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
790
+ full: z.boolean().optional().describe(FULL_DESC),
595
791
  },
596
792
  annotations: READ_ONLY,
597
793
  }, async (args) => readGuard(() => {
@@ -625,8 +821,8 @@ export function createThingsMcpServer(options = {}) {
625
821
  ...(args.trashed === true && { trashed: true }),
626
822
  ...(args.all === true && { all: true }),
627
823
  });
628
- return truncatedResult(items, truncation);
629
- }, args.tz));
824
+ return truncatedResult(shapeReadPayload("search", items, args.full === true), truncation);
825
+ }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
630
826
  server.registerTool("changes_since", {
631
827
  description: "List items created or modified since a moment — including trashed, logged, and " +
632
828
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
@@ -636,6 +832,7 @@ export function createThingsMcpServer(options = {}) {
636
832
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
637
833
  ...limitShape,
638
834
  ...tzShape,
835
+ full: z.boolean().optional().describe(FULL_DESC),
639
836
  },
640
837
  annotations: READ_ONLY,
641
838
  }, async (args) => readGuard(() => {
@@ -652,7 +849,7 @@ export function createThingsMcpServer(options = {}) {
652
849
  limit,
653
850
  ...(args.tz !== undefined && { zone: args.tz }),
654
851
  });
655
- return truncatedResult(items, truncation);
852
+ return truncatedResult(shapeReadPayload("changes", items, args.full === true), truncation);
656
853
  }, args.tz));
657
854
  server.registerTool("get_item", {
658
855
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
@@ -665,7 +862,7 @@ export function createThingsMcpServer(options = {}) {
665
862
  const item = getClient().read.byUuid(args.uuid);
666
863
  return item === null
667
864
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
668
- : readResult(item);
865
+ : readResult(shapeReadPayload("detail", item, false));
669
866
  }));
670
867
  server.registerTool("get_project", {
671
868
  description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
@@ -680,6 +877,7 @@ export function createThingsMcpServer(options = {}) {
680
877
  .boolean()
681
878
  .optional()
682
879
  .describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
880
+ full: z.boolean().optional().describe(FULL_DESC),
683
881
  },
684
882
  annotations: READ_ONLY,
685
883
  }, async (args) => readGuard(() => {
@@ -688,15 +886,17 @@ export function createThingsMcpServer(options = {}) {
688
886
  return badZone;
689
887
  if (tagFlagConflict(tagPresence(args)))
690
888
  return usage(MCP_UNTAGGED_CONFLICT);
691
- return readResult(getClient().read.projectView(args.uuid, {
889
+ return readResult(shapeReadPayload("project-view", getClient().read.projectView(args.uuid, {
692
890
  overdue: args.overdue === true,
693
891
  ...tagFilterFields(tagPresence(args)),
694
892
  ...(args.tz !== undefined && { zone: args.tz }),
695
- }));
893
+ }), args.full === true));
696
894
  }, args.tz));
697
895
  server.registerTool("get_area", {
698
896
  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. " +
897
+ "projects in canonical order, and later (scheduled/repeating/someday). The area " +
898
+ "logbook is not returned here — read it with read_view logbook + area; trashed " +
899
+ "rows live in read_view trash. " +
700
900
  `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
701
901
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
702
902
  "second result block reports the counts. " +
@@ -722,28 +922,36 @@ export function createThingsMcpServer(options = {}) {
722
922
  .optional()
723
923
  .describe("Keep only rows (loose to-dos AND child projects) whose own deadline is past (due today is not overdue); no descent into project contents"),
724
924
  all: z.boolean().optional().describe("return both sections in full (no caps)"),
925
+ full: z.boolean().optional().describe(FULL_DESC),
725
926
  },
726
927
  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));
928
+ }, async (args) => {
929
+ // The `loose` pseudo-area shadow disclosure the area-view read resolves —
930
+ // captured after fn runs and folded into meta.warnings (parity with the
931
+ // CLI's `area show loose` notice).
932
+ let areaNotice;
933
+ return readGuard(() => {
934
+ const badZone = badTz(args.tz);
935
+ if (badZone !== null)
936
+ return badZone;
937
+ if (tagFlagConflict(tagPresence(args)))
938
+ return usage(MCP_UNTAGGED_CONFLICT);
939
+ const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
940
+ const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
941
+ if (areaLimit === "conflict" || projectLimit === "conflict") {
942
+ return usage("pass at most one of area_limit/project_limit / all");
943
+ }
944
+ const { view, truncation, notice } = getClient().read.areaView(args.ref, {
945
+ overdue: args.overdue === true,
946
+ ...tagFilterFields(tagPresence(args)),
947
+ ...(args.tz !== undefined && { zone: args.tz }),
948
+ areaLimit,
949
+ projectLimit,
950
+ });
951
+ areaNotice = notice;
952
+ return groupedResult(shapeReadPayload("area-view", view, args.full === true), truncation);
953
+ }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
954
+ });
747
955
  server.registerTool("list_collections", {
748
956
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
749
957
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
@@ -757,6 +965,7 @@ export function createThingsMcpServer(options = {}) {
757
965
  .boolean()
758
966
  .optional()
759
967
  .describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
968
+ full: z.boolean().optional().describe(`projects only: ${FULL_DESC}`),
760
969
  },
761
970
  annotations: READ_ONLY,
762
971
  }, async (args) => readGuard(() => {
@@ -784,7 +993,7 @@ export function createThingsMcpServer(options = {}) {
784
993
  }
785
994
  if (tagFlagConflict(tagPresence(args)))
786
995
  return usage(MCP_UNTAGGED_CONFLICT);
787
- return readResult(args.kind === "projects"
996
+ return readResult(shapeReadPayload(args.kind, args.kind === "projects"
788
997
  ? c.read.projects({
789
998
  overdue: args.overdue === true,
790
999
  ...tagFilterFields(tagPresence(args)),
@@ -792,7 +1001,7 @@ export function createThingsMcpServer(options = {}) {
792
1001
  })
793
1002
  : args.kind === "areas"
794
1003
  ? c.read.areas()
795
- : c.read.tags());
1004
+ : c.read.tags(), args.full === true));
796
1005
  }, args.tz));
797
1006
  // ---------------------------------------------------------------- to-dos
798
1007
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
@@ -820,6 +1029,7 @@ export function createThingsMcpServer(options = {}) {
820
1029
  ...createTagsShape,
821
1030
  ...tzShape,
822
1031
  ...dryRunShape,
1032
+ ...opIdShape,
823
1033
  },
824
1034
  annotations: NON_DESTRUCTIVE,
825
1035
  }, async (args) => guard(async () => {
@@ -881,6 +1091,7 @@ export function createThingsMcpServer(options = {}) {
881
1091
  ...createTagsShape,
882
1092
  ...tzShape,
883
1093
  ...dryRunShape,
1094
+ ...opIdShape,
884
1095
  },
885
1096
  annotations: NON_DESTRUCTIVE,
886
1097
  }, async (args) => guard(async () => {
@@ -983,6 +1194,7 @@ export function createThingsMcpServer(options = {}) {
983
1194
  .optional()
984
1195
  .describe("scope project, open only: also reopen the to-dos resolved with the project"),
985
1196
  ...dryRunShape,
1197
+ ...opIdShape,
986
1198
  },
987
1199
  annotations: NON_DESTRUCTIVE,
988
1200
  }, async (args) => guard(async () => {
@@ -1017,6 +1229,13 @@ export function createThingsMcpServer(options = {}) {
1017
1229
  if (args.children !== undefined) {
1018
1230
  return usage("children applies only to status 'completed' or 'canceled'");
1019
1231
  }
1232
+ // Reopening a project is a multi-leg compound — single-op idempotency
1233
+ // (op_id) does not apply to it in phase 1 (every other set_status path
1234
+ // is a single mutation). Refuse rather than silently drop the key.
1235
+ if (args.op_id !== undefined) {
1236
+ return usage("op_id is not available when reopening a project (a multi-leg compound in phase 1) — " +
1237
+ "use the batch tool with a per-line op_id");
1238
+ }
1020
1239
  const outcome = await c.write.reopenProject(args.uuid, {
1021
1240
  ...opts,
1022
1241
  ...(args.restore_children === true && { restoreChildren: true }),
@@ -1026,21 +1245,37 @@ export function createThingsMcpServer(options = {}) {
1026
1245
  : mutationResult(outcome.project);
1027
1246
  }));
1028
1247
  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.",
1248
+ description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
1249
+ "belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
1250
+ "without changing membership, call this with a position (first/last/before/after) and " +
1251
+ "NO destination that is an in-place reorder, anchored at the earliest movee's slot, " +
1252
+ "and unmentioned siblings keep their order. The uuids order is the order they land " +
1253
+ "(name them backwards to reverse). Pass at most one destination: to_project, to_heading " +
1254
+ "(within to_project, or the movees' shared project), to_area, no_heading (leave the " +
1255
+ "heading, stay in the project), loose (leave heading, project, AND area), or to_inbox. " +
1256
+ "An anchor (before/after) positions but never migrates — an anchor-only move that would " +
1257
+ "cross containers is refused, as is a --before/--after whose movees span buckets. " +
1258
+ "Membership always succeeds; top-of-bucket placement is guaranteed only where a reorder " +
1259
+ "protocol exists (the result's placementClass states which). Moving into a " +
1260
+ "completed/canceled project reopens it — pass acknowledge_project_reopen.",
1034
1261
  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
1262
+ uuids: z.array(z.string()).describe("The to-dos to move, in the order they should land"),
1263
+ to_project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
1264
+ to_heading: z
1265
+ .string()
1266
+ .optional()
1267
+ .describe("Destination heading (exact title or uuid; within to_project or the shared project)"),
1268
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1269
+ no_heading: z
1041
1270
  .boolean()
1042
1271
  .optional()
1043
- .describe("Remove the project/area/heading assignment, keeping the schedule"),
1272
+ .describe("Leave the heading but stay in the current project (unheaded block)"),
1273
+ loose: z
1274
+ .boolean()
1275
+ .optional()
1276
+ .describe("Detach from heading, project, AND area, keeping the schedule"),
1277
+ to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
1278
+ ...positionShape,
1044
1279
  acknowledge_project_reopen: z
1045
1280
  .boolean()
1046
1281
  .optional()
@@ -1049,21 +1284,37 @@ export function createThingsMcpServer(options = {}) {
1049
1284
  },
1050
1285
  annotations: NON_DESTRUCTIVE,
1051
1286
  }, 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");
1287
+ const dests = [];
1288
+ if (args.to_heading !== undefined) {
1289
+ dests.push({
1290
+ kind: "heading",
1291
+ sel: args.to_heading,
1292
+ ...(args.to_project !== undefined && { project: containerRef(args.to_project) }),
1293
+ });
1056
1294
  }
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
- }));
1295
+ else if (args.to_project !== undefined) {
1296
+ dests.push({ kind: "project", ref: containerRef(args.to_project) });
1297
+ }
1298
+ if (args.to_area !== undefined)
1299
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1300
+ if (args.no_heading === true)
1301
+ dests.push({ kind: "no-heading" });
1302
+ if (args.loose === true)
1303
+ dests.push({ kind: "loose" });
1304
+ if (args.to_inbox === true)
1305
+ dests.push({ kind: "inbox" });
1306
+ if (dests.length > 1) {
1307
+ return usage("pass at most one destination (to_project/to_heading/to_area/no_heading/loose/to_inbox)");
1308
+ }
1309
+ const position = movePositionArgs(args);
1310
+ if (position === "conflict")
1311
+ return usage("pass at most one of first/last/before/after");
1312
+ const request = {
1313
+ uuids: args.uuids,
1314
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1315
+ ...(position !== undefined && { position }),
1316
+ };
1317
+ return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
1067
1318
  }));
1068
1319
  server.registerTool("set_tags", {
1069
1320
  description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
@@ -1075,6 +1326,7 @@ export function createThingsMcpServer(options = {}) {
1075
1326
  mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
1076
1327
  ...createTagsShape,
1077
1328
  ...dryRunShape,
1329
+ ...opIdShape,
1078
1330
  },
1079
1331
  annotations: NON_DESTRUCTIVE,
1080
1332
  }, async (args) => guard(async () => {
@@ -1184,8 +1436,9 @@ export function createThingsMcpServer(options = {}) {
1184
1436
  "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1185
1437
  "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1186
1438
  "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 " +
1439
+ "be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
1440
+ "projects to the Trash, so a non-empty area is refused unless you pass " +
1441
+ "allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
1189
1442
  "dangerously_permanent; the tag is removed from every item, and if it has nested child " +
1190
1443
  "tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
1191
1444
  inputSchema: {
@@ -1197,11 +1450,16 @@ export function createThingsMcpServer(options = {}) {
1197
1450
  .boolean()
1198
1451
  .optional()
1199
1452
  .describe("kind area/tag: confirm permanent, unrecoverable deletion"),
1453
+ allow_non_empty_area: z
1454
+ .boolean()
1455
+ .optional()
1456
+ .describe("kind area: delete a non-empty area together with its contents (its to-dos and projects move to the Trash)"),
1200
1457
  acknowledge_tag_subtree: z
1201
1458
  .boolean()
1202
1459
  .optional()
1203
1460
  .describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
1204
1461
  ...dryRunShape,
1462
+ ...opIdShape,
1205
1463
  },
1206
1464
  annotations: DESTRUCTIVE,
1207
1465
  }, async (args) => guard(async () => {
@@ -1222,7 +1480,7 @@ export function createThingsMcpServer(options = {}) {
1222
1480
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
1223
1481
  "previous schedule or project/area. A project is restored in place: its schedule, " +
1224
1482
  "area, and children come back exactly as they were.",
1225
- inputSchema: { uuid: z.string(), ...dryRunShape },
1483
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
1226
1484
  annotations: NON_DESTRUCTIVE,
1227
1485
  }, async (args) => guard(async () => {
1228
1486
  const c = getClient();
@@ -1239,6 +1497,7 @@ export function createThingsMcpServer(options = {}) {
1239
1497
  completion_date: z.string().optional().describe(DATE_FORMAT),
1240
1498
  creation_date: z.string().optional().describe(DATE_FORMAT),
1241
1499
  ...dryRunShape,
1500
+ ...opIdShape,
1242
1501
  },
1243
1502
  annotations: NON_DESTRUCTIVE,
1244
1503
  }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
@@ -1255,6 +1514,7 @@ export function createThingsMcpServer(options = {}) {
1255
1514
  creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
1256
1515
  notes: z.string().optional(),
1257
1516
  ...dryRunShape,
1517
+ ...opIdShape,
1258
1518
  },
1259
1519
  annotations: NON_DESTRUCTIVE,
1260
1520
  }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
@@ -1264,33 +1524,76 @@ export function createThingsMcpServer(options = {}) {
1264
1524
  ...(args.notes !== undefined && { notes: args.notes }),
1265
1525
  }, writeOptions(args)))));
1266
1526
  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.",
1527
+ description: "Manage a project's headings — action selects which; project is always required, and a " +
1528
+ "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
1529
+ "new heading in the project (project + title; returns its uuid; uses the Things proxy " +
1530
+ "shortcuts, set up once with `things setup shortcuts`); a placement flag positions it, " +
1531
+ "else it appends. rename_heading: rename in place (project + heading + title; works on " +
1532
+ "archived headings). archive_heading: retire a heading so it leaves the active project " +
1533
+ "view (reversible with unarchive_heading); with open children pass children complete " +
1534
+ "or cancel resolve them with the heading in one cascade, reparent moves them to the " +
1535
+ "project root keeping them open. unarchive_heading: bring an archived heading back; " +
1536
+ "restore_children also reopens the children the archive resolved with it. " +
1537
+ "promote_heading: promote a heading into a new project — this REPLACES the heading and " +
1538
+ "cannot be undone (its to-dos move under the new project), and requires " +
1539
+ "dangerously_drive_gui. move_heading: reposition headings as an ordered block (children " +
1540
+ "follow); pass exactly one placement flag. Reordering headings needs allow-experimental. " +
1541
+ "move_heading_to_project: relocate ONE heading (with its to-dos) to a DIFFERENT project " +
1542
+ "(project + heading + to_project) — the cross-project move, distinct from move_heading's " +
1543
+ "within-project reorder; GUI-only (requires dangerously_drive_gui), fails closed on a " +
1544
+ "source-heading or destination-project title collision, and has no undo (move it back). " +
1545
+ "dissolve_heading: remove a heading but KEEP its to-dos as direct project children (NOT " +
1546
+ "trashed — the opposite of a delete cascade); GUI-only (requires dangerously_drive_gui), " +
1547
+ "fails closed on a title collision, no undo.",
1278
1548
  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
1549
+ action: z.enum([
1550
+ "add_heading",
1551
+ "rename_heading",
1552
+ "archive_heading",
1553
+ "unarchive_heading",
1554
+ "promote_heading",
1555
+ "move_heading",
1556
+ "move_heading_to_project",
1557
+ "dissolve_heading",
1558
+ ]),
1559
+ project: z.string().describe(`the heading's project (${REF_FORMAT})`),
1560
+ heading: z
1561
+ .string()
1562
+ .optional()
1563
+ .describe("rename/archive/unarchive/promote: the heading selector (exact title or uuid)"),
1564
+ headings: z
1565
+ .array(z.string())
1566
+ .optional()
1567
+ .describe("move_heading: heading selectors in the order they should land"),
1568
+ to_project: z
1569
+ .string()
1570
+ .optional()
1571
+ .describe(`move_heading_to_project: the destination project (${REF_FORMAT})`),
1572
+ title: z
1282
1573
  .string()
1283
1574
  .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"),
1575
+ .describe("add_heading: the new heading; rename_heading: the new title"),
1286
1576
  children: z
1287
1577
  .enum(["complete", "cancel", "reparent"])
1288
1578
  .optional()
1289
- .describe("archive: required when the heading has open children"),
1579
+ .describe("archive_heading: required when the heading has open children"),
1290
1580
  restore_children: z
1291
1581
  .boolean()
1292
1582
  .optional()
1293
- .describe("unarchive: also reopen the children archived with the heading"),
1583
+ .describe("unarchive_heading: also reopen the children archived with the heading"),
1584
+ first: z
1585
+ .boolean()
1586
+ .optional()
1587
+ .describe("add/move: place first among the project's headings"),
1588
+ last: z.boolean().optional().describe("add/move: place last among the project's headings"),
1589
+ before_heading: z
1590
+ .string()
1591
+ .optional()
1592
+ .describe("add/move: place immediately before this heading (title or uuid)"),
1593
+ after_heading: z
1594
+ .string()
1595
+ .optional()
1596
+ .describe("add/move: place immediately after this heading (title or uuid)"),
1294
1597
  ...driveGuiShape,
1295
1598
  ...dryRunShape,
1296
1599
  },
@@ -1298,45 +1601,105 @@ export function createThingsMcpServer(options = {}) {
1298
1601
  }, async (args) => guard(async () => {
1299
1602
  const c = getClient();
1300
1603
  const opts = writeOptions(args);
1604
+ const proj = c.resolve.project(args.project);
1605
+ const placementCount = [
1606
+ args.first === true,
1607
+ args.last === true,
1608
+ args.before_heading !== undefined,
1609
+ args.after_heading !== undefined,
1610
+ ].filter(Boolean).length;
1611
+ const placement = () => {
1612
+ if (args.first === true)
1613
+ return { position: "first" };
1614
+ if (args.last === true)
1615
+ return { position: "last" };
1616
+ if (args.before_heading !== undefined) {
1617
+ return { before: c.resolve.heading(proj.uuid, args.before_heading).uuid };
1618
+ }
1619
+ if (args.after_heading !== undefined) {
1620
+ return { after: c.resolve.heading(proj.uuid, args.after_heading).uuid };
1621
+ }
1622
+ return undefined;
1623
+ };
1301
1624
  switch (args.action) {
1302
- case "create":
1303
- if (args.project === undefined || args.title === undefined) {
1304
- return usage('action "create" requires project and title');
1625
+ case "add_heading": {
1626
+ if (args.title === undefined)
1627
+ return usage('action "add_heading" requires title');
1628
+ if (placementCount > 1) {
1629
+ return usage("pass at most one of first/last/before_heading/after_heading");
1305
1630
  }
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');
1631
+ return mutationResult(await c.write.addHeading({ uuid: proj.uuid }, args.title, placement(), opts));
1632
+ }
1633
+ case "rename_heading": {
1634
+ if (args.heading === undefined || args.title === undefined) {
1635
+ return usage('action "rename_heading" requires heading and title');
1310
1636
  }
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);
1637
+ const h = c.resolve.heading(proj.uuid, args.heading);
1638
+ return mutationResult(await c.write.renameHeading(h.uuid, args.title, opts));
1639
+ }
1640
+ case "archive_heading": {
1641
+ if (args.heading === undefined)
1642
+ return usage('action "archive_heading" requires heading');
1643
+ const h = c.resolve.heading(proj.uuid, args.heading);
1644
+ const r = await c.write.archiveHeading(h.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
1316
1645
  return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1317
1646
  ? jsonResult(r)
1318
1647
  : mutationResult(r.heading);
1319
1648
  }
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);
1649
+ case "unarchive_heading": {
1650
+ if (args.heading === undefined) {
1651
+ return usage('action "unarchive_heading" requires heading');
1652
+ }
1653
+ const h = c.resolve.heading(proj.uuid, args.heading);
1654
+ const r = await c.write.unarchiveHeading(h.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
1324
1655
  return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1325
1656
  ? jsonResult(r)
1326
1657
  : mutationResult(r.heading);
1327
1658
  }
1328
- case "convert_to_project": {
1329
- if (args.uuid === undefined) {
1330
- return usage('action "convert_to_project" requires uuid');
1659
+ case "promote_heading": {
1660
+ if (args.heading === undefined)
1661
+ return usage('action "promote_heading" requires heading');
1662
+ const h = c.resolve.heading(proj.uuid, args.heading);
1663
+ return mutationResult(await c.write.run("project.promote-heading", { uuid: h.uuid }, opts));
1664
+ }
1665
+ case "move_heading": {
1666
+ if (args.headings === undefined || args.headings.length === 0) {
1667
+ return usage('action "move_heading" requires headings');
1668
+ }
1669
+ if (placementCount !== 1) {
1670
+ return usage("move_heading requires exactly one of first/last/before_heading/after_heading");
1671
+ }
1672
+ const headings = args.headings.map((s) => c.resolve.heading(proj.uuid, s).uuid);
1673
+ return mutationResult(await c.write.moveHeading({ uuid: proj.uuid }, headings, placement(), opts));
1674
+ }
1675
+ case "move_heading_to_project": {
1676
+ if (args.heading === undefined || args.to_project === undefined) {
1677
+ return usage('action "move_heading_to_project" requires heading and to_project');
1331
1678
  }
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));
1679
+ const dest = c.resolve.project(args.to_project);
1680
+ return mutationResult(await c.write.moveHeadingToProject({ uuid: proj.uuid }, args.heading, { uuid: dest.uuid }, opts));
1681
+ }
1682
+ case "dissolve_heading": {
1683
+ if (args.heading === undefined)
1684
+ return usage('action "dissolve_heading" requires heading');
1685
+ const h = c.resolve.heading(proj.uuid, args.heading);
1686
+ return mutationResult(await c.write.dissolveHeading(h.uuid, opts));
1337
1687
  }
1338
1688
  }
1339
1689
  }));
1690
+ server.registerTool("convert_to_project", {
1691
+ description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
1692
+ "are kept); the to-do's identity is gone and it cannot be undone. Requires " +
1693
+ "dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
1694
+ "use the heading tool's promote_heading action.)",
1695
+ inputSchema: {
1696
+ uuid: z.string().describe("the to-do's uuid"),
1697
+ ...driveGuiShape,
1698
+ ...dryRunShape,
1699
+ ...opIdShape,
1700
+ },
1701
+ annotations: DESTRUCTIVE,
1702
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
1340
1703
  server.registerTool("clear_reminder", {
1341
1704
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1342
1705
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
@@ -1355,7 +1718,7 @@ export function createThingsMcpServer(options = {}) {
1355
1718
  "friday",
1356
1719
  "saturday",
1357
1720
  ]);
1358
- // The base rule (also used by create_repeating_project, which stays minimal —
1721
+ // The base rule (also used by the repeat tool's create action, which stays minimal —
1359
1722
  // its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
1360
1723
  const baseRepeatShape = {
1361
1724
  frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
@@ -1432,39 +1795,36 @@ export function createThingsMcpServer(options = {}) {
1432
1795
  "visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
1433
1796
  "change a repeating item's rule in place, keeping the same item (undoable — it restores " +
1434
1797
  "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 " +
1798
+ "the rule. action add (scope project only): create a project and make it repeating in " +
1436
1799
  "one call — the project is created first and PERSISTS even if the make-repeating step " +
1437
1800
  "refuses; give an area to place it or omit it to create in Someday (only frequency and " +
1438
1801
  "interval are supported); returns the new project's uuid.",
1439
1802
  inputSchema: {
1440
1803
  scope: z.enum(["todo", "project"]),
1441
- action: z.enum(["start", "reschedule", "pause", "resume", "create"]),
1804
+ action: z.enum(["start", "reschedule", "pause", "resume", "add"]),
1442
1805
  uuid: z
1443
1806
  .string()
1444
1807
  .optional()
1445
1808
  .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})`),
1809
+ title: z.string().optional().describe("add (project): the new project's title"),
1810
+ notes: z.string().optional().describe("add (project): notes"),
1811
+ area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
1449
1812
  project_deadline: z
1450
1813
  .string()
1451
1814
  .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"),
1815
+ .describe(`add (project): the project's due date — ${DATE_FORMAT}`),
1816
+ todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
1457
1817
  frequency: z
1458
1818
  .enum(["daily", "weekly", "monthly", "yearly"])
1459
1819
  .optional()
1460
- .describe("start/reschedule/create: how often it repeats"),
1820
+ .describe("start/reschedule/add: how often it repeats"),
1461
1821
  interval: z
1462
1822
  .number()
1463
1823
  .int()
1464
1824
  .min(1)
1465
1825
  .max(99)
1466
1826
  .optional()
1467
- .describe("start/reschedule/create: every N units (1–99)"),
1827
+ .describe("start/reschedule/add: every N units (1–99)"),
1468
1828
  after_completion: repeatRuleShape.after_completion,
1469
1829
  weekdays: repeatRuleShape.weekdays,
1470
1830
  monthly_day: repeatRuleShape.monthly_day,
@@ -1484,15 +1844,15 @@ export function createThingsMcpServer(options = {}) {
1484
1844
  const c = getClient();
1485
1845
  const opts = writeOptions(args);
1486
1846
  const { frequency, interval } = args;
1487
- if (args.action === "create") {
1847
+ if (args.action === "add") {
1488
1848
  if (args.scope !== "project")
1489
- return usage('action "create" requires scope "project"');
1849
+ return usage('action "add" requires scope "project"');
1490
1850
  if (args.title === undefined)
1491
- return usage('action "create" requires title');
1851
+ return usage('action "add" requires title');
1492
1852
  if (frequency === undefined || interval === undefined) {
1493
- return usage('action "create" requires frequency and interval');
1853
+ return usage('action "add" requires frequency and interval');
1494
1854
  }
1495
- return mutationResult(await c.write.createRepeatingProject({
1855
+ return mutationResult(await c.write.addRepeatingProject({
1496
1856
  title: args.title,
1497
1857
  ...(args.notes !== undefined && { notes: args.notes }),
1498
1858
  ...(args.area !== undefined && { area: containerRef(args.area) }),
@@ -1533,7 +1893,7 @@ export function createThingsMcpServer(options = {}) {
1533
1893
  server.registerTool("duplicate_item", {
1534
1894
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
1535
1895
  "includes its children. Not available for repeating items.",
1536
- inputSchema: { uuid: z.string(), ...dryRunShape },
1896
+ inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
1537
1897
  annotations: NON_DESTRUCTIVE,
1538
1898
  }, async (args) => guard(async () => {
1539
1899
  const c = getClient();
@@ -1554,6 +1914,7 @@ export function createThingsMcpServer(options = {}) {
1554
1914
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1555
1915
  ...tzShape,
1556
1916
  ...dryRunShape,
1917
+ ...opIdShape,
1557
1918
  },
1558
1919
  annotations: NON_DESTRUCTIVE,
1559
1920
  }, async (args) => guard(async () => {
@@ -1570,23 +1931,37 @@ export function createThingsMcpServer(options = {}) {
1570
1931
  }, writeOptions(args)));
1571
1932
  }));
1572
1933
  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.",
1934
+ description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
1935
+ "destination: to_area, or no_area (leave the area a project's complete detach). To " +
1936
+ "REORDER projects among their siblings without changing area, pass a position " +
1937
+ "(first/last/before/after) and NO destination. An anchor positions but never migrates. " +
1938
+ "The project's status and schedule are unaffected; the result's placementClass states " +
1939
+ "whether top-of-bucket placement was guaranteed.",
1575
1940
  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"),
1941
+ uuids: z.array(z.string()).describe(`The projects to move (${REF_FORMAT}), in order`),
1942
+ to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1943
+ no_area: z.boolean().optional().describe("Leave the current area (a project's detach)"),
1944
+ ...positionShape,
1579
1945
  ...dryRunShape,
1580
1946
  },
1581
1947
  annotations: NON_DESTRUCTIVE,
1582
1948
  }, 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)));
1949
+ const dests = [];
1950
+ if (args.to_area !== undefined)
1951
+ dests.push({ kind: "area", ref: containerRef(args.to_area) });
1952
+ if (args.no_area === true)
1953
+ dests.push({ kind: "no-area" });
1954
+ if (dests.length > 1)
1955
+ return usage("pass at most one of to_area / no_area");
1956
+ const position = movePositionArgs(args);
1957
+ if (position === "conflict")
1958
+ return usage("pass at most one of first/last/before/after");
1959
+ const request = {
1960
+ uuids: args.uuids,
1961
+ ...(dests[0] !== undefined && { destination: dests[0] }),
1962
+ ...(position !== undefined && { position }),
1963
+ };
1964
+ return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
1590
1965
  }));
1591
1966
  // ----------------------------------------------------------------- areas
1592
1967
  server.registerTool("add_area", {
@@ -1596,6 +1971,7 @@ export function createThingsMcpServer(options = {}) {
1596
1971
  tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
1597
1972
  ...createTagsShape,
1598
1973
  ...dryRunShape,
1974
+ ...opIdShape,
1599
1975
  },
1600
1976
  annotations: NON_DESTRUCTIVE,
1601
1977
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
@@ -1606,6 +1982,7 @@ export function createThingsMcpServer(options = {}) {
1606
1982
  title: z.string(),
1607
1983
  parent: z.string().optional().describe("Existing parent tag name"),
1608
1984
  ...dryRunShape,
1985
+ ...opIdShape,
1609
1986
  },
1610
1987
  annotations: NON_DESTRUCTIVE,
1611
1988
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
@@ -1629,25 +2006,48 @@ export function createThingsMcpServer(options = {}) {
1629
2006
  .boolean()
1630
2007
  .optional()
1631
2008
  .describe("Required for area/tag delete and trash.empty (PERMANENT, no Trash)"),
2009
+ allow_non_empty_area: z
2010
+ .boolean()
2011
+ .optional()
2012
+ .describe("area.delete: delete a non-empty area together with its contents"),
1632
2013
  ...dryRunShape,
2014
+ ...opIdShape,
1633
2015
  },
1634
2016
  annotations: DESTRUCTIVE,
1635
2017
  }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
1636
2018
  server.registerTool("batch", {
1637
2019
  description: "Run several operations in order, each independently — there are no transactions, " +
1638
2020
  "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.",
2021
+ "in order; fail_fast skips the remainder after the first failure. " +
2022
+ "CHAINING: an operation that creates something may carry temp_id (a handle); a LATER " +
2023
+ 'operation references that new uuid as "$handle" in any id/container field (dotted ' +
2024
+ '"$handle.instance"/"$handle.replaced" reach a repeating op\'s spawned instance / ' +
2025
+ "replaced source). A temp_id is valid only on a creating operation (not tag.add — " +
2026
+ "reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
2027
+ "safe — an operation matching an earlier success is reported already-applied, not " +
2028
+ "re-created. The result adds temp_id_mapping (handle → uuid) and undo_token, which " +
2029
+ "reverses the whole batch as one unit via the undo tool.",
1640
2030
  inputSchema: {
1641
2031
  ops: z
1642
2032
  .array(z.object({
1643
2033
  op: z.enum(OPERATION_KINDS),
1644
2034
  params: z.record(z.string(), z.unknown()),
2035
+ temp_id: z
2036
+ .string()
2037
+ .optional()
2038
+ .describe('Handle for this op\'s new uuid, referenced later as "$handle"'),
2039
+ op_id: z
2040
+ .string()
2041
+ .optional()
2042
+ .describe("Idempotency id — a resubmitted op matching an earlier success is skipped"),
1645
2043
  options: z
1646
2044
  .object({
1647
2045
  acknowledge_checklist_reset: z.boolean().optional(),
1648
2046
  acknowledge_project_reopen: z.boolean().optional(),
1649
2047
  dangerously_permanent: z.boolean().optional(),
1650
2048
  acknowledge_tag_subtree: z.boolean().optional(),
2049
+ allow_non_empty_area: z.boolean().optional(),
2050
+ dangerously_drive_gui: z.boolean().optional(),
1651
2051
  })
1652
2052
  .optional(),
1653
2053
  }))
@@ -1668,91 +2068,120 @@ export function createThingsMcpServer(options = {}) {
1668
2068
  ...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
1669
2069
  ...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
1670
2070
  ...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
2071
+ ...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
2072
+ ...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
1671
2073
  ...(ceiling !== undefined && { maxDisruption: ceiling }),
1672
2074
  };
1673
2075
  return {
1674
2076
  op: op.op,
1675
2077
  params: op.params,
2078
+ ...(op.temp_id !== undefined && { tempId: op.temp_id }),
2079
+ ...(op.op_id !== undefined && { opId: op.op_id }),
1676
2080
  ...(Object.keys(opts).length > 0 && { options: opts }),
1677
2081
  };
1678
2082
  });
1679
- const results = await getClient().write.batch(ops, {
2083
+ const batchResult = await getClient().write.batch(ops, {
1680
2084
  ...(args.dry_run === true && { dryRun: true }),
1681
2085
  ...(args.fail_fast === true && { failFast: true }),
1682
2086
  actor: mcpActor(),
1683
2087
  });
1684
- return jsonResult(results);
2088
+ // First block: the per-op results, each FLATTENED to the wire shape (a
2089
+ // string `outcome` with the variant fields hoisted — parity with the CLI
2090
+ // JSONL stream, docs/contract.md). Second block (additive): the batch
2091
+ // chaining/undo summary, present only when the batch minted a token or
2092
+ // bound temp ids.
2093
+ const lines = batchResult.results.map(flattenBatchLine);
2094
+ const hasSummary = batchResult.undoToken !== undefined || Object.keys(batchResult.tempIdMapping).length > 0;
2095
+ if (!hasSummary)
2096
+ return jsonResult(lines);
2097
+ return {
2098
+ content: [
2099
+ { type: "text", text: JSON.stringify(lines) },
2100
+ {
2101
+ type: "text",
2102
+ text: JSON.stringify({
2103
+ tempIdMapping: batchResult.tempIdMapping,
2104
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
2105
+ }),
2106
+ },
2107
+ ],
2108
+ };
1685
2109
  }));
1686
2110
  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.",
2111
+ description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
2112
+ "share this REARRANGES, never changes what an item belongs to (to change membership " +
2113
+ "use move_todo / move_project). The refs order is the resulting order; unmentioned " +
2114
+ "siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
2115
+ "EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
2116
+ "position the block instead. Refs that span different containers or buckets are refused. " +
2117
+ "A Today or This Evening member also holds a slot in its own container, so a set that is " +
2118
+ "coherent on BOTH axes is ambiguous — pass `in` to name the axis (the refusal names both " +
2119
+ "choices). The project rows the Today/Evening/day lists intermix with to-dos may be " +
2120
+ "reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
2121
+ "to-dos, or an area must first be enabled once via `things config set allow-experimental " +
2122
+ "true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
2123
+ "move_heading action; to reorder sidebar AREAS use reorder_areas.",
1700
2124
  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
2125
+ refs: z
1717
2126
  .array(z.string())
2127
+ .describe("The items to rearrange, in the order they should land (may be a subset)"),
2128
+ ...positionShape,
2129
+ in: z
2130
+ .string()
1718
2131
  .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})`),
2132
+ .describe("Disambiguate the axis of a Today/This Evening set: today | evening | anytime | " +
2133
+ "someday | inbox, or a project/area/heading ref (uuid or unique title)"),
2134
+ ...dryRunShape,
2135
+ },
2136
+ annotations: NON_DESTRUCTIVE,
2137
+ }, async (args) => guard(async () => {
2138
+ const position = movePositionArgs(args);
2139
+ if (position === "conflict")
2140
+ return usage("pass at most one of first/last/before/after");
2141
+ const request = {
2142
+ uuids: args.refs,
2143
+ ...(position !== undefined && { position }),
2144
+ ...(args.in !== undefined && { in: args.in }),
2145
+ };
2146
+ return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
2147
+ }));
2148
+ server.registerTool("reorder_areas", {
2149
+ description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
2150
+ "name). Pass exactly one destination: before/after another area, or first/last. This " +
2151
+ "visibly drives the local Things app (the window comes forward and the sidebar may " +
2152
+ "scroll) and must be turned on first with `things config set ui-enabled true`; the " +
2153
+ "area's projects and to-dos are untouched.",
2154
+ inputSchema: {
2155
+ target: z.string().describe(`the area to move (${REF_FORMAT})`),
1722
2156
  before: z
1723
2157
  .string()
1724
2158
  .optional()
1725
- .describe(`scope areas: place it immediately above this area (${REF_FORMAT})`),
2159
+ .describe(`place it immediately above this area (${REF_FORMAT})`),
1726
2160
  after: z
1727
2161
  .string()
1728
2162
  .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"),
2163
+ .describe(`place it immediately below this area (${REF_FORMAT})`),
2164
+ first: z.boolean().optional().describe("move it to the top of the area list"),
2165
+ last: z.boolean().optional().describe("move it to the bottom of the area list"),
1734
2166
  ...driveGuiShape,
1735
2167
  ...dryRunShape,
1736
2168
  },
1737
2169
  annotations: NON_DESTRUCTIVE,
1738
2170
  }, 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 }),
2171
+ const chosen = [
2172
+ args.before !== undefined,
2173
+ args.after !== undefined,
2174
+ args.first === true,
2175
+ args.last === true,
2176
+ ].filter(Boolean).length;
2177
+ if (chosen !== 1)
2178
+ return usage("pass exactly one of before / after / first / last");
2179
+ return mutationResult(await getClient().write.run("area.reorder", {
2180
+ target: args.target,
2181
+ ...(args.before !== undefined && { before: args.before }),
2182
+ ...(args.after !== undefined && { after: args.after }),
2183
+ ...(args.first === true && { position: "first" }),
2184
+ ...(args.last === true && { position: "last" }),
1756
2185
  }, writeOptions(args)));
1757
2186
  }));
1758
2187
  server.registerTool("undo", {