things-api 0.10.0 → 0.11.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 (151) hide show
  1. package/README.md +38 -12
  2. package/dist/cli/clock.d.ts +8 -0
  3. package/dist/cli/clock.js +24 -0
  4. package/dist/cli/clock.js.map +1 -0
  5. package/dist/cli/commands/area.js +8 -9
  6. package/dist/cli/commands/area.js.map +1 -1
  7. package/dist/cli/commands/install-skill.d.ts +45 -0
  8. package/dist/cli/commands/install-skill.js +207 -0
  9. package/dist/cli/commands/install-skill.js.map +1 -0
  10. package/dist/cli/commands/project.js +7 -6
  11. package/dist/cli/commands/project.js.map +1 -1
  12. package/dist/cli/commands/reads.d.ts +15 -2
  13. package/dist/cli/commands/reads.js +45 -17
  14. package/dist/cli/commands/reads.js.map +1 -1
  15. package/dist/cli/commands/setup.js +7 -2
  16. package/dist/cli/commands/setup.js.map +1 -1
  17. package/dist/cli/commands/show.js +6 -6
  18. package/dist/cli/commands/show.js.map +1 -1
  19. package/dist/cli/commands/todo.js +14 -8
  20. package/dist/cli/commands/todo.js.map +1 -1
  21. package/dist/cli/commands/writes.js +29 -32
  22. package/dist/cli/commands/writes.js.map +1 -1
  23. package/dist/cli/glyphs.d.ts +16 -0
  24. package/dist/cli/glyphs.js +23 -0
  25. package/dist/cli/glyphs.js.map +1 -1
  26. package/dist/cli/help.js +56 -6
  27. package/dist/cli/help.js.map +1 -1
  28. package/dist/cli/main.js +33 -2
  29. package/dist/cli/main.js.map +1 -1
  30. package/dist/cli/move-hint.d.ts +45 -0
  31. package/dist/cli/move-hint.js +196 -0
  32. package/dist/cli/move-hint.js.map +1 -0
  33. package/dist/cli/period.d.ts +2 -8
  34. package/dist/cli/period.js +44 -30
  35. package/dist/cli/period.js.map +1 -1
  36. package/dist/cli/read-driver.js +11 -1
  37. package/dist/cli/read-driver.js.map +1 -1
  38. package/dist/cli/render.js +16 -7
  39. package/dist/cli/render.js.map +1 -1
  40. package/dist/cli/skill-check.d.ts +21 -0
  41. package/dist/cli/skill-check.js +82 -0
  42. package/dist/cli/skill-check.js.map +1 -0
  43. package/dist/cli/skill.d.ts +62 -0
  44. package/dist/cli/skill.js +142 -0
  45. package/dist/cli/skill.js.map +1 -0
  46. package/dist/cli/tag-filters.d.ts +6 -2
  47. package/dist/cli/tag-filters.js +8 -6
  48. package/dist/cli/tag-filters.js.map +1 -1
  49. package/dist/cli/verb-hint.js +27 -2
  50. package/dist/cli/verb-hint.js.map +1 -1
  51. package/dist/cli/version.d.ts +4 -0
  52. package/dist/cli/version.js +18 -0
  53. package/dist/cli/version.js.map +1 -0
  54. package/dist/client.d.ts +35 -12
  55. package/dist/client.js +42 -18
  56. package/dist/client.js.map +1 -1
  57. package/dist/contracts.d.ts +11 -1
  58. package/dist/contracts.js +1 -1
  59. package/dist/contracts.js.map +1 -1
  60. package/dist/index.d.ts +6 -1
  61. package/dist/index.js +3 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/mcp/server.js +550 -506
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/model/clock.d.ts +61 -0
  66. package/dist/model/clock.js +71 -0
  67. package/dist/model/clock.js.map +1 -0
  68. package/dist/model/dates.d.ts +26 -3
  69. package/dist/model/dates.js +84 -3
  70. package/dist/model/dates.js.map +1 -1
  71. package/dist/model/entities.d.ts +17 -3
  72. package/dist/model/entities.js.map +1 -1
  73. package/dist/model/mappers.d.ts +2 -2
  74. package/dist/model/mappers.js +25 -7
  75. package/dist/model/mappers.js.map +1 -1
  76. package/dist/read/area-view.d.ts +1 -1
  77. package/dist/read/area-view.js +8 -6
  78. package/dist/read/area-view.js.map +1 -1
  79. package/dist/read/detail.d.ts +1 -1
  80. package/dist/read/detail.js +11 -4
  81. package/dist/read/detail.js.map +1 -1
  82. package/dist/read/log-boundary.d.ts +1 -1
  83. package/dist/read/log-boundary.js +13 -3
  84. package/dist/read/log-boundary.js.map +1 -1
  85. package/dist/read/project-view.d.ts +1 -1
  86. package/dist/read/project-view.js +8 -6
  87. package/dist/read/project-view.js.map +1 -1
  88. package/dist/read/queries.d.ts +3 -1
  89. package/dist/read/queries.js +27 -5
  90. package/dist/read/queries.js.map +1 -1
  91. package/dist/read/snapshot.d.ts +1 -1
  92. package/dist/read/snapshot.js +9 -2
  93. package/dist/read/snapshot.js.map +1 -1
  94. package/dist/read/truncation.d.ts +1 -1
  95. package/dist/read/truncation.js +2 -2
  96. package/dist/read/truncation.js.map +1 -1
  97. package/dist/read/views.d.ts +12 -11
  98. package/dist/read/views.js +48 -43
  99. package/dist/read/views.js.map +1 -1
  100. package/dist/write/accessibility-probe.d.ts +1 -1
  101. package/dist/write/accessibility-probe.js +8 -0
  102. package/dist/write/accessibility-probe.js.map +1 -1
  103. package/dist/write/automation-probe.d.ts +1 -1
  104. package/dist/write/automation-probe.js +8 -0
  105. package/dist/write/automation-probe.js.map +1 -1
  106. package/dist/write/batch.js +3 -2
  107. package/dist/write/batch.js.map +1 -1
  108. package/dist/write/commands.d.ts +1 -1
  109. package/dist/write/commands.js +54 -15
  110. package/dist/write/commands.js.map +1 -1
  111. package/dist/write/guards.d.ts +1 -1
  112. package/dist/write/guards.js +23 -0
  113. package/dist/write/guards.js.map +1 -1
  114. package/dist/write/pipeline.d.ts +54 -2
  115. package/dist/write/pipeline.js +112 -20
  116. package/dist/write/pipeline.js.map +1 -1
  117. package/dist/write/planner.js +8 -1
  118. package/dist/write/planner.js.map +1 -1
  119. package/dist/write/pre-state.d.ts +37 -0
  120. package/dist/write/pre-state.js +42 -0
  121. package/dist/write/pre-state.js.map +1 -1
  122. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  123. package/dist/write/recurrence-rule-blob.js +150 -0
  124. package/dist/write/recurrence-rule-blob.js.map +1 -0
  125. package/dist/write/vectors/registry.d.ts +5 -7
  126. package/dist/write/vectors/registry.js +49 -2
  127. package/dist/write/vectors/registry.js.map +1 -1
  128. package/dist/write/vectors/simulator.d.ts +44 -0
  129. package/dist/write/vectors/simulator.js +1136 -0
  130. package/dist/write/vectors/simulator.js.map +1 -0
  131. package/dist/write/vectors/types.d.ts +35 -0
  132. package/dist/write/vectors/ui-certification.d.ts +9 -3
  133. package/dist/write/vectors/ui-certification.js +3 -6
  134. package/dist/write/vectors/ui-certification.js.map +1 -1
  135. package/dist/write/vectors/ui-recipes.d.ts +13 -0
  136. package/dist/write/vectors/ui-recipes.js +52 -0
  137. package/dist/write/vectors/ui-recipes.js.map +1 -1
  138. package/dist/write/vectors/ui.d.ts +15 -0
  139. package/dist/write/vectors/ui.js +54 -6
  140. package/dist/write/vectors/ui.js.map +1 -1
  141. package/dist/write/verify/delta.d.ts +110 -1
  142. package/dist/write/verify/delta.js +177 -15
  143. package/dist/write/verify/delta.js.map +1 -1
  144. package/dist/write/verify/poller.d.ts +7 -1
  145. package/dist/write/verify/poller.js +20 -1
  146. package/dist/write/verify/poller.js.map +1 -1
  147. package/package.json +18 -3
  148. package/skills/things-cli/SKILL.md +53 -0
  149. package/skills/things-cli/references/contracts.md +41 -0
  150. package/skills/things-cli/references/gui.md +11 -0
  151. package/skills/things-cli/references/model.md +51 -0
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { z } from "zod";
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
- import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, LIMIT_DESC, MCP_WHEN_LABELS, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
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";
17
17
  function jsonResult(data) {
18
18
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
19
19
  }
@@ -72,15 +72,40 @@ function resolveCap(value, all, defaultLimit) {
72
72
  return null;
73
73
  return value ?? defaultLimit;
74
74
  }
75
- /** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
75
+ /**
76
+ * Resolve MCP limit/all (flat read tools) into a row cap (null = every row).
77
+ * all:true wins: it lifts the cap and takes precedence over a limit passed
78
+ * alongside it — an explicit "everything" request resolves the contradiction
79
+ * rather than erroring — so the pair is never a usage error on the flat tools.
80
+ */
76
81
  function resolveLimit(args) {
77
- return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
82
+ if (args.all === true)
83
+ return null;
84
+ return args.limit ?? DEFAULT_LIST_LIMIT;
78
85
  }
86
+ /** Precedence notes appended to `limit`/`all` wherever a flat tool accepts both. */
87
+ const LIMIT_IGNORED_NOTE = "ignored when all is set";
88
+ const ALL_WINS_NOTE = "wins over limit if both are set";
79
89
  /** Shared limit/all input schema fragment for the flat read tools. */
80
90
  const limitShape = {
81
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
82
- all: z.boolean().optional().describe(ALL_DESC),
91
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
92
+ all: z.boolean().optional().describe(`${ALL_DESC}; ${ALL_WINS_NOTE}`),
83
93
  };
94
+ /**
95
+ * The per-call time-zone knob for date-sensitive tools: an IANA zone that
96
+ * evaluates every date boundary (today/evening/upcoming/logbook/overdue/…) for
97
+ * the consumer's calendar, overriding the server's THINGS_TZ for THIS call.
98
+ */
99
+ const TZ_DESC = "IANA time zone (e.g. Asia/Tokyo) to evaluate date boundaries in for this call — " +
100
+ "overrides the server default. Reminder times stay wall-clock and are never shifted.";
101
+ const tzShape = { tz: z.string().optional().describe(TZ_DESC) };
102
+ /** A usage result when `tz` is present but not a recognized IANA zone; null when it is valid/absent. */
103
+ function badTz(tz) {
104
+ if (tz !== undefined && !isValidTimeZone(tz)) {
105
+ return usage(`tz is not a valid IANA time zone: "${tz}" — expected e.g. "America/New_York" or "Asia/Tokyo"`);
106
+ }
107
+ return null;
108
+ }
84
109
  function errorResult(error) {
85
110
  return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
86
111
  }
@@ -176,6 +201,20 @@ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
176
201
  const dryRunShape = {
177
202
  dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
178
203
  };
204
+ /**
205
+ * The per-call opt-in for the tools that reach a change only by driving the
206
+ * local Things app's accessibility interface. Shared by `repeat`, the
207
+ * `convert_to_project` heading action, and the `areas` reorder scope.
208
+ */
209
+ const driveGuiShape = {
210
+ dangerously_drive_gui: z
211
+ .boolean()
212
+ .optional()
213
+ .describe("Required: this drives the local Things app through its accessibility interface to " +
214
+ "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
215
+ "on the machine running this server, and must be turned on first with `things config " +
216
+ "set ui-enabled true`. Intended for a dedicated always-on Mac."),
217
+ };
179
218
  /** How a tag value may be expressed on any tag-accepting tool. */
180
219
  const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
181
220
  /** create_tags param, shared by every tag-accepting write tool. */
@@ -211,10 +250,18 @@ function buildInstructions(getClient) {
211
250
  "rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
212
251
  "add_area, add_project).",
213
252
  `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
214
- `are ${REMINDER_FORMAT}.`,
253
+ `are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
254
+ `below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
215
255
  "- Every write tool accepts dry_run: true to preview the change without applying it. " +
216
- "Operations with cascading or permanent effects require the explicit confirmation " +
217
- "parameter named in their description; refused calls return an error saying what to pass.",
256
+ "A preview creates no state, so later calls cannot reference an item that only appeared in " +
257
+ "a dry-run result. Operations with cascading or permanent effects require the explicit " +
258
+ "confirmation parameter named in their description; refused calls return an error saying " +
259
+ "what to pass.",
260
+ "- 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.",
264
+ "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
218
265
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
219
266
  ];
220
267
  try {
@@ -222,9 +269,12 @@ function buildInstructions(getClient) {
222
269
  const areas = c.read.areas();
223
270
  const tags = c.read.tags();
224
271
  const projects = c.read.projects();
272
+ const clock = c.clockMeta();
225
273
  const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
226
274
  const overflow = projects.length - shown.length;
227
- lines.push("", "Current inventory (read at server start — refresh with list_collections):", `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
275
+ lines.push("", "Current inventory (read at server start — refresh with list_collections):", ...(clock !== undefined
276
+ ? [`- Calendar context at server start: ${JSON.stringify(clock)}`]
277
+ : []), `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
228
278
  (overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
229
279
  }
230
280
  catch {
@@ -285,6 +335,7 @@ export function createThingsMcpServer(options = {}) {
285
335
  ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
286
336
  ...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
287
337
  ...(args.create_tags === true && { createTags: true }),
338
+ ...(args.tz !== undefined && { zone: args.tz }),
288
339
  });
289
340
  /** Run a handler, mapping environment/usage throws to tool errors. */
290
341
  const guard = async (fn) => {
@@ -314,22 +365,32 @@ export function createThingsMcpServer(options = {}) {
314
365
  * longer matches the validated schema and its data may be incomplete. No
315
366
  * block is added when the schema checks out or the read itself errored.
316
367
  */
317
- const readGuard = async (fn) => {
368
+ const readGuard = async (fn, tz) => {
318
369
  const result = await guard(fn);
319
370
  if (result.isError === true)
320
371
  return result;
321
372
  let warnings = [];
373
+ let clock;
322
374
  try {
323
- warnings = schemaWarnings(getClient().schemaStatus());
375
+ const c = getClient();
376
+ warnings = schemaWarnings(c.schemaStatus());
377
+ // The clock honesty field for this call's effective zone (the per-call
378
+ // tz over the server default) — present only when a consumer zone /
379
+ // pinned now is in effect.
380
+ clock = c.clockMeta(tz);
324
381
  }
325
382
  catch {
326
383
  warnings = [];
327
384
  }
328
- if (warnings.length === 0)
385
+ const meta = {
386
+ ...(warnings.length > 0 && { warnings }),
387
+ ...(clock !== undefined && { clock }),
388
+ };
389
+ if (Object.keys(meta).length === 0)
329
390
  return result;
330
391
  return {
331
392
  ...result,
332
- content: [...result.content, { type: "text", text: JSON.stringify({ meta: { warnings } }) }],
393
+ content: [...result.content, { type: "text", text: JSON.stringify({ meta }) }],
333
394
  };
334
395
  };
335
396
  /** Resolve a uuid to to-do/project for the type-generic item tools. */
@@ -360,6 +421,7 @@ export function createThingsMcpServer(options = {}) {
360
421
  inputSchema: {
361
422
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
362
423
  ...tagFilterShape,
424
+ ...tzShape,
363
425
  evening: z.boolean().optional().describe("today only: show only the This Evening section"),
364
426
  show_active_project_items: z
365
427
  .union([z.boolean(), z.number().int().min(1)])
@@ -382,7 +444,7 @@ export function createThingsMcpServer(options = {}) {
382
444
  .int()
383
445
  .min(1)
384
446
  .optional()
385
- .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
447
+ .describe(`flat views only (not anytime/someday): ${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
386
448
  area_limit: z
387
449
  .number()
388
450
  .int()
@@ -398,10 +460,14 @@ export function createThingsMcpServer(options = {}) {
398
460
  all: z
399
461
  .boolean()
400
462
  .optional()
401
- .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
463
+ .describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
464
+ ALL_WINS_NOTE),
402
465
  },
403
466
  annotations: READ_ONLY,
404
467
  }, async (args) => readGuard(() => {
468
+ const badZone = badTz(args.tz);
469
+ if (badZone !== null)
470
+ return badZone;
405
471
  // Tag-conflict AND overdue-applicability both derive from the shared
406
472
  // contract: read_view honors overdue only on today/inbox/anytime/someday
407
473
  // (the current-work views), matching FILTER_CONTRACT.
@@ -434,8 +500,6 @@ export function createThingsMcpServer(options = {}) {
434
500
  "to cap that section's project lists");
435
501
  }
436
502
  const limit = resolveLimit(args);
437
- if (limit === "conflict")
438
- return usage("pass at most one of limit / all");
439
503
  const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
440
504
  const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
441
505
  if (areaLimit === "conflict" || projectLimit === "conflict") {
@@ -443,26 +507,34 @@ export function createThingsMcpServer(options = {}) {
443
507
  }
444
508
  const c = getClient();
445
509
  const filter = validated.filter;
510
+ const zone = args.tz !== undefined ? { zone: args.tz } : {};
446
511
  switch (args.view) {
447
512
  case "today": {
448
513
  const { view, truncation } = c.read.today({
449
514
  ...filter,
515
+ ...zone,
450
516
  ...(args.evening === true && { eveningOnly: true }),
451
517
  limit,
452
518
  });
453
519
  return truncatedResult(view, truncation);
454
520
  }
455
521
  case "inbox": {
456
- const { items, truncation } = c.read.inbox({ ...filter, limit });
522
+ const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
457
523
  return truncatedResult(items, truncation);
458
524
  }
459
525
  case "anytime": {
460
- const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
526
+ const { view, grouped } = c.read.anytime({
527
+ ...filter,
528
+ ...zone,
529
+ areaLimit,
530
+ projectLimit,
531
+ });
461
532
  return groupedResult(view, grouped);
462
533
  }
463
534
  case "upcoming": {
464
535
  const { items, truncation } = c.read.upcoming({
465
536
  ...filter,
537
+ ...zone,
466
538
  ...(args.horizon !== undefined && { horizon: args.horizon }),
467
539
  limit,
468
540
  });
@@ -475,6 +547,7 @@ export function createThingsMcpServer(options = {}) {
475
547
  }
476
548
  const { view, grouped } = c.read.someday({
477
549
  ...filter,
550
+ ...zone,
478
551
  ...((active === true || typeof active === "number") && {
479
552
  activeProjectItems: true,
480
553
  }),
@@ -485,15 +558,15 @@ export function createThingsMcpServer(options = {}) {
485
558
  return groupedResult(view, grouped);
486
559
  }
487
560
  case "logbook": {
488
- const { items, truncation } = c.read.logbook({ ...filter, limit });
561
+ const { items, truncation } = c.read.logbook({ ...filter, ...zone, limit });
489
562
  return truncatedResult(items, truncation);
490
563
  }
491
564
  case "trash": {
492
- const { items, truncation } = c.read.trash({ limit });
565
+ const { items, truncation } = c.read.trash({ ...zone, limit });
493
566
  return truncatedResult(items, truncation);
494
567
  }
495
568
  }
496
- }));
569
+ }, args.tz));
497
570
  server.registerTool("search", {
498
571
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
499
572
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
@@ -502,6 +575,7 @@ export function createThingsMcpServer(options = {}) {
502
575
  inputSchema: {
503
576
  query: z.string(),
504
577
  ...tagFilterShape,
578
+ ...tzShape,
505
579
  project: z
506
580
  .string()
507
581
  .optional()
@@ -516,11 +590,14 @@ export function createThingsMcpServer(options = {}) {
516
590
  all: z
517
591
  .boolean()
518
592
  .optional()
519
- .describe("Everything, unbounded: open + logged + trashed, no row limit"),
520
- limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
593
+ .describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
594
+ limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
521
595
  },
522
596
  annotations: READ_ONLY,
523
597
  }, async (args) => readGuard(() => {
598
+ const badZone = badTz(args.tz);
599
+ if (badZone !== null)
600
+ return badZone;
524
601
  // Tag-conflict AND the overdue/status-widening incompatibility both
525
602
  // derive from the shared contract (search: statusWidening = true).
526
603
  const validated = validateViewArgs("search", {
@@ -537,11 +614,10 @@ export function createThingsMcpServer(options = {}) {
537
614
  if (!validated.ok)
538
615
  return usage(validated.message);
539
616
  const limit = resolveLimit(args);
540
- if (limit === "conflict")
541
- return usage("pass at most one of limit / all");
542
617
  const { items, truncation } = getClient().read.search(args.query, {
543
618
  limit,
544
619
  ...validated.filter,
620
+ ...(args.tz !== undefined && { zone: args.tz }),
545
621
  ...(args.project !== undefined && { project: args.project }),
546
622
  ...(args.area !== undefined && { area: args.area }),
547
623
  ...(args.type !== undefined && { type: args.type }),
@@ -550,7 +626,7 @@ export function createThingsMcpServer(options = {}) {
550
626
  ...(args.all === true && { all: true }),
551
627
  });
552
628
  return truncatedResult(items, truncation);
553
- }));
629
+ }, args.tz));
554
630
  server.registerTool("changes_since", {
555
631
  description: "List items created or modified since a moment — including trashed, logged, and " +
556
632
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
@@ -559,19 +635,25 @@ export function createThingsMcpServer(options = {}) {
559
635
  inputSchema: {
560
636
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
561
637
  ...limitShape,
638
+ ...tzShape,
562
639
  },
563
640
  annotations: READ_ONLY,
564
641
  }, async (args) => readGuard(() => {
642
+ const badZone = badTz(args.tz);
643
+ if (badZone !== null)
644
+ return badZone;
565
645
  const limit = resolveLimit(args);
566
- if (limit === "conflict")
567
- return usage("pass at most one of limit / all");
568
646
  const since = new Date(args.since);
569
647
  if (Number.isNaN(since.getTime())) {
570
648
  return usage(`since is not a parseable date: ${args.since}`);
571
649
  }
572
- const { items, truncation } = getClient().read.changes({ since, limit });
650
+ const { items, truncation } = getClient().read.changes({
651
+ since,
652
+ limit,
653
+ ...(args.tz !== undefined && { zone: args.tz }),
654
+ });
573
655
  return truncatedResult(items, truncation);
574
- }));
656
+ }, args.tz));
575
657
  server.registerTool("get_item", {
576
658
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
577
659
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
@@ -593,6 +675,7 @@ export function createThingsMcpServer(options = {}) {
593
675
  inputSchema: {
594
676
  uuid: z.string().describe("Project uuid or unique name"),
595
677
  ...tagOnlyShape,
678
+ ...tzShape,
596
679
  overdue: z
597
680
  .boolean()
598
681
  .optional()
@@ -600,13 +683,17 @@ export function createThingsMcpServer(options = {}) {
600
683
  },
601
684
  annotations: READ_ONLY,
602
685
  }, async (args) => readGuard(() => {
686
+ const badZone = badTz(args.tz);
687
+ if (badZone !== null)
688
+ return badZone;
603
689
  if (tagFlagConflict(tagPresence(args)))
604
690
  return usage(MCP_UNTAGGED_CONFLICT);
605
691
  return readResult(getClient().read.projectView(args.uuid, {
606
692
  overdue: args.overdue === true,
607
693
  ...tagFilterFields(tagPresence(args)),
694
+ ...(args.tz !== undefined && { zone: args.tz }),
608
695
  }));
609
- }));
696
+ }, args.tz));
610
697
  server.registerTool("get_area", {
611
698
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
612
699
  "projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
@@ -617,6 +704,7 @@ export function createThingsMcpServer(options = {}) {
617
704
  inputSchema: {
618
705
  ref: z.string().describe("Area uuid or unique name"),
619
706
  ...tagOnlyShape,
707
+ ...tzShape,
620
708
  area_limit: z
621
709
  .number()
622
710
  .int()
@@ -637,6 +725,9 @@ export function createThingsMcpServer(options = {}) {
637
725
  },
638
726
  annotations: READ_ONLY,
639
727
  }, async (args) => readGuard(() => {
728
+ const badZone = badTz(args.tz);
729
+ if (badZone !== null)
730
+ return badZone;
640
731
  if (tagFlagConflict(tagPresence(args)))
641
732
  return usage(MCP_UNTAGGED_CONFLICT);
642
733
  const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
@@ -647,11 +738,12 @@ export function createThingsMcpServer(options = {}) {
647
738
  const { view, grouped } = getClient().read.areaView(args.ref, {
648
739
  overdue: args.overdue === true,
649
740
  ...tagFilterFields(tagPresence(args)),
741
+ ...(args.tz !== undefined && { zone: args.tz }),
650
742
  areaLimit,
651
743
  projectLimit,
652
744
  });
653
745
  return groupedResult(view, grouped);
654
- }));
746
+ }, args.tz));
655
747
  server.registerTool("list_collections", {
656
748
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
657
749
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
@@ -660,6 +752,7 @@ export function createThingsMcpServer(options = {}) {
660
752
  inputSchema: {
661
753
  kind: z.enum(["projects", "areas", "tags"]),
662
754
  ...tagOnlyShape,
755
+ ...tzShape,
663
756
  overdue: z
664
757
  .boolean()
665
758
  .optional()
@@ -667,6 +760,9 @@ export function createThingsMcpServer(options = {}) {
667
760
  },
668
761
  annotations: READ_ONLY,
669
762
  }, async (args) => readGuard(() => {
763
+ const badZone = badTz(args.tz);
764
+ if (badZone !== null)
765
+ return badZone;
670
766
  const c = getClient();
671
767
  // areas/tags are not dated entities and have no per-row tag list to
672
768
  // filter — overdue and the tag filters are vacuous there, rejected
@@ -692,17 +788,18 @@ export function createThingsMcpServer(options = {}) {
692
788
  ? c.read.projects({
693
789
  overdue: args.overdue === true,
694
790
  ...tagFilterFields(tagPresence(args)),
791
+ ...(args.tz !== undefined && { zone: args.tz }),
695
792
  })
696
793
  : args.kind === "areas"
697
794
  ? c.read.areas()
698
795
  : c.read.tags());
699
- }));
796
+ }, args.tz));
700
797
  // ---------------------------------------------------------------- to-dos
701
798
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
702
799
  server.registerTool("add_todo", {
703
800
  description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
704
801
  "deadline, tag it, give it a checklist, and place it in a project or area " +
705
- "(optionally under an existing heading). A reminder " +
802
+ "(optionally under a heading within that project). A reminder " +
706
803
  "requires when = today, evening, or a date. Adding into a completed or canceled " +
707
804
  "project reopens that project — pass acknowledge_project_reopen to confirm.",
708
805
  inputSchema: {
@@ -721,10 +818,14 @@ export function createThingsMcpServer(options = {}) {
721
818
  .optional()
722
819
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
723
820
  ...createTagsShape,
821
+ ...tzShape,
724
822
  ...dryRunShape,
725
823
  },
726
824
  annotations: NON_DESTRUCTIVE,
727
825
  }, async (args) => guard(async () => {
826
+ const badZone = badTz(args.tz);
827
+ if (badZone !== null)
828
+ return badZone;
728
829
  const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
729
830
  if (sugar.kind === "error")
730
831
  return usage(sugar.message);
@@ -743,76 +844,189 @@ export function createThingsMcpServer(options = {}) {
743
844
  ...(args.heading !== undefined && { heading: args.heading }),
744
845
  }, writeOptions(args)));
745
846
  }));
746
- server.registerTool("update_todo", {
747
- description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
748
- "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
749
- "notes). Changing the schedule keeps an existing reminder unless the call sets a " +
750
- "new one. clear_reminder works while the to-do is scheduled for today or this " +
751
- "evening; a reminder on a future date can only be changed, not cleared " +
752
- "(re-schedule to today first). Schedule and deadline changes are not available " +
753
- "for repeating to-dos.",
847
+ server.registerTool("update", {
848
+ description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
849
+ "kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
850
+ "existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
851
+ "deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
852
+ "one is set, schedule and deadline changes are unavailable for repeating items, and " +
853
+ "clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
854
+ "future date can only be changed, not cleared). kind area: title and/or tags (the full " +
855
+ "replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
856
+ "exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
857
+ "create_tags is set.",
754
858
  inputSchema: {
755
- uuid: z.string(),
756
- title: z.string().optional(),
757
- notes: z.string().optional().describe("Replaces the whole notes body"),
758
- append_notes: z.string().optional(),
759
- prepend_notes: z.string().optional(),
859
+ kind: z.enum(["todo", "project", "area", "tag"]),
860
+ uuid: z
861
+ .string()
862
+ .describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
863
+ "unique name"),
864
+ title: z.string().optional().describe("New title (any kind)"),
865
+ notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
866
+ append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
867
+ prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
760
868
  when: whenSchema,
761
- reminder: z.string().optional().describe(REMINDER_FORMAT),
762
- clear_reminder: z.boolean().optional(),
763
- deadline: z.string().optional().describe(DATE_FORMAT),
764
- clear_deadline: z.boolean().optional(),
869
+ reminder: z.string().optional().describe(`todo/project: ${REMINDER_FORMAT}`),
870
+ clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
871
+ deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
872
+ clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
873
+ tags: z
874
+ .array(z.string())
875
+ .optional()
876
+ .describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
877
+ parent: z.string().optional().describe("tag: existing tag to nest under"),
878
+ unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
879
+ shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
880
+ clear_shortcut: z.boolean().optional().describe("tag: remove the keyboard shortcut"),
881
+ ...createTagsShape,
882
+ ...tzShape,
765
883
  ...dryRunShape,
766
884
  },
767
885
  annotations: NON_DESTRUCTIVE,
768
886
  }, async (args) => guard(async () => {
769
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
770
- if (notesModes.length > 1) {
771
- return usage("notes, append_notes, prepend_notes are exclusive");
887
+ const badZone = badTz(args.tz);
888
+ if (badZone !== null)
889
+ return badZone;
890
+ const opts = writeOptions(args);
891
+ const c = getClient();
892
+ if (args.kind === "todo" || args.kind === "project") {
893
+ const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
894
+ if (notesModes.length > 1) {
895
+ return usage("notes, append_notes, prepend_notes are exclusive");
896
+ }
897
+ if (args.reminder !== undefined && args.clear_reminder === true) {
898
+ return usage("pass at most one of reminder / clear_reminder");
899
+ }
900
+ if (args.deadline !== undefined && args.clear_deadline === true) {
901
+ return usage("pass at most one of deadline / clear_deadline");
902
+ }
903
+ if (args.kind === "todo") {
904
+ const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
905
+ if (sugar.kind === "error")
906
+ return usage(sugar.message);
907
+ const when = sugar.kind === "split" ? sugar.when : args.when;
908
+ const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
909
+ return mutationResult(await c.write.updateTodo(args.uuid, {
910
+ ...(args.title !== undefined && { title: args.title }),
911
+ ...(args.notes !== undefined && { notes: args.notes }),
912
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
913
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
914
+ ...(when !== undefined && { when: when }),
915
+ ...(reminder !== undefined && { reminder }),
916
+ ...(args.clear_reminder === true && { reminder: null }),
917
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
918
+ ...(args.clear_deadline === true && { deadline: null }),
919
+ }, opts));
920
+ }
921
+ return mutationResult(await c.write.updateProject(args.uuid, {
922
+ ...(args.title !== undefined && { title: args.title }),
923
+ ...(args.notes !== undefined && { notes: args.notes }),
924
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
925
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
926
+ ...(args.when !== undefined && { when: args.when }),
927
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
928
+ ...(args.clear_reminder === true && { reminder: null }),
929
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
930
+ ...(args.clear_deadline === true && { deadline: null }),
931
+ }, opts));
932
+ }
933
+ if (args.kind === "area") {
934
+ if (args.title === undefined && args.tags === undefined) {
935
+ return usage("kind area requires title and/or tags");
936
+ }
937
+ return mutationResult(await c.write.updateArea(args.uuid, {
938
+ ...(args.title !== undefined && { title: args.title }),
939
+ ...(args.tags !== undefined && { tags: args.tags }),
940
+ }, opts));
941
+ }
942
+ // kind tag
943
+ if (args.title === undefined &&
944
+ args.parent === undefined &&
945
+ args.unnest === undefined &&
946
+ args.shortcut === undefined &&
947
+ args.clear_shortcut === undefined) {
948
+ return usage("kind tag requires title, parent, unnest, shortcut, and/or clear_shortcut");
772
949
  }
773
- if (args.reminder !== undefined && args.clear_reminder === true) {
774
- return usage("pass at most one of reminder / clear_reminder");
950
+ if (args.parent !== undefined && args.unnest === true) {
951
+ return usage("parent and unnest are exclusive");
775
952
  }
776
- if (args.deadline !== undefined && args.clear_deadline === true) {
777
- return usage("pass at most one of deadline / clear_deadline");
953
+ if (args.shortcut !== undefined && args.clear_shortcut === true) {
954
+ return usage("shortcut and clear_shortcut are exclusive");
778
955
  }
779
- const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
780
- if (sugar.kind === "error")
781
- return usage(sugar.message);
782
- const when = sugar.kind === "split" ? sugar.when : args.when;
783
- const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
784
- return mutationResult(await getClient().write.updateTodo(args.uuid, {
956
+ return mutationResult(await c.write.updateTag(args.uuid, {
785
957
  ...(args.title !== undefined && { title: args.title }),
786
- ...(args.notes !== undefined && { notes: args.notes }),
787
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
788
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
789
- ...(when !== undefined && { when: when }),
790
- ...(reminder !== undefined && { reminder }),
791
- ...(args.clear_reminder === true && { reminder: null }),
792
- ...(args.deadline !== undefined && { deadline: args.deadline }),
793
- ...(args.clear_deadline === true && { deadline: null }),
794
- }, writeOptions(args)));
958
+ ...(args.parent !== undefined && { parent: args.parent }),
959
+ ...(args.unnest === true && { unnest: true }),
960
+ ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
961
+ ...(args.clear_shortcut === true && { clearShortcut: true }),
962
+ }, opts));
795
963
  }));
796
- server.registerTool("set_todo_status", {
797
- description: "Set a to-do's status: completed, canceled, or open (reopening a " +
798
- "completed/canceled to-do). Not available for repeating to-dos.",
964
+ server.registerTool("set_status", {
965
+ description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
966
+ "(reopening a completed/canceled item). Not available for repeating to-dos. " +
967
+ "scope project, completing or canceling requires a children policy: 'require-resolved' " +
968
+ "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
969
+ "the project (canceling never alters already-completed children). scope project, status " +
970
+ "open, restore_children also reopens the to-dos that were resolved with the project.",
799
971
  inputSchema: {
800
- uuid: z.string(),
972
+ scope: z.enum(["todo", "project"]),
973
+ uuid: z
974
+ .string()
975
+ .describe("The item — a to-do by uuid; a project also accepts a unique name"),
801
976
  status: z.enum(["completed", "canceled", "open"]),
977
+ children: z
978
+ .enum(["require-resolved", "auto-complete", "auto-cancel"])
979
+ .optional()
980
+ .describe("scope project, completed/canceled: what to do with the project's open to-dos"),
981
+ restore_children: z
982
+ .boolean()
983
+ .optional()
984
+ .describe("scope project, open only: also reopen the to-dos resolved with the project"),
802
985
  ...dryRunShape,
803
986
  },
804
987
  annotations: NON_DESTRUCTIVE,
805
988
  }, async (args) => guard(async () => {
806
989
  const c = getClient();
807
990
  const opts = writeOptions(args);
808
- return mutationResult(args.status === "completed"
809
- ? await c.write.completeTodo(args.uuid, opts)
810
- : args.status === "canceled"
811
- ? await c.write.cancelTodo(args.uuid, opts)
812
- : await c.write.reopenTodo(args.uuid, opts));
991
+ if (args.scope === "todo") {
992
+ if (args.children !== undefined || args.restore_children !== undefined) {
993
+ return usage("children/restore_children apply only to scope project");
994
+ }
995
+ return mutationResult(args.status === "completed"
996
+ ? await c.write.completeTodo(args.uuid, opts)
997
+ : args.status === "canceled"
998
+ ? await c.write.cancelTodo(args.uuid, opts)
999
+ : await c.write.reopenTodo(args.uuid, opts));
1000
+ }
1001
+ // scope project
1002
+ if (args.status !== "open" && args.restore_children !== undefined) {
1003
+ return usage("restore_children applies only to status 'open'");
1004
+ }
1005
+ if (args.status === "completed") {
1006
+ if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1007
+ return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1008
+ }
1009
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
1010
+ }
1011
+ if (args.status === "canceled") {
1012
+ if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1013
+ return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1014
+ }
1015
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
1016
+ }
1017
+ if (args.children !== undefined) {
1018
+ return usage("children applies only to status 'completed' or 'canceled'");
1019
+ }
1020
+ const outcome = await c.write.reopenProject(args.uuid, {
1021
+ ...opts,
1022
+ ...(args.restore_children === true && { restoreChildren: true }),
1023
+ });
1024
+ return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
1025
+ ? jsonResult(outcome)
1026
+ : mutationResult(outcome.project);
813
1027
  }));
814
1028
  server.registerTool("move_todo", {
815
- description: "Move a to-do. Pass exactly one destination: a project and/or area (optionally an " +
1029
+ description: "Move a to-do. Pass exactly one destination: a project or area (optionally an " +
816
1030
  "existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
817
1031
  "removes any schedule; detach removes the project/area/heading assignment while " +
818
1032
  "keeping the schedule. Moving into a completed or canceled project reopens that " +
@@ -965,17 +1179,44 @@ export function createThingsMcpServer(options = {}) {
965
1179
  return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
966
1180
  }));
967
1181
  // ------------------------------------------------- to-dos AND projects
968
- server.registerTool("delete_item", {
969
- description: "Move a to-do or project to the Trash (recoverable via restore_item until the " +
970
- "Trash is emptied). Deleting a project sends its to-dos to the Trash with it. Not " +
971
- "available for repeating to-dos.",
972
- inputSchema: { uuid: z.string(), ...dryRunShape },
1182
+ server.registerTool("delete", {
1183
+ description: "Delete a to-do/project (kind item), an area, or a tag kind selects which. " +
1184
+ "kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
1185
+ "the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
1186
+ "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 " +
1189
+ "dangerously_permanent; the tag is removed from every item, and if it has nested child " +
1190
+ "tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
1191
+ inputSchema: {
1192
+ kind: z.enum(["item", "area", "tag"]),
1193
+ uuid: z
1194
+ .string()
1195
+ .describe("The target — an item by uuid; an area or tag also accepts a unique name"),
1196
+ dangerously_permanent: z
1197
+ .boolean()
1198
+ .optional()
1199
+ .describe("kind area/tag: confirm permanent, unrecoverable deletion"),
1200
+ acknowledge_tag_subtree: z
1201
+ .boolean()
1202
+ .optional()
1203
+ .describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
1204
+ ...dryRunShape,
1205
+ },
973
1206
  annotations: DESTRUCTIVE,
974
1207
  }, async (args) => guard(async () => {
975
1208
  const c = getClient();
976
- return mutationResult(itemType(args.uuid) === "to-do"
977
- ? await c.write.deleteTodo(args.uuid, writeOptions(args))
978
- : await c.write.deleteProject(args.uuid, writeOptions(args)));
1209
+ const opts = writeOptions(args);
1210
+ switch (args.kind) {
1211
+ case "item":
1212
+ return mutationResult(itemType(args.uuid) === "to-do"
1213
+ ? await c.write.deleteTodo(args.uuid, opts)
1214
+ : await c.write.deleteProject(args.uuid, opts));
1215
+ case "area":
1216
+ return mutationResult(await c.write.deleteArea(args.uuid, opts));
1217
+ case "tag":
1218
+ return mutationResult(await c.write.deleteTag(args.uuid, opts));
1219
+ }
979
1220
  }));
980
1221
  server.registerTool("restore_item", {
981
1222
  description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
@@ -1022,17 +1263,80 @@ export function createThingsMcpServer(options = {}) {
1022
1263
  ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1023
1264
  ...(args.notes !== undefined && { notes: args.notes }),
1024
1265
  }, writeOptions(args)))));
1025
- server.registerTool("create_heading", {
1026
- description: "Create a heading inside an existing project; its uuid is returned. The project " +
1027
- "must name an existing project. Uses the Things proxy shortcuts set them up once " +
1028
- "with `things setup shortcuts`.",
1266
+ 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.",
1029
1278
  inputSchema: {
1030
- project: z.string().describe(`Existing project (${REF_FORMAT})`),
1031
- title: z.string(),
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
1282
+ .string()
1283
+ .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"),
1286
+ children: z
1287
+ .enum(["complete", "cancel", "reparent"])
1288
+ .optional()
1289
+ .describe("archive: required when the heading has open children"),
1290
+ restore_children: z
1291
+ .boolean()
1292
+ .optional()
1293
+ .describe("unarchive: also reopen the children archived with the heading"),
1294
+ ...driveGuiShape,
1032
1295
  ...dryRunShape,
1033
1296
  },
1034
- annotations: NON_DESTRUCTIVE,
1035
- }, async (args) => guard(async () => mutationResult(await getClient().write.createHeading(containerRef(args.project), args.title, writeOptions(args)))));
1297
+ annotations: DESTRUCTIVE,
1298
+ }, async (args) => guard(async () => {
1299
+ const c = getClient();
1300
+ const opts = writeOptions(args);
1301
+ switch (args.action) {
1302
+ case "create":
1303
+ if (args.project === undefined || args.title === undefined) {
1304
+ return usage('action "create" requires project and title');
1305
+ }
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');
1310
+ }
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);
1316
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1317
+ ? jsonResult(r)
1318
+ : mutationResult(r.heading);
1319
+ }
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);
1324
+ return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1325
+ ? jsonResult(r)
1326
+ : mutationResult(r.heading);
1327
+ }
1328
+ case "convert_to_project": {
1329
+ if (args.uuid === undefined) {
1330
+ return usage('action "convert_to_project" requires uuid');
1331
+ }
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));
1337
+ }
1338
+ }
1339
+ }));
1036
1340
  server.registerTool("clear_reminder", {
1037
1341
  description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
1038
1342
  "Things proxy shortcuts when installed (in place, and the only path for a repeating " +
@@ -1041,59 +1345,7 @@ export function createThingsMcpServer(options = {}) {
1041
1345
  inputSchema: { uuid: z.string(), ...dryRunShape },
1042
1346
  annotations: NON_DESTRUCTIVE,
1043
1347
  }, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
1044
- server.registerTool("rename_heading", {
1045
- description: "Rename a heading in place (works on archived headings too).",
1046
- inputSchema: { uuid: z.string(), title: z.string(), ...dryRunShape },
1047
- annotations: NON_DESTRUCTIVE,
1048
- }, async (args) => guard(async () => mutationResult(await getClient().write.renameHeading(args.uuid, args.title, writeOptions(args)))));
1049
- server.registerTool("archive_heading", {
1050
- description: "Archive a heading — it leaves the active project view (reversible with " +
1051
- "unarchive_heading). The preferred way to retire a heading: row deletion only " +
1052
- "exists in the app's UI / Shortcuts behind a per-run consent dialog. With open " +
1053
- "children the children policy is required: complete or cancel resolve them with " +
1054
- "the heading in one cascade; reparent moves them to the project root first, " +
1055
- "keeping them open (a compound sequence that undo reverses as one unit).",
1056
- inputSchema: {
1057
- uuid: z.string(),
1058
- children: z
1059
- .enum(["complete", "cancel", "reparent"])
1060
- .optional()
1061
- .describe("Required when the heading has open children"),
1062
- ...dryRunShape,
1063
- },
1064
- annotations: NON_DESTRUCTIVE,
1065
- }, async (args) => guard(async () => {
1066
- const r = await getClient().write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, writeOptions(args));
1067
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1068
- ? jsonResult(r)
1069
- : mutationResult(r.heading);
1070
- }));
1071
- server.registerTool("unarchive_heading", {
1072
- description: "Un-archive a heading. restore_children also reopens the children the archive " +
1073
- "cascade resolved with it (matching resolution timestamps; someday state " +
1074
- "survives). Children resolved at other times are never touched.",
1075
- inputSchema: {
1076
- uuid: z.string(),
1077
- restore_children: z.boolean().optional(),
1078
- ...dryRunShape,
1079
- },
1080
- annotations: NON_DESTRUCTIVE,
1081
- }, async (args) => guard(async () => {
1082
- const r = await getClient().write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, writeOptions(args));
1083
- return r.heading.kind === "ok" || r.heading.kind === "dry-run"
1084
- ? jsonResult(r)
1085
- : mutationResult(r.heading);
1086
- }));
1087
1348
  // -------------------------------------------- GUI-driven (Accessibility)
1088
- const driveGuiShape = {
1089
- dangerously_drive_gui: z
1090
- .boolean()
1091
- .optional()
1092
- .describe("Required: this drives the local Things app through its accessibility interface to " +
1093
- "make a change the app offers nowhere else. It briefly interacts with the app's UI " +
1094
- "on the machine running this server, and must be turned on first with `things config " +
1095
- "set ui-enabled true`. Intended for a dedicated always-on Mac."),
1096
- };
1097
1349
  const WEEKDAY_ENUM = z.enum([
1098
1350
  "sunday",
1099
1351
  "monday",
@@ -1171,177 +1423,112 @@ export function createThingsMcpServer(options = {}) {
1171
1423
  fields.startDaysEarlier = a.start_days_earlier;
1172
1424
  return fields;
1173
1425
  };
1174
- server.registerTool("make_repeating", {
1175
- description: "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new recurring " +
1176
- "seriesthe original disappears and a fresh repeating item takes its place, so it " +
1177
- "cannot be undone. Set the frequency and interval, and optionally the weekday set, " +
1178
- "monthly/yearly day, end bound, reminders, or deadline. Returns the new item's uuid.",
1179
- inputSchema: {
1180
- uuid: z.string().describe("The to-do to make repeating"),
1181
- ...repeatRuleShape,
1182
- ...driveGuiShape,
1183
- ...dryRunShape,
1184
- },
1185
- annotations: DESTRUCTIVE,
1186
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.make-repeating", {
1187
- uuid: args.uuid,
1188
- frequency: args.frequency,
1189
- interval: args.interval,
1190
- ...repeatExtras(args, args.frequency),
1191
- }, writeOptions(args)))));
1192
- server.registerTool("reschedule_repeat", {
1193
- description: "Change a repeating to-do's rule in place, keeping the same item. Set the frequency and " +
1194
- "interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, or " +
1195
- "deadline. This can be undone — it restores the previous rule.",
1196
- inputSchema: {
1197
- uuid: z.string().describe("The repeating to-do to reschedule"),
1198
- ...repeatRuleShape,
1199
- ...driveGuiShape,
1200
- ...dryRunShape,
1201
- },
1202
- annotations: NON_DESTRUCTIVE,
1203
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.reschedule-repeat", {
1204
- uuid: args.uuid,
1205
- frequency: args.frequency,
1206
- interval: args.interval,
1207
- ...repeatExtras(args, args.frequency),
1208
- }, writeOptions(args)))));
1209
- server.registerTool("set_repeat_state", {
1210
- description: "Pause or resume a repeating to-do. 'pause' stops it spawning new occurrences but keeps " +
1211
- "its rule; 'resume' starts it again. The two are inverses of each other.",
1212
- inputSchema: {
1213
- uuid: z.string().describe("The repeating to-do"),
1214
- state: z.enum(["pause", "resume"]),
1215
- ...driveGuiShape,
1216
- ...dryRunShape,
1217
- },
1218
- annotations: NON_DESTRUCTIVE,
1219
- }, async (args) => guard(async () => {
1220
- const op = args.state === "pause" ? "todo.pause-repeat" : "todo.resume-repeat";
1221
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1222
- }));
1223
- server.registerTool("reschedule_project_repeat", {
1224
- description: "Change a repeating project's rule in place, keeping the same project. Set the frequency " +
1225
- "and interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, " +
1226
- "or deadline. This can be undone — it restores the previous rule.",
1227
- inputSchema: {
1228
- uuid: z.string().describe(`The repeating project to reschedule (${REF_FORMAT})`),
1229
- ...repeatRuleShape,
1230
- ...driveGuiShape,
1231
- ...dryRunShape,
1232
- },
1233
- annotations: NON_DESTRUCTIVE,
1234
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("project.reschedule-repeat", {
1235
- uuid: args.uuid,
1236
- frequency: args.frequency,
1237
- interval: args.interval,
1238
- ...repeatExtras(args, args.frequency),
1239
- }, writeOptions(args)))));
1240
- server.registerTool("set_project_repeat_state", {
1241
- description: "Pause or resume a repeating project. 'pause' stops it spawning new occurrences but keeps " +
1242
- "its rule; 'resume' starts it again. The two are inverses of each other.",
1243
- inputSchema: {
1244
- uuid: z.string().describe(`The repeating project (${REF_FORMAT})`),
1245
- state: z.enum(["pause", "resume"]),
1246
- ...driveGuiShape,
1247
- ...dryRunShape,
1248
- },
1249
- annotations: NON_DESTRUCTIVE,
1250
- }, async (args) => guard(async () => {
1251
- const op = args.state === "pause" ? "project.pause-repeat" : "project.resume-repeat";
1252
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1253
- }));
1254
- server.registerTool("reorder_area", {
1255
- description: "Move an area to a new position in the area order. Give the area plus exactly one " +
1256
- "destination: before/after another area, or position first/last. The move is made by " +
1257
- "driving the Things window with the pointer — the app comes to the front and the " +
1258
- "sidebar may scroll while the area is dragged; the area's projects and to-dos are " +
1259
- "untouched. Area references are a uuid or a unique name.",
1426
+ server.registerTool("repeat", {
1427
+ description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
1428
+ "interfaceevery action needs dangerously_drive_gui. action start: turn a plain item " +
1429
+ "into a repeating one this REPLACES it with a new series and cannot be undone (give " +
1430
+ "frequency + interval and optionally the weekday set, monthly/yearly day, end bound, " +
1431
+ "reminder, or per-occurrence deadline); returns a repeating block with instanceUuid (the " +
1432
+ "visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
1433
+ "change a repeating item's rule in place, keeping the same item (undoable — it restores " +
1434
+ "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 " +
1436
+ "one call — the project is created first and PERSISTS even if the make-repeating step " +
1437
+ "refuses; give an area to place it or omit it to create in Someday (only frequency and " +
1438
+ "interval are supported); returns the new project's uuid.",
1260
1439
  inputSchema: {
1261
- target: z.string().describe("The area to move (uuid or unique name)"),
1262
- before: z
1440
+ scope: z.enum(["todo", "project"]),
1441
+ action: z.enum(["start", "reschedule", "pause", "resume", "create"]),
1442
+ uuid: z
1263
1443
  .string()
1264
1444
  .optional()
1265
- .describe("Place it immediately above this area (uuid or unique name)"),
1266
- after: z
1445
+ .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})`),
1449
+ project_deadline: z
1267
1450
  .string()
1268
1451
  .optional()
1269
- .describe("Place it immediately below this area (uuid or unique name)"),
1270
- position: z
1271
- .enum(["first", "last"])
1452
+ .describe(`create (project): the project's due date ${DATE_FORMAT}`),
1453
+ todos: z
1454
+ .array(z.string())
1272
1455
  .optional()
1273
- .describe("Move it to the top or bottom of the area list"),
1274
- ...driveGuiShape,
1275
- ...dryRunShape,
1276
- },
1277
- annotations: NON_DESTRUCTIVE,
1278
- }, async (args) => guard(async () => mutationResult(await getClient().write.run("area.reorder", {
1279
- target: args.target,
1280
- ...(args.before !== undefined && { before: args.before }),
1281
- ...(args.after !== undefined && { after: args.after }),
1282
- ...(args.position !== undefined && { position: args.position }),
1283
- }, writeOptions(args)))));
1284
- server.registerTool("make_project_repeating", {
1285
- description: "Turn an existing project into a repeating one. This REPLACES the project with a new " +
1286
- "recurring series — the original disappears and a fresh repeating project takes its place " +
1287
- "(its area is kept), so it cannot be undone. An area-less project scheduled for Anytime is " +
1288
- "moved to Someday first (a cleanup-free intermediate step, shown by dry_run). Set the " +
1289
- "frequency and interval, and optionally the weekday set, monthly/yearly day, end bound, " +
1290
- "reminders, or deadline. Returns the new project's uuid.",
1291
- inputSchema: {
1292
- uuid: z.string().describe(`The project to make repeating (${REF_FORMAT})`),
1293
- ...repeatRuleShape,
1294
- ...driveGuiShape,
1295
- ...dryRunShape,
1296
- },
1297
- annotations: DESTRUCTIVE,
1298
- }, async (args) => guard(async () => mutationResult(await getClient().write.makeRepeatingProject(args.uuid, {
1299
- frequency: args.frequency,
1300
- interval: args.interval,
1301
- ...repeatExtras(args, args.frequency),
1302
- }, writeOptions(args)))));
1303
- server.registerTool("create_repeating_project", {
1304
- description: "Create a project and make it repeating in one call. TWO operations: the project is " +
1305
- "created first and PERSISTS even if the make-repeating step refuses; then it is promoted " +
1306
- "(which drives the GUI). Give an area to place it, or omit it to create in Someday. Only a " +
1307
- "frequency and an interval are supported. Returns the new repeating project's uuid.",
1308
- inputSchema: {
1309
- title: z.string(),
1310
- notes: z.string().optional(),
1311
- area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
1312
- deadline: z.string().optional().describe(DATE_FORMAT),
1313
- todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1314
- ...baseRepeatShape,
1315
- ...driveGuiShape,
1316
- ...dryRunShape,
1317
- },
1318
- annotations: DESTRUCTIVE,
1319
- }, async (args) => guard(async () => mutationResult(await getClient().write.createRepeatingProject({
1320
- title: args.title,
1321
- ...(args.notes !== undefined && { notes: args.notes }),
1322
- ...(args.area !== undefined && { area: containerRef(args.area) }),
1323
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1324
- ...(args.todos !== undefined && { todos: args.todos }),
1325
- frequency: args.frequency,
1326
- interval: args.interval,
1327
- }, writeOptions(args)))));
1328
- server.registerTool("convert_to_project", {
1329
- description: "Convert a to-do or a heading into a project. This REPLACES the original with a new " +
1330
- "project (a converted to-do keeps its notes; a converted heading is promoted alongside " +
1331
- "its project and its to-dos move under the new project). The original is gone and this " +
1332
- "cannot be undone. Returns the new project's uuid.",
1333
- inputSchema: {
1334
- uuid: z.string().describe("The to-do or heading to convert"),
1456
+ .describe("create (project): initial child to-do titles"),
1457
+ frequency: z
1458
+ .enum(["daily", "weekly", "monthly", "yearly"])
1459
+ .optional()
1460
+ .describe("start/reschedule/create: how often it repeats"),
1461
+ interval: z
1462
+ .number()
1463
+ .int()
1464
+ .min(1)
1465
+ .max(99)
1466
+ .optional()
1467
+ .describe("start/reschedule/create: every N units (1–99)"),
1468
+ after_completion: repeatRuleShape.after_completion,
1469
+ weekdays: repeatRuleShape.weekdays,
1470
+ monthly_day: repeatRuleShape.monthly_day,
1471
+ monthly_weekday: repeatRuleShape.monthly_weekday,
1472
+ monthly_ordinal: repeatRuleShape.monthly_ordinal,
1473
+ yearly_month: repeatRuleShape.yearly_month,
1474
+ ends_after: repeatRuleShape.ends_after,
1475
+ ends_on: repeatRuleShape.ends_on,
1476
+ reminder: repeatRuleShape.reminder,
1477
+ deadline: repeatRuleShape.deadline,
1478
+ start_days_earlier: repeatRuleShape.start_days_earlier,
1335
1479
  ...driveGuiShape,
1336
1480
  ...dryRunShape,
1337
1481
  },
1338
1482
  annotations: DESTRUCTIVE,
1339
1483
  }, async (args) => guard(async () => {
1340
- const item = getClient().read.byUuid(args.uuid);
1341
- if (item === null)
1342
- throw new RangeError(`no item with uuid ${args.uuid}`);
1343
- const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
1344
- return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
1484
+ const c = getClient();
1485
+ const opts = writeOptions(args);
1486
+ const { frequency, interval } = args;
1487
+ if (args.action === "create") {
1488
+ if (args.scope !== "project")
1489
+ return usage('action "create" requires scope "project"');
1490
+ if (args.title === undefined)
1491
+ return usage('action "create" requires title');
1492
+ if (frequency === undefined || interval === undefined) {
1493
+ return usage('action "create" requires frequency and interval');
1494
+ }
1495
+ return mutationResult(await c.write.createRepeatingProject({
1496
+ title: args.title,
1497
+ ...(args.notes !== undefined && { notes: args.notes }),
1498
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1499
+ ...(args.project_deadline !== undefined && { deadline: args.project_deadline }),
1500
+ ...(args.todos !== undefined && { todos: args.todos }),
1501
+ frequency,
1502
+ interval,
1503
+ }, opts));
1504
+ }
1505
+ if (args.uuid === undefined)
1506
+ return usage(`action "${args.action}" requires uuid`);
1507
+ if (args.action === "pause" || args.action === "resume") {
1508
+ const op = args.scope === "todo"
1509
+ ? args.action === "pause"
1510
+ ? "todo.pause-repeat"
1511
+ : "todo.resume-repeat"
1512
+ : args.action === "pause"
1513
+ ? "project.pause-repeat"
1514
+ : "project.resume-repeat";
1515
+ return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
1516
+ }
1517
+ // start | reschedule
1518
+ if (frequency === undefined || interval === undefined) {
1519
+ return usage(`action "${args.action}" requires frequency and interval`);
1520
+ }
1521
+ const extras = repeatExtras(args, frequency);
1522
+ if (args.scope === "todo") {
1523
+ const op = args.action === "start" ? "todo.make-repeating" : "todo.reschedule-repeat";
1524
+ return mutationResult(await c.write.run(op, { uuid: args.uuid, frequency, interval, ...extras }, opts));
1525
+ }
1526
+ // scope project
1527
+ if (args.action === "reschedule") {
1528
+ return mutationResult(await c.write.run("project.reschedule-repeat", { uuid: args.uuid, frequency, interval, ...extras }, opts));
1529
+ }
1530
+ // scope project, action start → the dedicated make-repeating method
1531
+ return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
1345
1532
  }));
1346
1533
  server.registerTool("duplicate_item", {
1347
1534
  description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
@@ -1365,109 +1552,23 @@ export function createThingsMcpServer(options = {}) {
1365
1552
  when: whenSchema,
1366
1553
  deadline: z.string().optional().describe(DATE_FORMAT),
1367
1554
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1368
- ...dryRunShape,
1369
- },
1370
- annotations: NON_DESTRUCTIVE,
1371
- }, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
1372
- title: args.title,
1373
- ...(args.notes !== undefined && { notes: args.notes }),
1374
- ...(args.area !== undefined && { area: containerRef(args.area) }),
1375
- ...(args.when !== undefined && { when: args.when }),
1376
- ...(args.deadline !== undefined && { deadline: args.deadline }),
1377
- ...(args.todos !== undefined && { todos: args.todos }),
1378
- }, writeOptions(args)))));
1379
- server.registerTool("update_project", {
1380
- description: "Update a project's title, notes, schedule, reminder, or deadline. " +
1381
- "append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
1382
- "Changing the schedule keeps an existing reminder unless the call sets a new one. " +
1383
- "clear_reminder works while the project is scheduled for today or this evening; a " +
1384
- "reminder on a future date can only be changed, not cleared.",
1385
- inputSchema: {
1386
- uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
1387
- title: z.string().optional(),
1388
- notes: z.string().optional().describe("Replaces the whole notes body"),
1389
- append_notes: z.string().optional(),
1390
- prepend_notes: z.string().optional(),
1391
- when: whenSchema,
1392
- reminder: z.string().optional().describe(REMINDER_FORMAT),
1393
- clear_reminder: z.boolean().optional(),
1394
- deadline: z.string().optional().describe(DATE_FORMAT),
1395
- clear_deadline: z.boolean().optional(),
1555
+ ...tzShape,
1396
1556
  ...dryRunShape,
1397
1557
  },
1398
1558
  annotations: NON_DESTRUCTIVE,
1399
1559
  }, async (args) => guard(async () => {
1400
- const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
1401
- if (notesModes.length > 1) {
1402
- return usage("notes, append_notes, prepend_notes are exclusive");
1403
- }
1404
- if (args.reminder !== undefined && args.clear_reminder === true) {
1405
- return usage("pass at most one of reminder / clear_reminder");
1406
- }
1407
- if (args.deadline !== undefined && args.clear_deadline === true) {
1408
- return usage("pass at most one of deadline / clear_deadline");
1409
- }
1410
- return mutationResult(await getClient().write.updateProject(args.uuid, {
1411
- ...(args.title !== undefined && { title: args.title }),
1560
+ const badZone = badTz(args.tz);
1561
+ if (badZone !== null)
1562
+ return badZone;
1563
+ return mutationResult(await getClient().write.addProject({
1564
+ title: args.title,
1412
1565
  ...(args.notes !== undefined && { notes: args.notes }),
1413
- ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
1414
- ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
1566
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
1415
1567
  ...(args.when !== undefined && { when: args.when }),
1416
- ...(args.reminder !== undefined && { reminder: args.reminder }),
1417
- ...(args.clear_reminder === true && { reminder: null }),
1418
1568
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1419
- ...(args.clear_deadline === true && { deadline: null }),
1569
+ ...(args.todos !== undefined && { todos: args.todos }),
1420
1570
  }, writeOptions(args)));
1421
1571
  }));
1422
- server.registerTool("set_project_status", {
1423
- description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
1424
- "children policy: 'require-resolved' errors if open to-dos remain; " +
1425
- "'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
1426
- "never alters already-completed children). status 'open' reopens a completed or " +
1427
- "canceled project; its children stay completed/canceled unless restore_children " +
1428
- "also reopens the ones that were resolved together with the project.",
1429
- inputSchema: {
1430
- uuid: z.string().describe(`The project (${REF_FORMAT})`),
1431
- status: z.enum(["completed", "canceled", "open"]),
1432
- children: z
1433
- .enum(["require-resolved", "auto-complete", "auto-cancel"])
1434
- .optional()
1435
- .describe("Required for completed/canceled: what to do with the project's open to-dos"),
1436
- restore_children: z
1437
- .boolean()
1438
- .optional()
1439
- .describe("open only: also reopen the to-dos that were resolved with the project"),
1440
- ...dryRunShape,
1441
- },
1442
- annotations: NON_DESTRUCTIVE,
1443
- }, async (args) => guard(async () => {
1444
- const c = getClient();
1445
- if (args.status !== "open" && args.restore_children !== undefined) {
1446
- return usage("restore_children applies only to status 'open'");
1447
- }
1448
- if (args.status === "completed") {
1449
- if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1450
- return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1451
- }
1452
- return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
1453
- }
1454
- if (args.status === "canceled") {
1455
- if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1456
- return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1457
- }
1458
- return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
1459
- }
1460
- if (args.children !== undefined) {
1461
- return usage("children applies only to status 'completed' or 'canceled'");
1462
- }
1463
- const outcome = await c.write.reopenProject(args.uuid, {
1464
- ...writeOptions(args),
1465
- ...(args.restore_children === true && { restoreChildren: true }),
1466
- });
1467
- return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
1468
- ? jsonResult(outcome)
1469
- : mutationResult(outcome.project);
1470
- }));
1471
1572
  server.registerTool("move_project", {
1472
1573
  description: "Move a project into an area, or detach it from its current area. Pass exactly " +
1473
1574
  "one of area / detach. The project's status and schedule are unaffected.",
@@ -1498,43 +1599,6 @@ export function createThingsMcpServer(options = {}) {
1498
1599
  },
1499
1600
  annotations: NON_DESTRUCTIVE,
1500
1601
  }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
1501
- server.registerTool("update_area", {
1502
- description: "Rename an area and/or replace its tags (the full set). Tags must exist unless " +
1503
- "create_tags is set.",
1504
- inputSchema: {
1505
- target: z.string().describe(`Area to update (${REF_FORMAT})`),
1506
- title: z.string().optional().describe("New name"),
1507
- tags: z
1508
- .array(z.string())
1509
- .optional()
1510
- .describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
1511
- ...createTagsShape,
1512
- ...dryRunShape,
1513
- },
1514
- annotations: NON_DESTRUCTIVE,
1515
- }, async (args) => guard(async () => {
1516
- if (args.title === undefined && args.tags === undefined) {
1517
- return usage("pass title and/or tags");
1518
- }
1519
- return mutationResult(await getClient().write.updateArea(args.target, {
1520
- ...(args.title !== undefined && { title: args.title }),
1521
- ...(args.tags !== undefined && { tags: args.tags }),
1522
- }, writeOptions(args)));
1523
- }));
1524
- server.registerTool("delete_area", {
1525
- description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
1526
- "undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
1527
- "projects remain, no longer assigned to any area.",
1528
- inputSchema: {
1529
- target: z.string().describe(`Area to delete (${REF_FORMAT})`),
1530
- dangerously_permanent: z
1531
- .boolean()
1532
- .optional()
1533
- .describe("Confirm permanent, unrecoverable deletion"),
1534
- ...dryRunShape,
1535
- },
1536
- annotations: DESTRUCTIVE,
1537
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
1538
1602
  // ------------------------------------------------------------------ tags
1539
1603
  server.registerTool("add_tag", {
1540
1604
  description: "Create a tag, optionally nested under an existing parent tag.",
@@ -1545,61 +1609,6 @@ export function createThingsMcpServer(options = {}) {
1545
1609
  },
1546
1610
  annotations: NON_DESTRUCTIVE,
1547
1611
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
1548
- server.registerTool("update_tag", {
1549
- description: "Rename a tag (existing assignments follow the rename), nest it under another " +
1550
- "existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
1551
- "parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
1552
- inputSchema: {
1553
- target: z.string().describe(`Tag to update (${REF_FORMAT})`),
1554
- title: z.string().optional().describe("New name"),
1555
- parent: z.string().optional().describe("Existing tag to nest under"),
1556
- unnest: z.boolean().optional().describe("Move the tag to the top level"),
1557
- shortcut: z.string().optional().describe("Keyboard shortcut character"),
1558
- clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
1559
- ...dryRunShape,
1560
- },
1561
- annotations: NON_DESTRUCTIVE,
1562
- }, async (args) => guard(async () => {
1563
- if (args.title === undefined &&
1564
- args.parent === undefined &&
1565
- args.unnest === undefined &&
1566
- args.shortcut === undefined &&
1567
- args.clear_shortcut === undefined) {
1568
- return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
1569
- }
1570
- if (args.parent !== undefined && args.unnest === true) {
1571
- return usage("parent and unnest are exclusive");
1572
- }
1573
- if (args.shortcut !== undefined && args.clear_shortcut === true) {
1574
- return usage("shortcut and clear_shortcut are exclusive");
1575
- }
1576
- return mutationResult(await getClient().write.updateTag(args.target, {
1577
- ...(args.title !== undefined && { title: args.title }),
1578
- ...(args.parent !== undefined && { parent: args.parent }),
1579
- ...(args.unnest === true && { unnest: true }),
1580
- ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
1581
- ...(args.clear_shortcut === true && { clearShortcut: true }),
1582
- }, writeOptions(args)));
1583
- }));
1584
- server.registerTool("delete_tag", {
1585
- description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
1586
- "requires dangerously_permanent. The tag is removed from every item. If the tag " +
1587
- "has nested child tags they are ALL permanently deleted with it — pass " +
1588
- "acknowledge_tag_subtree to confirm.",
1589
- inputSchema: {
1590
- target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
1591
- dangerously_permanent: z
1592
- .boolean()
1593
- .optional()
1594
- .describe("Confirm permanent, unrecoverable deletion"),
1595
- acknowledge_tag_subtree: z
1596
- .boolean()
1597
- .optional()
1598
- .describe("Confirm permanent deletion of ALL nested child tags too"),
1599
- ...dryRunShape,
1600
- },
1601
- annotations: DESTRUCTIVE,
1602
- }, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
1603
1612
  // -------------------------------------------------- generic + discovery
1604
1613
  server.registerTool("run_operation", {
1605
1614
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
@@ -1684,7 +1693,10 @@ export function createThingsMcpServer(options = {}) {
1684
1693
  "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
1685
1694
  "via `things config set allow-experimental true`. This Evening and " +
1686
1695
  `scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
1687
- "to-dos and projects are ordered separately — one kind per call.",
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.",
1688
1700
  inputSchema: {
1689
1701
  scope: z.enum([
1690
1702
  "today",
@@ -1695,22 +1707,54 @@ export function createThingsMcpServer(options = {}) {
1695
1707
  "headings",
1696
1708
  "area",
1697
1709
  "projects",
1710
+ "areas",
1698
1711
  ]),
1699
1712
  container: z
1700
1713
  .string()
1701
1714
  .optional()
1702
1715
  .describe(`Project/area (${REF_FORMAT}) — required for those scopes`),
1703
- uuids: z.array(z.string()).describe("Desired order, top first (may be a subset)"),
1716
+ uuids: z
1717
+ .array(z.string())
1718
+ .optional()
1719
+ .describe("Desired order, top first (may be a subset) — required for every scope but areas"),
1704
1720
  strategy: z.enum(["native", "bounce"]).optional(),
1721
+ target: z.string().optional().describe(`scope areas: the area to move (${REF_FORMAT})`),
1722
+ before: z
1723
+ .string()
1724
+ .optional()
1725
+ .describe(`scope areas: place it immediately above this area (${REF_FORMAT})`),
1726
+ after: z
1727
+ .string()
1728
+ .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"),
1734
+ ...driveGuiShape,
1705
1735
  ...dryRunShape,
1706
1736
  },
1707
1737
  annotations: NON_DESTRUCTIVE,
1708
- }, async (args) => guard(async () => mutationResult(await getClient().write.reorder({
1709
- scope: args.scope,
1710
- uuids: args.uuids,
1711
- ...(args.container !== undefined && { container: containerRef(args.container) }),
1712
- ...(args.strategy !== undefined && { strategy: args.strategy }),
1713
- }, writeOptions(args)))));
1738
+ }, 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 }),
1756
+ }, writeOptions(args)));
1757
+ }));
1714
1758
  server.registerTool("undo", {
1715
1759
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +
1716
1760
  "cannot be undone here). By default this undoes only changes made through THIS " +