things-api 0.5.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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/bin/things.js +9 -0
  4. package/dist/audit/log.d.ts +10 -0
  5. package/dist/audit/log.js +31 -0
  6. package/dist/audit/log.js.map +1 -0
  7. package/dist/audit/schema.d.ts +37 -0
  8. package/dist/audit/schema.js +9 -0
  9. package/dist/audit/schema.js.map +1 -0
  10. package/dist/cli/commands/doctor.d.ts +9 -0
  11. package/dist/cli/commands/doctor.js +60 -0
  12. package/dist/cli/commands/doctor.js.map +1 -0
  13. package/dist/cli/commands/mcp.d.ts +7 -0
  14. package/dist/cli/commands/mcp.js +26 -0
  15. package/dist/cli/commands/mcp.js.map +1 -0
  16. package/dist/cli/commands/project.d.ts +5 -0
  17. package/dist/cli/commands/project.js +50 -0
  18. package/dist/cli/commands/project.js.map +1 -0
  19. package/dist/cli/commands/reads.d.ts +21 -0
  20. package/dist/cli/commands/reads.js +295 -0
  21. package/dist/cli/commands/reads.js.map +1 -0
  22. package/dist/cli/commands/snapshot.d.ts +7 -0
  23. package/dist/cli/commands/snapshot.js +22 -0
  24. package/dist/cli/commands/snapshot.js.map +1 -0
  25. package/dist/cli/commands/todo.d.ts +6 -0
  26. package/dist/cli/commands/todo.js +43 -0
  27. package/dist/cli/commands/todo.js.map +1 -0
  28. package/dist/cli/commands/writes.d.ts +9 -0
  29. package/dist/cli/commands/writes.js +994 -0
  30. package/dist/cli/commands/writes.js.map +1 -0
  31. package/dist/cli/exit-codes.d.ts +26 -0
  32. package/dist/cli/exit-codes.js +26 -0
  33. package/dist/cli/exit-codes.js.map +1 -0
  34. package/dist/cli/main.d.ts +4 -0
  35. package/dist/cli/main.js +73 -0
  36. package/dist/cli/main.js.map +1 -0
  37. package/dist/cli/output.d.ts +42 -0
  38. package/dist/cli/output.js +16 -0
  39. package/dist/cli/output.js.map +1 -0
  40. package/dist/cli/style.d.ts +6 -0
  41. package/dist/cli/style.js +25 -0
  42. package/dist/cli/style.js.map +1 -0
  43. package/dist/client.d.ts +184 -0
  44. package/dist/client.js +187 -0
  45. package/dist/client.js.map +1 -0
  46. package/dist/config.d.ts +31 -0
  47. package/dist/config.js +76 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/contracts.d.ts +79 -0
  50. package/dist/contracts.js +46 -0
  51. package/dist/contracts.js.map +1 -0
  52. package/dist/db/baselines/db-v26.d.ts +8 -0
  53. package/dist/db/baselines/db-v26.js +16 -0
  54. package/dist/db/baselines/db-v26.js.map +1 -0
  55. package/dist/db/baselines/index.d.ts +3 -0
  56. package/dist/db/baselines/index.js +4 -0
  57. package/dist/db/baselines/index.js.map +1 -0
  58. package/dist/db/connection.d.ts +22 -0
  59. package/dist/db/connection.js +40 -0
  60. package/dist/db/connection.js.map +1 -0
  61. package/dist/db/fingerprint.d.ts +42 -0
  62. package/dist/db/fingerprint.js +82 -0
  63. package/dist/db/fingerprint.js.map +1 -0
  64. package/dist/db/locate.d.ts +13 -0
  65. package/dist/db/locate.js +44 -0
  66. package/dist/db/locate.js.map +1 -0
  67. package/dist/db/schema.d.ts +37 -0
  68. package/dist/db/schema.js +81 -0
  69. package/dist/db/schema.js.map +1 -0
  70. package/dist/diagnose.d.ts +67 -0
  71. package/dist/diagnose.js +143 -0
  72. package/dist/diagnose.js.map +1 -0
  73. package/dist/index.d.ts +46 -0
  74. package/dist/index.js +23 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/mcp/server.d.ts +8 -0
  77. package/dist/mcp/server.js +1002 -0
  78. package/dist/mcp/server.js.map +1 -0
  79. package/dist/model/dates.d.ts +38 -0
  80. package/dist/model/dates.js +104 -0
  81. package/dist/model/dates.js.map +1 -0
  82. package/dist/model/entities.d.ts +117 -0
  83. package/dist/model/entities.js +21 -0
  84. package/dist/model/entities.js.map +1 -0
  85. package/dist/model/mappers.d.ts +55 -0
  86. package/dist/model/mappers.js +105 -0
  87. package/dist/model/mappers.js.map +1 -0
  88. package/dist/model/occurrences.d.ts +46 -0
  89. package/dist/model/occurrences.js +164 -0
  90. package/dist/model/occurrences.js.map +1 -0
  91. package/dist/model/recurrence.d.ts +52 -0
  92. package/dist/model/recurrence.js +164 -0
  93. package/dist/model/recurrence.js.map +1 -0
  94. package/dist/paths.d.ts +6 -0
  95. package/dist/paths.js +30 -0
  96. package/dist/paths.js.map +1 -0
  97. package/dist/read/detail.d.ts +8 -0
  98. package/dist/read/detail.js +34 -0
  99. package/dist/read/detail.js.map +1 -0
  100. package/dist/read/project-view.d.ts +34 -0
  101. package/dist/read/project-view.js +87 -0
  102. package/dist/read/project-view.js.map +1 -0
  103. package/dist/read/queries.d.ts +39 -0
  104. package/dist/read/queries.js +134 -0
  105. package/dist/read/queries.js.map +1 -0
  106. package/dist/read/snapshot.d.ts +25 -0
  107. package/dist/read/snapshot.js +39 -0
  108. package/dist/read/snapshot.js.map +1 -0
  109. package/dist/read/tags.d.ts +17 -0
  110. package/dist/read/tags.js +64 -0
  111. package/dist/read/tags.js.map +1 -0
  112. package/dist/read/views.d.ts +152 -0
  113. package/dist/read/views.js +322 -0
  114. package/dist/read/views.js.map +1 -0
  115. package/dist/surface-copy.d.ts +15 -0
  116. package/dist/surface-copy.js +16 -0
  117. package/dist/surface-copy.js.map +1 -0
  118. package/dist/write/automation-probe.d.ts +12 -0
  119. package/dist/write/automation-probe.js +59 -0
  120. package/dist/write/automation-probe.js.map +1 -0
  121. package/dist/write/batch.d.ts +51 -0
  122. package/dist/write/batch.js +97 -0
  123. package/dist/write/batch.js.map +1 -0
  124. package/dist/write/capabilities.d.ts +16 -0
  125. package/dist/write/capabilities.js +16 -0
  126. package/dist/write/capabilities.js.map +1 -0
  127. package/dist/write/commands.d.ts +32 -0
  128. package/dist/write/commands.js +1274 -0
  129. package/dist/write/commands.js.map +1 -0
  130. package/dist/write/environment.d.ts +29 -0
  131. package/dist/write/environment.js +105 -0
  132. package/dist/write/environment.js.map +1 -0
  133. package/dist/write/experimental.d.ts +7 -0
  134. package/dist/write/experimental.js +39 -0
  135. package/dist/write/experimental.js.map +1 -0
  136. package/dist/write/failure-hints.d.ts +32 -0
  137. package/dist/write/failure-hints.js +71 -0
  138. package/dist/write/failure-hints.js.map +1 -0
  139. package/dist/write/guards.d.ts +21 -0
  140. package/dist/write/guards.js +312 -0
  141. package/dist/write/guards.js.map +1 -0
  142. package/dist/write/lock.d.ts +10 -0
  143. package/dist/write/lock.js +70 -0
  144. package/dist/write/lock.js.map +1 -0
  145. package/dist/write/operations.d.ts +262 -0
  146. package/dist/write/operations.js +34 -0
  147. package/dist/write/operations.js.map +1 -0
  148. package/dist/write/pipeline.d.ts +90 -0
  149. package/dist/write/pipeline.js +375 -0
  150. package/dist/write/pipeline.js.map +1 -0
  151. package/dist/write/planner.d.ts +40 -0
  152. package/dist/write/planner.js +57 -0
  153. package/dist/write/planner.js.map +1 -0
  154. package/dist/write/pre-state.d.ts +125 -0
  155. package/dist/write/pre-state.js +299 -0
  156. package/dist/write/pre-state.js.map +1 -0
  157. package/dist/write/reopen.d.ts +29 -0
  158. package/dist/write/reopen.js +63 -0
  159. package/dist/write/reopen.js.map +1 -0
  160. package/dist/write/reorder.d.ts +19 -0
  161. package/dist/write/reorder.js +285 -0
  162. package/dist/write/reorder.js.map +1 -0
  163. package/dist/write/undo.d.ts +53 -0
  164. package/dist/write/undo.js +703 -0
  165. package/dist/write/undo.js.map +1 -0
  166. package/dist/write/vectors/applescript.d.ts +5 -0
  167. package/dist/write/vectors/applescript.js +216 -0
  168. package/dist/write/vectors/applescript.js.map +1 -0
  169. package/dist/write/vectors/registry.d.ts +2 -0
  170. package/dist/write/vectors/registry.js +11 -0
  171. package/dist/write/vectors/registry.js.map +1 -0
  172. package/dist/write/vectors/types.d.ts +48 -0
  173. package/dist/write/vectors/types.js +2 -0
  174. package/dist/write/vectors/types.js.map +1 -0
  175. package/dist/write/vectors/url-scheme.d.ts +3 -0
  176. package/dist/write/vectors/url-scheme.js +176 -0
  177. package/dist/write/vectors/url-scheme.js.map +1 -0
  178. package/dist/write/verify/delta.d.ts +126 -0
  179. package/dist/write/verify/delta.js +281 -0
  180. package/dist/write/verify/delta.js.map +1 -0
  181. package/dist/write/verify/poller.d.ts +24 -0
  182. package/dist/write/verify/poller.js +28 -0
  183. package/dist/write/verify/poller.js.map +1 -0
  184. package/package.json +52 -0
@@ -0,0 +1,1002 @@
1
+ /**
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.
7
+ *
8
+ * Tool descriptions and the server instructions follow the consumer-voice
9
+ * contract in docs/design/surface-copy.md: behavior and side effects only,
10
+ * no pipeline/audit/lab vocabulary (enforced by test/mcp/server.test.ts).
11
+ *
12
+ * Serve over stdio with `things mcp`.
13
+ */
14
+ import { z } from "zod";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { openThings } from "../client.js";
17
+ import { PKG_VERSION } from "../contracts.js";
18
+ import { diagnose } from "../diagnose.js";
19
+ import { DATE_FORMAT, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES } from "../surface-copy.js";
20
+ import { capabilitiesTable } from "../write/capabilities.js";
21
+ import { OPERATION_KINDS } from "../write/operations.js";
22
+ import { BOUNCE_MAX_ITEMS } from "../write/reorder.js";
23
+ function jsonResult(data) {
24
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
25
+ }
26
+ function errorResult(error) {
27
+ return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
28
+ }
29
+ function usage(message) {
30
+ return errorResult({ code: "usage", message });
31
+ }
32
+ /** Map a mutation outcome to an MCP result (errors carry remediation). */
33
+ function mutationResult(result) {
34
+ switch (result.kind) {
35
+ case "ok":
36
+ case "dry-run":
37
+ return jsonResult(result);
38
+ case "blocked":
39
+ return errorResult({
40
+ code: `blocked:${result.hazard ?? result.reason}`,
41
+ message: result.detail,
42
+ ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
43
+ remediation: result.remediation,
44
+ });
45
+ case "verify-failed":
46
+ return errorResult({
47
+ code: `verify-failed:${result.reason}`,
48
+ message: result.detail,
49
+ ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
50
+ ...(result.hint !== undefined && { remediation: result.hint }),
51
+ });
52
+ case "unsupported":
53
+ return errorResult({
54
+ code: "unsupported",
55
+ message: `${result.op} is not supported`,
56
+ remediation: JSON.stringify(result.considered),
57
+ });
58
+ case "bounce-aborted":
59
+ return errorResult({
60
+ code: "bounce-aborted",
61
+ message: result.detail,
62
+ remediation: `placed: ${result.placed.join(",") || "none"}; remaining: ${result.remaining.join(",")}`,
63
+ });
64
+ }
65
+ }
66
+ const READ_ONLY = { readOnlyHint: true };
67
+ const NON_DESTRUCTIVE = { destructiveHint: false };
68
+ const DESTRUCTIVE = { destructiveHint: true };
69
+ const tagFilterShape = {
70
+ tag: z
71
+ .string()
72
+ .optional()
73
+ .describe(`Filter by tag (${REF_FORMAT}); includes items carrying any nested child tag`),
74
+ exact_tag: z.boolean().optional().describe("Match only the named tag, not its nested children"),
75
+ };
76
+ const dryRunShape = {
77
+ dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
78
+ };
79
+ const containerRef = (ref) => ({ uuid: ref, title: ref });
80
+ /** Cap on project titles inlined into the server instructions. */
81
+ const INSTRUCTIONS_MAX_PROJECTS = 100;
82
+ /**
83
+ * Live-inventory preamble: conventions plus the user's actual areas, tags,
84
+ * and open projects, read once at server start so models can reference real
85
+ * names without a discovery round-trip. Degrades to conventions-only when
86
+ * the database is not readable.
87
+ */
88
+ function buildInstructions(getClient) {
89
+ const lines = [
90
+ "This server reads and modifies the user's Things 3 data: to-dos, projects, areas, and tags.",
91
+ "",
92
+ "Conventions:",
93
+ "- Items are identified by uuid (returned by every read tool). Where a parameter says " +
94
+ `"${REF_FORMAT}", projects, areas, and tags may also be referenced by exact name.`,
95
+ "- References must name existing items; unknown or ambiguous references return an error " +
96
+ "rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
97
+ "add_area, add_project).",
98
+ `- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
99
+ `are ${REMINDER_FORMAT}.`,
100
+ "- Every write tool accepts dry_run: true to preview the change without applying it. " +
101
+ "Operations with cascading or permanent effects require the explicit confirmation " +
102
+ "parameter named in their description; refused calls return an error saying what to pass.",
103
+ ];
104
+ try {
105
+ const c = getClient();
106
+ const areas = c.read.areas();
107
+ const tags = c.read.tags();
108
+ const projects = c.read.projects();
109
+ const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
110
+ const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
111
+ const overflow = projects.length - shown.length;
112
+ 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"}` +
113
+ (overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
114
+ }
115
+ catch {
116
+ lines.push("", "The Things database was not readable when this server started, so no area/tag/project " +
117
+ "inventory is included. Run the doctor tool to diagnose, then list_collections for " +
118
+ "the live inventory.");
119
+ }
120
+ return lines.join("\n");
121
+ }
122
+ export function createThingsMcpServer(options = {}) {
123
+ // One lazily-opened client for the server's lifetime; SQLite read
124
+ // snapshots are per-statement, so fresh reads see external commits.
125
+ let client = null;
126
+ const getClient = () => {
127
+ client ??= openThings({
128
+ ...(options.dbPath !== undefined && { dbPath: options.dbPath }),
129
+ ...options.openOptions,
130
+ });
131
+ return client;
132
+ };
133
+ const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
134
+ /** Run a handler, mapping environment/usage throws to tool errors. */
135
+ const guard = async (fn) => {
136
+ try {
137
+ return await fn();
138
+ }
139
+ catch (err) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ const code = err instanceof RangeError ? "usage" : "environment";
142
+ return errorResult({ code, message });
143
+ }
144
+ };
145
+ const writeOptions = (args) => ({
146
+ actor: "mcp",
147
+ ...(args.dry_run === true && { dryRun: true }),
148
+ ...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
149
+ ...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
150
+ ...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
151
+ ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
152
+ ...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
153
+ });
154
+ /** Resolve a uuid to to-do/project for the type-generic item tools. */
155
+ const itemType = (uuid) => {
156
+ const item = getClient().read.byUuid(uuid);
157
+ if (item === null)
158
+ throw new RangeError(`no item with uuid ${uuid}`);
159
+ if (item.type === "heading") {
160
+ throw new RangeError(`${uuid} is a heading — only to-dos and projects can be targeted`);
161
+ }
162
+ return item.type;
163
+ };
164
+ // ------------------------------------------------------------------ reads
165
+ server.registerTool("read_view", {
166
+ description: "Read a Things list as the app presents it: today (split into Today and This " +
167
+ "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
168
+ "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
169
+ "anytime/someday return sidebar-ordered sections (area + items; null area = the " +
170
+ "top-level block); children of someday/future-scheduled projects are excluded " +
171
+ "from anytime — the project row represents them.",
172
+ inputSchema: {
173
+ view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
174
+ ...tagFilterShape,
175
+ active_project_items: z
176
+ .boolean()
177
+ .optional()
178
+ .describe("someday only: also list someday to-dos inside active projects"),
179
+ horizon: z
180
+ .number()
181
+ .int()
182
+ .min(1)
183
+ .max(10)
184
+ .optional()
185
+ .describe("upcoming only: occurrences shown per repeating item (default 1)"),
186
+ limit: z.number().int().min(1).optional().describe("logbook/trash only (default 50)"),
187
+ },
188
+ annotations: READ_ONLY,
189
+ }, async (args) => guard(() => {
190
+ const c = getClient();
191
+ const filter = {
192
+ ...(args.tag !== undefined && { tag: args.tag }),
193
+ ...(args.exact_tag === true && { exactTag: true }),
194
+ };
195
+ switch (args.view) {
196
+ case "today":
197
+ return jsonResult(c.read.today(filter));
198
+ case "inbox":
199
+ return jsonResult(c.read.inbox(filter));
200
+ case "anytime":
201
+ return jsonResult(c.read.anytime(filter));
202
+ case "upcoming":
203
+ return jsonResult(c.read.upcoming({
204
+ ...filter,
205
+ ...(args.horizon !== undefined && { horizon: args.horizon }),
206
+ }));
207
+ case "someday":
208
+ return jsonResult(c.read.someday({
209
+ ...filter,
210
+ ...(args.active_project_items === true && { activeProjectItems: true }),
211
+ }));
212
+ case "logbook":
213
+ return jsonResult(c.read.logbook({ ...filter, ...(args.limit !== undefined && { limit: args.limit }) }));
214
+ case "trash":
215
+ return jsonResult(c.read.trash(args.limit !== undefined ? { limit: args.limit } : {}));
216
+ }
217
+ }));
218
+ server.registerTool("search", {
219
+ description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
220
+ "include more with logged/trashed/all. Scope with project/area/tag — scope " +
221
+ "references must name existing items.",
222
+ inputSchema: {
223
+ query: z.string(),
224
+ ...tagFilterShape,
225
+ project: z
226
+ .string()
227
+ .optional()
228
+ .describe(`Restrict to one project's children (${REF_FORMAT})`),
229
+ area: z
230
+ .string()
231
+ .optional()
232
+ .describe(`Restrict to one area's direct members (${REF_FORMAT})`),
233
+ type: z.enum(["to-do", "project"]).optional(),
234
+ logged: z.boolean().optional().describe("Also include completed/canceled items"),
235
+ trashed: z.boolean().optional().describe("Also include trashed items"),
236
+ all: z.boolean().optional().describe("Everything: open + logged + trashed"),
237
+ limit: z.number().int().min(1).optional().describe("Default 50"),
238
+ },
239
+ annotations: READ_ONLY,
240
+ }, async (args) => guard(() => jsonResult(getClient().read.search(args.query, {
241
+ ...(args.tag !== undefined && { tag: args.tag }),
242
+ ...(args.exact_tag === true && { exactTag: true }),
243
+ ...(args.project !== undefined && { project: args.project }),
244
+ ...(args.area !== undefined && { area: args.area }),
245
+ ...(args.type !== undefined && { type: args.type }),
246
+ ...(args.logged === true && { logged: true }),
247
+ ...(args.trashed === true && { trashed: true }),
248
+ ...(args.all === true && { all: true }),
249
+ ...(args.limit !== undefined && { limit: args.limit }),
250
+ }))));
251
+ server.registerTool("changes_since", {
252
+ description: "List items created or modified since a moment — including trashed, logged, and " +
253
+ "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
254
+ "areas, and checklist items do not mark the containing item as modified.",
255
+ inputSchema: {
256
+ since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
257
+ limit: z.number().int().min(1).optional().describe("Default 200"),
258
+ },
259
+ annotations: READ_ONLY,
260
+ }, async (args) => guard(() => {
261
+ const since = new Date(args.since);
262
+ if (Number.isNaN(since.getTime())) {
263
+ return usage(`since is not a parseable date: ${args.since}`);
264
+ }
265
+ return jsonResult(getClient().read.changes({
266
+ since,
267
+ ...(args.limit !== undefined && { limit: args.limit }),
268
+ }));
269
+ }));
270
+ server.registerTool("get_item", {
271
+ description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
272
+ "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
273
+ "project/area/heading.",
274
+ inputSchema: { uuid: z.string() },
275
+ annotations: READ_ONLY,
276
+ }, async (args) => guard(() => {
277
+ const item = getClient().read.byUuid(args.uuid);
278
+ return item === null
279
+ ? errorResult({ code: "not-found", message: `no record with uuid ${args.uuid}` })
280
+ : jsonResult(item);
281
+ }));
282
+ server.registerTool("get_project", {
283
+ description: "One project's full contents: metadata plus its to-dos grouped under their headings.",
284
+ inputSchema: { uuid: z.string().describe("Project uuid") },
285
+ annotations: READ_ONLY,
286
+ }, async (args) => guard(() => jsonResult(getClient().read.projectView(args.uuid))));
287
+ server.registerTool("list_collections", {
288
+ description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
289
+ "refresh the inventory summarized in the server instructions.",
290
+ inputSchema: { kind: z.enum(["projects", "areas", "tags"]) },
291
+ annotations: READ_ONLY,
292
+ }, async (args) => guard(() => {
293
+ const c = getClient();
294
+ return jsonResult(args.kind === "projects"
295
+ ? c.read.projects()
296
+ : args.kind === "areas"
297
+ ? c.read.areas()
298
+ : c.read.tags());
299
+ }));
300
+ // ---------------------------------------------------------------- to-dos
301
+ const whenSchema = z.string().optional().describe(WHEN_VALUES);
302
+ server.registerTool("add_todo", {
303
+ description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
304
+ "deadline, tag it, give it a checklist, and place it in a project or area " +
305
+ "(optionally under an existing heading). Tags must name existing tags. A reminder " +
306
+ "requires when = today, evening, or a date. Adding into a completed or canceled " +
307
+ "project reopens that project — pass acknowledge_project_reopen to confirm.",
308
+ inputSchema: {
309
+ title: z.string(),
310
+ notes: z.string().optional(),
311
+ when: whenSchema,
312
+ reminder: z.string().optional().describe(REMINDER_FORMAT),
313
+ deadline: z.string().optional().describe(DATE_FORMAT),
314
+ tags: z.array(z.string()).optional().describe("Existing tag names"),
315
+ checklist_items: z.array(z.string()).optional(),
316
+ project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
317
+ area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
318
+ heading: z.string().optional().describe("Existing heading in the destination project"),
319
+ acknowledge_project_reopen: z
320
+ .boolean()
321
+ .optional()
322
+ .describe("Confirm adding into a completed/canceled project (this reopens it)"),
323
+ ...dryRunShape,
324
+ },
325
+ annotations: NON_DESTRUCTIVE,
326
+ }, async (args) => guard(async () => mutationResult(await getClient().write.addTodo({
327
+ title: args.title,
328
+ ...(args.notes !== undefined && { notes: args.notes }),
329
+ ...(args.when !== undefined && { when: args.when }),
330
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
331
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
332
+ ...(args.tags !== undefined && { tags: args.tags }),
333
+ ...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
334
+ ...(args.project !== undefined && { project: containerRef(args.project) }),
335
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
336
+ ...(args.heading !== undefined && { heading: args.heading }),
337
+ }, writeOptions(args)))));
338
+ server.registerTool("update_todo", {
339
+ description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
340
+ "append_notes/prepend_notes add a line to the existing notes (exclusive with " +
341
+ "notes). Changing the schedule keeps an existing reminder unless the call sets a " +
342
+ "new one. clear_reminder works while the to-do is scheduled for today or this " +
343
+ "evening; a reminder on a future date can only be changed, not cleared " +
344
+ "(re-schedule to today first). Schedule and deadline changes are not available " +
345
+ "for repeating to-dos.",
346
+ inputSchema: {
347
+ uuid: z.string(),
348
+ title: z.string().optional(),
349
+ notes: z.string().optional().describe("Replaces the whole notes body"),
350
+ append_notes: z.string().optional(),
351
+ prepend_notes: z.string().optional(),
352
+ when: whenSchema,
353
+ reminder: z.string().optional().describe(REMINDER_FORMAT),
354
+ clear_reminder: z.boolean().optional(),
355
+ deadline: z.string().optional().describe(DATE_FORMAT),
356
+ clear_deadline: z.boolean().optional(),
357
+ ...dryRunShape,
358
+ },
359
+ annotations: NON_DESTRUCTIVE,
360
+ }, async (args) => guard(async () => {
361
+ const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
362
+ if (notesModes.length > 1) {
363
+ return usage("notes, append_notes, prepend_notes are exclusive");
364
+ }
365
+ if (args.reminder !== undefined && args.clear_reminder === true) {
366
+ return usage("pass at most one of reminder / clear_reminder");
367
+ }
368
+ if (args.deadline !== undefined && args.clear_deadline === true) {
369
+ return usage("pass at most one of deadline / clear_deadline");
370
+ }
371
+ return mutationResult(await getClient().write.updateTodo(args.uuid, {
372
+ ...(args.title !== undefined && { title: args.title }),
373
+ ...(args.notes !== undefined && { notes: args.notes }),
374
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
375
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
376
+ ...(args.when !== undefined && { when: args.when }),
377
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
378
+ ...(args.clear_reminder === true && { reminder: null }),
379
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
380
+ ...(args.clear_deadline === true && { deadline: null }),
381
+ }, writeOptions(args)));
382
+ }));
383
+ server.registerTool("set_todo_status", {
384
+ description: "Set a to-do's status: completed, canceled, or open (reopening a " +
385
+ "completed/canceled to-do). Not available for repeating to-dos.",
386
+ inputSchema: {
387
+ uuid: z.string(),
388
+ status: z.enum(["completed", "canceled", "open"]),
389
+ ...dryRunShape,
390
+ },
391
+ annotations: NON_DESTRUCTIVE,
392
+ }, async (args) => guard(async () => {
393
+ const c = getClient();
394
+ const opts = writeOptions(args);
395
+ return mutationResult(args.status === "completed"
396
+ ? await c.write.completeTodo(args.uuid, opts)
397
+ : args.status === "canceled"
398
+ ? await c.write.cancelTodo(args.uuid, opts)
399
+ : await c.write.reopenTodo(args.uuid, opts));
400
+ }));
401
+ server.registerTool("move_todo", {
402
+ description: "Move a to-do. Pass exactly one destination: a project and/or area (optionally an " +
403
+ "existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
404
+ "removes any schedule; detach removes the project/area/heading assignment while " +
405
+ "keeping the schedule. Moving into a completed or canceled project reopens that " +
406
+ "project — pass acknowledge_project_reopen to confirm.",
407
+ inputSchema: {
408
+ uuid: z.string(),
409
+ project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
410
+ area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
411
+ heading: z.string().optional().describe("Existing heading in the destination project"),
412
+ to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
413
+ detach: z
414
+ .boolean()
415
+ .optional()
416
+ .describe("Remove the project/area/heading assignment, keeping the schedule"),
417
+ acknowledge_project_reopen: z
418
+ .boolean()
419
+ .optional()
420
+ .describe("Confirm moving into a completed/canceled project (this reopens it)"),
421
+ ...dryRunShape,
422
+ },
423
+ annotations: NON_DESTRUCTIVE,
424
+ }, async (args) => guard(async () => {
425
+ const dest = args.project !== undefined || args.area !== undefined || args.heading !== undefined;
426
+ const modes = [dest, args.to_inbox === true, args.detach === true].filter(Boolean).length;
427
+ if (modes !== 1) {
428
+ return usage("pass exactly one destination: project/area/heading, to_inbox, or detach");
429
+ }
430
+ return mutationResult(await getClient().write.moveTodo(args.uuid, {
431
+ ...(args.project !== undefined && { project: containerRef(args.project) }),
432
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
433
+ ...(args.heading !== undefined && { heading: args.heading }),
434
+ ...(args.to_inbox === true && { inbox: true }),
435
+ ...(args.detach === true && { detach: true }),
436
+ }, {
437
+ ...writeOptions(args),
438
+ ...(args.to_inbox === true && { vector: "applescript" }),
439
+ }));
440
+ }));
441
+ server.registerTool("set_tags", {
442
+ description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
443
+ "the given list — an empty list removes all tags; mode 'add' merges with the current " +
444
+ "tags. Tags must name existing tags (create them first with add_tag).",
445
+ inputSchema: {
446
+ uuid: z.string(),
447
+ tags: z.array(z.string()).describe("Existing tag names"),
448
+ mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
449
+ ...dryRunShape,
450
+ },
451
+ annotations: NON_DESTRUCTIVE,
452
+ }, async (args) => guard(async () => {
453
+ const c = getClient();
454
+ const isProject = itemType(args.uuid) === "project";
455
+ const opts = writeOptions(args);
456
+ if (args.mode === "add") {
457
+ return mutationResult(isProject
458
+ ? await c.write.addProjectTags(args.uuid, args.tags, opts)
459
+ : await c.write.addTags(args.uuid, args.tags, opts));
460
+ }
461
+ return mutationResult(isProject
462
+ ? await c.write.setProjectTags(args.uuid, args.tags, opts)
463
+ : await c.write.setTags(args.uuid, args.tags, opts));
464
+ }));
465
+ server.registerTool("edit_checklist", {
466
+ description: "Edit a to-do's checklist. The single-item actions add / remove / check / uncheck " +
467
+ "/ rename / move change one item — matched by exact title — and leave every other " +
468
+ "item and its checked state untouched. The replace action swaps in a whole new " +
469
+ "list (items), discarding the existing items and their checked states — pass " +
470
+ "acknowledge_checklist_reset to confirm when items already exist; entries may be " +
471
+ "strings or {title, completed} objects to create items pre-checked. Positions are " +
472
+ "1-based.",
473
+ inputSchema: {
474
+ uuid: z.string(),
475
+ action: z.enum(["add", "remove", "check", "uncheck", "rename", "move", "replace"]),
476
+ title: z.string().optional().describe("add: the new item's title; rename: the new title"),
477
+ item: z
478
+ .string()
479
+ .optional()
480
+ .describe("remove/check/uncheck/rename/move: the existing item's exact title"),
481
+ at: z
482
+ .number()
483
+ .int()
484
+ .min(1)
485
+ .optional()
486
+ .describe("add: 1-based insert position (default: end)"),
487
+ to: z.number().int().min(1).optional().describe("move: 1-based target position"),
488
+ items: z
489
+ .array(z.union([
490
+ z.string(),
491
+ z.object({ title: z.string(), completed: z.boolean().optional() }),
492
+ ]))
493
+ .optional()
494
+ .describe("replace: the full new checklist, in order"),
495
+ acknowledge_checklist_reset: z
496
+ .boolean()
497
+ .optional()
498
+ .describe("replace: confirm discarding the existing items and their checked states"),
499
+ ...dryRunShape,
500
+ },
501
+ annotations: NON_DESTRUCTIVE,
502
+ }, async (args) => guard(async () => {
503
+ const c = getClient();
504
+ if (args.action === "replace") {
505
+ if (args.items === undefined)
506
+ return usage('action "replace" requires items');
507
+ const items = args.items.map((i) => typeof i === "string"
508
+ ? i
509
+ : { title: i.title, ...(i.completed !== undefined && { completed: i.completed }) });
510
+ return mutationResult(await c.write.run("todo.replace-checklist", { uuid: args.uuid, items }, writeOptions(args)));
511
+ }
512
+ let edit;
513
+ switch (args.action) {
514
+ case "add":
515
+ if (args.title === undefined)
516
+ return usage('action "add" requires title');
517
+ edit = {
518
+ action: "add",
519
+ title: args.title,
520
+ ...(args.at !== undefined && { at: args.at }),
521
+ };
522
+ break;
523
+ case "remove":
524
+ case "check":
525
+ case "uncheck":
526
+ if (args.item === undefined)
527
+ return usage(`action "${args.action}" requires item`);
528
+ edit = { action: args.action, item: args.item };
529
+ break;
530
+ case "rename":
531
+ if (args.item === undefined || args.title === undefined) {
532
+ return usage('action "rename" requires item and title');
533
+ }
534
+ edit = { action: "rename", item: args.item, title: args.title };
535
+ break;
536
+ case "move":
537
+ if (args.item === undefined || args.to === undefined) {
538
+ return usage('action "move" requires item and to');
539
+ }
540
+ edit = { action: "move", item: args.item, to: args.to };
541
+ break;
542
+ }
543
+ return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
544
+ }));
545
+ // ------------------------------------------------- to-dos AND projects
546
+ server.registerTool("delete_item", {
547
+ description: "Move a to-do or project to the Trash (recoverable via restore_item until the " +
548
+ "Trash is emptied). Deleting a project sends its to-dos to the Trash with it. Not " +
549
+ "available for repeating to-dos.",
550
+ inputSchema: { uuid: z.string(), ...dryRunShape },
551
+ annotations: DESTRUCTIVE,
552
+ }, async (args) => guard(async () => {
553
+ const c = getClient();
554
+ return mutationResult(itemType(args.uuid) === "to-do"
555
+ ? await c.write.deleteTodo(args.uuid, writeOptions(args))
556
+ : await c.write.deleteProject(args.uuid, writeOptions(args)));
557
+ }));
558
+ server.registerTool("restore_item", {
559
+ description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
560
+ "previous schedule or project/area. A project is restored in place: its schedule, " +
561
+ "area, and children come back exactly as they were.",
562
+ inputSchema: { uuid: z.string(), ...dryRunShape },
563
+ annotations: NON_DESTRUCTIVE,
564
+ }, async (args) => guard(async () => {
565
+ const c = getClient();
566
+ return mutationResult(itemType(args.uuid) === "to-do"
567
+ ? await c.write.restoreTodo(args.uuid, writeOptions(args))
568
+ : await c.write.restoreProject(args.uuid, writeOptions(args)));
569
+ }));
570
+ server.registerTool("backdate_todo", {
571
+ description: "Rewrite a to-do's completion and/or creation timestamp to noon (local) on the " +
572
+ "given date. completion_date requires the to-do to already be completed or " +
573
+ "canceled; the Logbook re-sorts to the new date.",
574
+ inputSchema: {
575
+ uuid: z.string(),
576
+ completion_date: z.string().optional().describe(DATE_FORMAT),
577
+ creation_date: z.string().optional().describe(DATE_FORMAT),
578
+ ...dryRunShape,
579
+ },
580
+ annotations: NON_DESTRUCTIVE,
581
+ }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
582
+ ...(args.completion_date !== undefined && { completionDate: args.completion_date }),
583
+ ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
584
+ }, writeOptions(args)))));
585
+ server.registerTool("add_logged_todo", {
586
+ description: "Create a to-do directly in the Logbook: completed, with the given past " +
587
+ "completion date (and optionally a past creation date). For importing history " +
588
+ "from another system.",
589
+ inputSchema: {
590
+ title: z.string(),
591
+ completion_date: z.string().describe(DATE_FORMAT),
592
+ creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
593
+ notes: z.string().optional(),
594
+ ...dryRunShape,
595
+ },
596
+ annotations: NON_DESTRUCTIVE,
597
+ }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
598
+ title: args.title,
599
+ completionDate: args.completion_date,
600
+ ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
601
+ ...(args.notes !== undefined && { notes: args.notes }),
602
+ }, writeOptions(args)))));
603
+ server.registerTool("duplicate_item", {
604
+ description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
605
+ "includes its children. Not available for repeating items.",
606
+ inputSchema: { uuid: z.string(), ...dryRunShape },
607
+ annotations: NON_DESTRUCTIVE,
608
+ }, async (args) => guard(async () => {
609
+ const c = getClient();
610
+ return mutationResult(itemType(args.uuid) === "to-do"
611
+ ? await c.write.duplicateTodo(args.uuid, writeOptions(args))
612
+ : await c.write.duplicateProject(args.uuid, writeOptions(args)));
613
+ }));
614
+ // -------------------------------------------------------------- projects
615
+ server.registerTool("add_project", {
616
+ description: "Create a project and return its uuid. Optionally place it in an area, schedule " +
617
+ "it, set a deadline, and seed it with initial to-dos.",
618
+ inputSchema: {
619
+ title: z.string(),
620
+ notes: z.string().optional(),
621
+ area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
622
+ when: whenSchema,
623
+ deadline: z.string().optional().describe(DATE_FORMAT),
624
+ todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
625
+ ...dryRunShape,
626
+ },
627
+ annotations: NON_DESTRUCTIVE,
628
+ }, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
629
+ title: args.title,
630
+ ...(args.notes !== undefined && { notes: args.notes }),
631
+ ...(args.area !== undefined && { area: containerRef(args.area) }),
632
+ ...(args.when !== undefined && { when: args.when }),
633
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
634
+ ...(args.todos !== undefined && { todos: args.todos }),
635
+ }, writeOptions(args)))));
636
+ server.registerTool("update_project", {
637
+ description: "Update a project's title, notes, schedule, reminder, or deadline. " +
638
+ "append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
639
+ "Changing the schedule keeps an existing reminder unless the call sets a new one. " +
640
+ "clear_reminder works while the project is scheduled for today or this evening; a " +
641
+ "reminder on a future date can only be changed, not cleared.",
642
+ inputSchema: {
643
+ uuid: z.string(),
644
+ title: z.string().optional(),
645
+ notes: z.string().optional().describe("Replaces the whole notes body"),
646
+ append_notes: z.string().optional(),
647
+ prepend_notes: z.string().optional(),
648
+ when: whenSchema,
649
+ reminder: z.string().optional().describe(REMINDER_FORMAT),
650
+ clear_reminder: z.boolean().optional(),
651
+ deadline: z.string().optional().describe(DATE_FORMAT),
652
+ clear_deadline: z.boolean().optional(),
653
+ ...dryRunShape,
654
+ },
655
+ annotations: NON_DESTRUCTIVE,
656
+ }, async (args) => guard(async () => {
657
+ const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
658
+ if (notesModes.length > 1) {
659
+ return usage("notes, append_notes, prepend_notes are exclusive");
660
+ }
661
+ if (args.reminder !== undefined && args.clear_reminder === true) {
662
+ return usage("pass at most one of reminder / clear_reminder");
663
+ }
664
+ if (args.deadline !== undefined && args.clear_deadline === true) {
665
+ return usage("pass at most one of deadline / clear_deadline");
666
+ }
667
+ return mutationResult(await getClient().write.updateProject(args.uuid, {
668
+ ...(args.title !== undefined && { title: args.title }),
669
+ ...(args.notes !== undefined && { notes: args.notes }),
670
+ ...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
671
+ ...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
672
+ ...(args.when !== undefined && { when: args.when }),
673
+ ...(args.reminder !== undefined && { reminder: args.reminder }),
674
+ ...(args.clear_reminder === true && { reminder: null }),
675
+ ...(args.deadline !== undefined && { deadline: args.deadline }),
676
+ ...(args.clear_deadline === true && { deadline: null }),
677
+ }, writeOptions(args)));
678
+ }));
679
+ server.registerTool("set_project_status", {
680
+ description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
681
+ "children policy: 'require-resolved' errors if open to-dos remain; " +
682
+ "'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
683
+ "never alters already-completed children). status 'open' reopens a completed or " +
684
+ "canceled project; its children stay completed/canceled unless restore_children " +
685
+ "also reopens the ones that were resolved together with the project.",
686
+ inputSchema: {
687
+ uuid: z.string().describe("Project uuid"),
688
+ status: z.enum(["completed", "canceled", "open"]),
689
+ children: z
690
+ .enum(["require-resolved", "auto-complete", "auto-cancel"])
691
+ .optional()
692
+ .describe("Required for completed/canceled: what to do with the project's open to-dos"),
693
+ restore_children: z
694
+ .boolean()
695
+ .optional()
696
+ .describe("open only: also reopen the to-dos that were resolved with the project"),
697
+ ...dryRunShape,
698
+ },
699
+ annotations: NON_DESTRUCTIVE,
700
+ }, async (args) => guard(async () => {
701
+ const c = getClient();
702
+ if (args.status !== "open" && args.restore_children !== undefined) {
703
+ return usage("restore_children applies only to status 'open'");
704
+ }
705
+ if (args.status === "completed") {
706
+ if (args.children !== "require-resolved" && args.children !== "auto-complete") {
707
+ return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
708
+ }
709
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
710
+ }
711
+ if (args.status === "canceled") {
712
+ if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
713
+ return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
714
+ }
715
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
716
+ }
717
+ if (args.children !== undefined) {
718
+ return usage("children applies only to status 'completed' or 'canceled'");
719
+ }
720
+ const outcome = await c.write.reopenProject(args.uuid, {
721
+ ...writeOptions(args),
722
+ ...(args.restore_children === true && { restoreChildren: true }),
723
+ });
724
+ return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
725
+ ? jsonResult(outcome)
726
+ : mutationResult(outcome.project);
727
+ }));
728
+ server.registerTool("move_project", {
729
+ description: "Move a project into an area, or detach it from its current area. Pass exactly " +
730
+ "one of area / detach. The project's status and schedule are unaffected.",
731
+ inputSchema: {
732
+ uuid: z.string().describe("Project uuid"),
733
+ area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
734
+ detach: z.boolean().optional().describe("Remove the current area assignment"),
735
+ ...dryRunShape,
736
+ },
737
+ annotations: NON_DESTRUCTIVE,
738
+ }, async (args) => guard(async () => {
739
+ if ((args.detach === true) === (args.area !== undefined)) {
740
+ return usage("pass exactly one of area / detach");
741
+ }
742
+ const c = getClient();
743
+ return mutationResult(args.detach === true
744
+ ? await c.write.detachProject(args.uuid, writeOptions(args))
745
+ : await c.write.moveProject(args.uuid, containerRef(args.area), writeOptions(args)));
746
+ }));
747
+ // ----------------------------------------------------------------- areas
748
+ server.registerTool("add_area", {
749
+ description: "Create an area. Tags, when given, must name existing tags.",
750
+ inputSchema: {
751
+ title: z.string(),
752
+ tags: z.array(z.string()).optional().describe("Existing tag names"),
753
+ ...dryRunShape,
754
+ },
755
+ annotations: NON_DESTRUCTIVE,
756
+ }, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
757
+ server.registerTool("update_area", {
758
+ description: "Rename an area and/or replace its tags (the full set; tags must name existing tags).",
759
+ inputSchema: {
760
+ target: z.string().describe(`Area to update (${REF_FORMAT})`),
761
+ title: z.string().optional().describe("New name"),
762
+ tags: z.array(z.string()).optional().describe("Existing tag names (full replacement)"),
763
+ ...dryRunShape,
764
+ },
765
+ annotations: NON_DESTRUCTIVE,
766
+ }, async (args) => guard(async () => {
767
+ if (args.title === undefined && args.tags === undefined) {
768
+ return usage("pass title and/or tags");
769
+ }
770
+ return mutationResult(await getClient().write.updateArea(args.target, {
771
+ ...(args.title !== undefined && { title: args.title }),
772
+ ...(args.tags !== undefined && { tags: args.tags }),
773
+ }, writeOptions(args)));
774
+ }));
775
+ server.registerTool("delete_area", {
776
+ description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
777
+ "undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
778
+ "projects remain, no longer assigned to any area.",
779
+ inputSchema: {
780
+ target: z.string().describe(`Area to delete (${REF_FORMAT})`),
781
+ dangerously_permanent: z
782
+ .boolean()
783
+ .optional()
784
+ .describe("Confirm permanent, unrecoverable deletion"),
785
+ ...dryRunShape,
786
+ },
787
+ annotations: DESTRUCTIVE,
788
+ }, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
789
+ // ------------------------------------------------------------------ tags
790
+ server.registerTool("add_tag", {
791
+ description: "Create a tag, optionally nested under an existing parent tag.",
792
+ inputSchema: {
793
+ title: z.string(),
794
+ parent: z.string().optional().describe("Existing parent tag name"),
795
+ ...dryRunShape,
796
+ },
797
+ annotations: NON_DESTRUCTIVE,
798
+ }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
799
+ server.registerTool("update_tag", {
800
+ description: "Rename a tag (existing assignments follow the rename), nest it under another " +
801
+ "existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
802
+ "parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
803
+ inputSchema: {
804
+ target: z.string().describe(`Tag to update (${REF_FORMAT})`),
805
+ title: z.string().optional().describe("New name"),
806
+ parent: z.string().optional().describe("Existing tag to nest under"),
807
+ unnest: z.boolean().optional().describe("Move the tag to the top level"),
808
+ shortcut: z.string().optional().describe("Keyboard shortcut character"),
809
+ clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
810
+ ...dryRunShape,
811
+ },
812
+ annotations: NON_DESTRUCTIVE,
813
+ }, async (args) => guard(async () => {
814
+ if (args.title === undefined &&
815
+ args.parent === undefined &&
816
+ args.unnest === undefined &&
817
+ args.shortcut === undefined &&
818
+ args.clear_shortcut === undefined) {
819
+ return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
820
+ }
821
+ if (args.parent !== undefined && args.unnest === true) {
822
+ return usage("parent and unnest are exclusive");
823
+ }
824
+ if (args.shortcut !== undefined && args.clear_shortcut === true) {
825
+ return usage("shortcut and clear_shortcut are exclusive");
826
+ }
827
+ return mutationResult(await getClient().write.updateTag(args.target, {
828
+ ...(args.title !== undefined && { title: args.title }),
829
+ ...(args.parent !== undefined && { parent: args.parent }),
830
+ ...(args.unnest === true && { unnest: true }),
831
+ ...(args.shortcut !== undefined && { shortcut: args.shortcut }),
832
+ ...(args.clear_shortcut === true && { clearShortcut: true }),
833
+ }, writeOptions(args)));
834
+ }));
835
+ server.registerTool("delete_tag", {
836
+ description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
837
+ "requires dangerously_permanent. The tag is removed from every item. If the tag " +
838
+ "has nested child tags they are ALL permanently deleted with it — pass " +
839
+ "acknowledge_tag_subtree to confirm.",
840
+ inputSchema: {
841
+ target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
842
+ dangerously_permanent: z
843
+ .boolean()
844
+ .optional()
845
+ .describe("Confirm permanent, unrecoverable deletion"),
846
+ acknowledge_tag_subtree: z
847
+ .boolean()
848
+ .optional()
849
+ .describe("Confirm permanent deletion of ALL nested child tags too"),
850
+ ...dryRunShape,
851
+ },
852
+ annotations: DESTRUCTIVE,
853
+ }, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
854
+ // -------------------------------------------------- generic + discovery
855
+ server.registerTool("run_operation", {
856
+ description: "Run any cataloged operation by kind — the generic entry for operations without a " +
857
+ "dedicated tool (e.g. trash.empty). Call capabilities first for the catalog of " +
858
+ "operation kinds and their parameter shapes.",
859
+ inputSchema: {
860
+ op: z.enum(OPERATION_KINDS),
861
+ params: z
862
+ .record(z.string(), z.unknown())
863
+ .describe('Operation params, e.g. {"uuid": "..."} — shapes per `capabilities`'),
864
+ acknowledge_checklist_reset: z.boolean().optional(),
865
+ acknowledge_project_reopen: z.boolean().optional(),
866
+ acknowledge_tag_subtree: z
867
+ .boolean()
868
+ .optional()
869
+ .describe("tag.delete: confirm permanent deletion of ALL nested child tags"),
870
+ dangerously_permanent: z
871
+ .boolean()
872
+ .optional()
873
+ .describe("Required for area/tag delete and trash.empty (PERMANENT, no Trash)"),
874
+ ...dryRunShape,
875
+ },
876
+ annotations: DESTRUCTIVE,
877
+ }, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
878
+ server.registerTool("batch", {
879
+ description: "Run several operations in order, each independently — there are no transactions, " +
880
+ "and a failure does not roll back earlier operations. Per-operation results return " +
881
+ "in order; fail_fast skips the remainder after the first failure.",
882
+ inputSchema: {
883
+ ops: z
884
+ .array(z.object({
885
+ op: z.enum(OPERATION_KINDS),
886
+ params: z.record(z.string(), z.unknown()),
887
+ options: z
888
+ .object({
889
+ acknowledgeChecklistReset: z.boolean().optional(),
890
+ acknowledgeProjectReopen: z.boolean().optional(),
891
+ dangerouslyPermanent: z.boolean().optional(),
892
+ acknowledgeTagSubtree: z.boolean().optional(),
893
+ })
894
+ .optional(),
895
+ }))
896
+ .describe("Ops in execution order"),
897
+ fail_fast: z.boolean().optional().describe("Skip remaining ops after the first failure"),
898
+ ...dryRunShape,
899
+ },
900
+ annotations: DESTRUCTIVE,
901
+ }, async (args) => guard(async () => {
902
+ const results = await getClient().write.batch(args.ops, {
903
+ ...(args.dry_run === true && { dryRun: true }),
904
+ ...(args.fail_fast === true && { failFast: true }),
905
+ actor: "mcp",
906
+ });
907
+ return jsonResult(results);
908
+ }));
909
+ server.registerTool("reorder", {
910
+ description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos), a " +
911
+ "project's to-dos, a project's headings (scope=headings — children move with " +
912
+ "their heading), an area, or the top-level sidebar projects (scope=projects — " +
913
+ "each project takes a brief someday/anytime round-trip) — the given uuids move " +
914
+ "to the TOP in the given order; unlisted items keep their relative order below. " +
915
+ "Today/inbox/someday/project/headings/area ordering must first be enabled once " +
916
+ "via `things config set allow-experimental true`. This Evening and " +
917
+ `scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
918
+ "to-dos and projects are ordered separately — one kind per call.",
919
+ inputSchema: {
920
+ scope: z.enum([
921
+ "today",
922
+ "evening",
923
+ "inbox",
924
+ "someday",
925
+ "project",
926
+ "headings",
927
+ "area",
928
+ "projects",
929
+ ]),
930
+ container: z
931
+ .string()
932
+ .optional()
933
+ .describe(`Project/area (${REF_FORMAT}) — required for those scopes`),
934
+ uuids: z.array(z.string()).describe("Desired order, top first (may be a subset)"),
935
+ strategy: z.enum(["native", "bounce"]).optional(),
936
+ ...dryRunShape,
937
+ },
938
+ annotations: NON_DESTRUCTIVE,
939
+ }, async (args) => guard(async () => mutationResult(await getClient().write.reorder({
940
+ scope: args.scope,
941
+ uuids: args.uuids,
942
+ ...(args.container !== undefined && { container: containerRef(args.container) }),
943
+ ...(args.strategy !== undefined && { strategy: args.strategy }),
944
+ }, writeOptions(args)))));
945
+ server.registerTool("undo", {
946
+ description: "Undo the last N changes made through this interface, newest first (changes made " +
947
+ "directly in the Things app cannot be undone here). Some changes cannot be " +
948
+ "reversed — permanent deletions, or changes whose prior state is unknown — and are " +
949
+ "reported as irreversible; a to-do brought back from an undone delete returns to " +
950
+ "the Inbox without its schedule. Undoing the creation of an area or tag deletes it " +
951
+ "permanently — requires dangerously_permanent.",
952
+ inputSchema: {
953
+ last: z.number().int().min(1).optional().describe("How many to unwind (default 1)"),
954
+ dangerously_permanent: z.boolean().optional(),
955
+ ...dryRunShape,
956
+ },
957
+ annotations: DESTRUCTIVE,
958
+ }, async (args) => guard(async () => {
959
+ const items = await getClient().write.undo({
960
+ ...(args.last !== undefined && { last: args.last }),
961
+ ...(args.dry_run === true && { dryRun: true }),
962
+ ...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
963
+ actor: "mcp",
964
+ });
965
+ return jsonResult(items);
966
+ }));
967
+ server.registerTool("capabilities", {
968
+ description: "Support reference for every operation kind usable with run_operation and batch: " +
969
+ "whether it is available, its caveats, and the confirmation parameters it needs.",
970
+ inputSchema: {
971
+ op: z
972
+ .enum(OPERATION_KINDS)
973
+ .optional()
974
+ .describe("Limit to one operation kind"),
975
+ },
976
+ annotations: READ_ONLY,
977
+ }, async (args) => guard(() => jsonResult(capabilitiesTable(args.op))));
978
+ server.registerTool("doctor", {
979
+ description: "Check the environment: whether the Things app and its database are reachable, " +
980
+ "whether changes can be made, any one-time setup still needed (macOS permissions, " +
981
+ "the app's 'Enable Things URLs' setting), and whether the environment changed since " +
982
+ "the last successful write, with steps to fix.",
983
+ inputSchema: {
984
+ probe_automation: z
985
+ .boolean()
986
+ .optional()
987
+ .describe("Also actively test whether automation of Things is authorized. May show a " +
988
+ "one-time macOS consent prompt on the machine; skipped when Things is not " +
989
+ "running."),
990
+ },
991
+ annotations: READ_ONLY,
992
+ }, async (args) => guard(() => {
993
+ const { report, error } = diagnose(options.dbPath, {
994
+ ...(args.probe_automation === true && { probeAutomation: true }),
995
+ });
996
+ return report !== null
997
+ ? jsonResult(report)
998
+ : errorResult(error ?? { code: "unexpected", message: "no report" });
999
+ }));
1000
+ return server;
1001
+ }
1002
+ //# sourceMappingURL=server.js.map