things-mcp-server 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -11,13 +11,18 @@ A local MCP server for controlling [Things](https://culturedcode.com/things/) on
11
11
  - `things_list_areas`
12
12
  - `things_list_area_items`
13
13
  - `things_list_project_todos`
14
+ - `things_list_tags`
15
+ - `things_list_tag_todos`
14
16
  - `things_search_todos`
15
17
  - `things_get_todo`
16
18
  - Write:
17
19
  - `things_add_area`
20
+ - `things_add_tag`
18
21
  - `things_add_todo`
19
22
  - `things_add_project`
23
+ - `things_update_tag`
20
24
  - `things_update_todo`
25
+ - `things_delete_tag`
21
26
  - `things_complete_todo`
22
27
  - `things_move_todo`
23
28
  - `things_delete_todo`
@@ -53,6 +58,8 @@ pnpm build
53
58
  pnpm test
54
59
  ```
55
60
 
61
+ `tests/sqlite-read-adapter.test.ts` only runs when `better-sqlite3` native binding can be loaded by the current Node runtime; otherwise that suite is skipped.
62
+
56
63
  ## Run
57
64
 
58
65
  ```bash
@@ -119,11 +126,12 @@ If `lastOpenError` includes `NODE_MODULE_VERSION` mismatch:
119
126
 
120
127
  ## Tool Safety Notes
121
128
 
122
- - `things_add_area`, `things_add_todo`, `things_add_project`, `things_update_todo`, `things_complete_todo`, `things_move_todo`, and `things_delete_todo` all require `confirm=true`.
129
+ - `things_add_area`, `things_add_tag`, `things_add_todo`, `things_add_project`, `things_update_tag`, `things_update_todo`, `things_delete_tag`, `things_complete_todo`, `things_move_todo`, and `things_delete_todo` all require `confirm=true`.
123
130
  - This server does not execute arbitrary scripts.
124
131
  - Reads are done via AppleScript.
125
- - URL-scheme-supported operations use Things URL scheme (`add_todo`, `add_project`, `move_todo`, `complete_todo`, `show_item`), and `update_todo` when heading placement is requested.
126
- - AppleScript is used for read operations, plus write operations `add_area`, `delete_todo`, and most `update_todo` cases.
132
+ - URL-scheme-supported operations use Things URL scheme (`add_todo`, `add_project`, `move_todo`, `complete_todo`, `show_item`), and `update_todo` when heading placement or tag updates are requested.
133
+ - AppleScript is used for read operations, plus write operations `add_area`, `delete_todo`, and `update_todo` cases that do not include heading/tag updates.
134
+ - For `update_todo` tag updates, tag names must already exist in Things. The server validates this and returns an explicit error if any tag is missing.
127
135
 
128
136
  ## Read Detail Fields
129
137
 
@@ -153,6 +161,7 @@ Container support:
153
161
  - `things_add_project` supports creating projects inside area via `area` or `areaId`.
154
162
  - `things_add_todo` supports direct placement via `list/listId` and optional `heading/headingId`.
155
163
  - `things_move_todo` supports moving into standard list, project, area, or heading.
164
+ - `things_list_tag_todos` supports querying todos by `tag` (name) or `tagId`.
156
165
 
157
166
  ## Known Limitations
158
167
 
@@ -705,6 +705,44 @@ function parseAreasPayload(payload, limit, offset) {
705
705
  items
706
706
  };
707
707
  }
708
+ function parseTagsPayload(payload, limit, offset) {
709
+ const normalized = payload.trim();
710
+ if (!normalized.startsWith("TOTAL:")) {
711
+ throw new ThingsOperationError("Malformed response from Things AppleScript adapter.");
712
+ }
713
+ const [header, ...rows] = normalized.split("\n");
714
+ const total = Number(header.replace("TOTAL:", "").trim());
715
+ if (!Number.isFinite(total) || total < 0) {
716
+ throw new ThingsOperationError("Invalid total value from Things AppleScript adapter.");
717
+ }
718
+ const items = rows
719
+ .filter((line) => line.trim().length > 0)
720
+ .map((line) => {
721
+ const [id, title, parentIdRaw, parentNameRaw, todoCountRaw, childCountRaw] = line.split("\t");
722
+ const parentId = parseOptional(parentIdRaw);
723
+ const parentName = parseOptional(parentNameRaw);
724
+ const todoCount = Number(todoCountRaw);
725
+ const childCount = Number(childCountRaw);
726
+ return {
727
+ id,
728
+ title,
729
+ parentId,
730
+ parentName,
731
+ todoCount: Number.isFinite(todoCount) ? todoCount : 0,
732
+ childCount: Number.isFinite(childCount) ? childCount : 0
733
+ };
734
+ });
735
+ const count = items.length;
736
+ const hasMore = offset + count < total;
737
+ return {
738
+ total,
739
+ count,
740
+ offset,
741
+ has_more: hasMore,
742
+ next_offset: hasMore ? offset + count : undefined,
743
+ items
744
+ };
745
+ }
708
746
  function parseAreaItemsPayload(payload, limit, offset) {
709
747
  const normalized = payload.trim();
710
748
  if (!normalized.startsWith("TOTAL:")) {
@@ -969,6 +1007,86 @@ export class AppleScriptAdapter {
969
1007
  const output = await this.runAppleScript(lines, [String(input.limit), String(input.offset)]);
970
1008
  return parseAreasPayload(output, input.limit, input.offset);
971
1009
  }
1010
+ async listTags(input) {
1011
+ const lines = [
1012
+ "on replaceText(findText, replaceText, sourceText)",
1013
+ "set AppleScript's text item delimiters to findText",
1014
+ "set tempList to every text item of sourceText",
1015
+ "set AppleScript's text item delimiters to replaceText",
1016
+ "set outputText to tempList as text",
1017
+ "set AppleScript's text item delimiters to \"\"",
1018
+ "return outputText",
1019
+ "end replaceText",
1020
+ "",
1021
+ "on sanitizeText(sourceText)",
1022
+ "set cleanedText to my replaceText(tab, \" \", sourceText)",
1023
+ "set cleanedText to my replaceText(return, \" \", cleanedText)",
1024
+ "set cleanedText to my replaceText(linefeed, \" \", cleanedText)",
1025
+ "return cleanedText",
1026
+ "end sanitizeText",
1027
+ "",
1028
+ "on safeTagId(sourceTag)",
1029
+ "if sourceTag is missing value then return \"\"",
1030
+ "try",
1031
+ "return id of sourceTag as text",
1032
+ "on error",
1033
+ "return \"\"",
1034
+ "end try",
1035
+ "end safeTagId",
1036
+ "",
1037
+ "on safeTagName(sourceTag)",
1038
+ "if sourceTag is missing value then return \"\"",
1039
+ "try",
1040
+ "return my sanitizeText(name of sourceTag as text)",
1041
+ "on error",
1042
+ "return \"\"",
1043
+ "end try",
1044
+ "end safeTagName",
1045
+ "",
1046
+ "on run argv",
1047
+ "set limitNum to item 1 of argv as integer",
1048
+ "set offsetNum to item 2 of argv as integer",
1049
+ "tell application \"Things3\"",
1050
+ "set sourceTags to tags",
1051
+ "set totalCount to count of sourceTags",
1052
+ "set startIndex to offsetNum + 1",
1053
+ "if startIndex > totalCount then",
1054
+ "return \"TOTAL:\" & totalCount",
1055
+ "end if",
1056
+ "set endIndex to offsetNum + limitNum",
1057
+ "if endIndex > totalCount then set endIndex to totalCount",
1058
+ "set linesOut to {}",
1059
+ "repeat with i from startIndex to endIndex",
1060
+ "set currentTag to item i of sourceTags",
1061
+ "set tagId to id of currentTag as text",
1062
+ "set tagName to my sanitizeText(name of currentTag as text)",
1063
+ "set parentTagId to \"\"",
1064
+ "set parentTagName to \"\"",
1065
+ "try",
1066
+ "set parentTagObj to parent tag of currentTag",
1067
+ "set parentTagId to my safeTagId(parentTagObj)",
1068
+ "set parentTagName to my safeTagName(parentTagObj)",
1069
+ "end try",
1070
+ "set todoCount to 0",
1071
+ "try",
1072
+ "set todoCount to count of to dos of currentTag",
1073
+ "end try",
1074
+ "set childCount to 0",
1075
+ "try",
1076
+ "set childCount to count of tags of currentTag",
1077
+ "end try",
1078
+ "set end of linesOut to tagId & tab & tagName & tab & parentTagId & tab & parentTagName & tab & todoCount & tab & childCount",
1079
+ "end repeat",
1080
+ "set AppleScript's text item delimiters to linefeed",
1081
+ "set linesText to linesOut as text",
1082
+ "set AppleScript's text item delimiters to \"\"",
1083
+ "return \"TOTAL:\" & totalCount & linefeed & linesText",
1084
+ "end tell",
1085
+ "end run"
1086
+ ];
1087
+ const output = await this.runAppleScript(lines, [String(input.limit), String(input.offset)]);
1088
+ return parseTagsPayload(output, input.limit, input.offset);
1089
+ }
972
1090
  async listAreaItems(input) {
973
1091
  const lines = [
974
1092
  "on replaceText(findText, replaceText, sourceText)",
@@ -1115,6 +1233,94 @@ export class AppleScriptAdapter {
1115
1233
  ]);
1116
1234
  return parseListPayload(output, input.limit, input.offset);
1117
1235
  }
1236
+ async listTagTodos(input) {
1237
+ const tagName = AppleScriptAdapter.sanitizeAppleScriptText(input.tag ?? "");
1238
+ const lines = [
1239
+ "on replaceText(findText, replaceText, sourceText)",
1240
+ "set AppleScript's text item delimiters to findText",
1241
+ "set tempList to every text item of sourceText",
1242
+ "set AppleScript's text item delimiters to replaceText",
1243
+ "set outputText to tempList as text",
1244
+ "set AppleScript's text item delimiters to \"\"",
1245
+ "return outputText",
1246
+ "end replaceText",
1247
+ "",
1248
+ "on sanitizeText(sourceText)",
1249
+ "set cleanedText to my replaceText(tab, \" \", sourceText)",
1250
+ "set cleanedText to my replaceText(return, \" \", cleanedText)",
1251
+ "set cleanedText to my replaceText(linefeed, \" \", cleanedText)",
1252
+ "return cleanedText",
1253
+ "end sanitizeText",
1254
+ "",
1255
+ "on formatDate(sourceDate)",
1256
+ "if sourceDate is missing value then return \"\"",
1257
+ "try",
1258
+ "return (sourceDate as «class isot») as text",
1259
+ "on error",
1260
+ "return sourceDate as text",
1261
+ "end try",
1262
+ "end formatDate",
1263
+ "",
1264
+ "on resolveTagByIdOrName(targetTagId, targetTagName)",
1265
+ "if targetTagId is not \"\" then",
1266
+ "try",
1267
+ "return first tag whose id is targetTagId",
1268
+ "on error",
1269
+ "error \"Target tag ID not found: \" & targetTagId",
1270
+ "end try",
1271
+ "end if",
1272
+ "if targetTagName is \"\" then error \"No tag specified.\"",
1273
+ "try",
1274
+ "return first tag whose name is targetTagName",
1275
+ "on error",
1276
+ "error \"Target tag not found: \" & targetTagName",
1277
+ "end try",
1278
+ "end resolveTagByIdOrName",
1279
+ "",
1280
+ "on run argv",
1281
+ "set targetTagId to item 1 of argv",
1282
+ "set targetTagName to item 2 of argv",
1283
+ "set limitNum to item 3 of argv as integer",
1284
+ "set offsetNum to item 4 of argv as integer",
1285
+ "tell application \"Things3\"",
1286
+ "set targetTag to my resolveTagByIdOrName(targetTagId, targetTagName)",
1287
+ "set sourceTodos to to dos of targetTag",
1288
+ "set totalCount to count of sourceTodos",
1289
+ "set startIndex to offsetNum + 1",
1290
+ "if startIndex > totalCount then",
1291
+ "return \"TOTAL:\" & totalCount",
1292
+ "end if",
1293
+ "set endIndex to offsetNum + limitNum",
1294
+ "if endIndex > totalCount then set endIndex to totalCount",
1295
+ "set linesOut to {}",
1296
+ "repeat with i from startIndex to endIndex",
1297
+ "set currentTodo to item i of sourceTodos",
1298
+ "set todoId to id of currentTodo as text",
1299
+ "set todoName to my sanitizeText(name of currentTodo as text)",
1300
+ "set todoStatus to status of currentTodo as text",
1301
+ "set todoDueDate to my formatDate(due date of currentTodo)",
1302
+ "set todoActivationDate to my formatDate(activation date of currentTodo)",
1303
+ "set todoJson to \"\"",
1304
+ "try",
1305
+ "set todoJson to my sanitizeText(|_private_experimental_ json| of currentTodo as text)",
1306
+ "end try",
1307
+ "set end of linesOut to todoId & tab & todoName & tab & todoStatus & tab & todoDueDate & tab & todoActivationDate & tab & todoJson",
1308
+ "end repeat",
1309
+ "set AppleScript's text item delimiters to linefeed",
1310
+ "set linesText to linesOut as text",
1311
+ "set AppleScript's text item delimiters to \"\"",
1312
+ "return \"TOTAL:\" & totalCount & linefeed & linesText",
1313
+ "end tell",
1314
+ "end run"
1315
+ ];
1316
+ const output = await this.runAppleScript(lines, [
1317
+ input.tagId ?? "",
1318
+ tagName,
1319
+ String(input.limit),
1320
+ String(input.offset)
1321
+ ]);
1322
+ return parseListPayload(output, input.limit, input.offset);
1323
+ }
1118
1324
  async searchTodos(input) {
1119
1325
  const query = AppleScriptAdapter.sanitizeAppleScriptText(input.query);
1120
1326
  const lines = [
@@ -1180,6 +1386,32 @@ export class AppleScriptAdapter {
1180
1386
  const output = await this.runAppleScript(lines, [query, String(input.limit), String(input.offset)]);
1181
1387
  return parseListPayload(output, input.limit, input.offset);
1182
1388
  }
1389
+ async listTagNames() {
1390
+ const lines = [
1391
+ "on run argv",
1392
+ "tell application \"Things3\"",
1393
+ "set allTags to tags",
1394
+ "set tagNames to {}",
1395
+ "repeat with currentTag in allTags",
1396
+ "set end of tagNames to (name of currentTag as text)",
1397
+ "end repeat",
1398
+ "if (count of tagNames) is 0 then return \"\"",
1399
+ "set AppleScript's text item delimiters to linefeed",
1400
+ "set outputText to tagNames as text",
1401
+ "set AppleScript's text item delimiters to \"\"",
1402
+ "return outputText",
1403
+ "end tell",
1404
+ "end run"
1405
+ ];
1406
+ const output = (await this.runAppleScript(lines, [])).trim();
1407
+ if (output.length === 0) {
1408
+ return [];
1409
+ }
1410
+ return output
1411
+ .split(/\r?\n/)
1412
+ .map((value) => value.trim())
1413
+ .filter((value) => value.length > 0);
1414
+ }
1183
1415
  async getTodo(id) {
1184
1416
  const lines = [
1185
1417
  "on replaceText(findText, replaceText, sourceText)",
@@ -1525,6 +1757,49 @@ export class AppleScriptAdapter {
1525
1757
  input.listId ?? ""
1526
1758
  ]);
1527
1759
  }
1760
+ async addTag(input) {
1761
+ const tagTitle = AppleScriptAdapter.sanitizeAppleScriptText(input.title);
1762
+ const lines = [
1763
+ "on run argv",
1764
+ "set requestedName to item 1 of argv",
1765
+ "tell application \"Things3\"",
1766
+ "set existingTag to missing value",
1767
+ "try",
1768
+ "set existingTag to first tag whose name is requestedName",
1769
+ "end try",
1770
+ "if existingTag is not missing value then return id of existingTag as text",
1771
+ "set newTag to make new tag with properties {name:requestedName}",
1772
+ "return id of newTag as text",
1773
+ "end tell",
1774
+ "end run"
1775
+ ];
1776
+ return (await this.runAppleScript(lines, [tagTitle])).trim();
1777
+ }
1778
+ async updateTag(input) {
1779
+ const tagTitle = AppleScriptAdapter.sanitizeAppleScriptText(input.title);
1780
+ const lines = [
1781
+ "on run argv",
1782
+ "set targetId to item 1 of argv",
1783
+ "set tagTitle to item 2 of argv",
1784
+ "tell application \"Things3\"",
1785
+ "set targetTag to first tag whose id is targetId",
1786
+ "set name of targetTag to tagTitle",
1787
+ "end tell",
1788
+ "end run"
1789
+ ];
1790
+ await this.runAppleScript(lines, [input.id, tagTitle]);
1791
+ }
1792
+ async deleteTag(input) {
1793
+ const lines = [
1794
+ "on run argv",
1795
+ "set targetId to item 1 of argv",
1796
+ "tell application \"Things3\"",
1797
+ "delete (first tag whose id is targetId)",
1798
+ "end tell",
1799
+ "end run"
1800
+ ];
1801
+ await this.runAppleScript(lines, [input.id]);
1802
+ }
1528
1803
  async completeTodo(id) {
1529
1804
  const lines = [
1530
1805
  "on run argv",
@@ -62,7 +62,7 @@ export class UrlSchemeAdapter {
62
62
  "list-id": input.listId,
63
63
  heading: input.heading,
64
64
  "heading-id": input.headingId,
65
- tags: input.tags?.join(","),
65
+ "add-tags": input.tags?.join(","),
66
66
  "auth-token": token
67
67
  });
68
68
  await this.openThingsUrl(url);
@@ -43,6 +43,16 @@ export const listAreasInputSchema = z.object({
43
43
  .describe("Maximum items to return."),
44
44
  offset: z.number().int().min(0).default(0).describe("Pagination offset.")
45
45
  });
46
+ export const listTagsInputSchema = z.object({
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(MAX_PAGE_SIZE)
52
+ .default(DEFAULT_PAGE_SIZE)
53
+ .describe("Maximum tags to return."),
54
+ offset: z.number().int().min(0).default(0).describe("Pagination offset.")
55
+ });
46
56
  export const searchTodosInputSchema = z.object({
47
57
  query: z.string().min(1).max(300).describe("Search query against todo title."),
48
58
  limit: z
@@ -79,6 +89,25 @@ export const projectTodosInputSchema = z.object({
79
89
  .describe("Maximum items to return."),
80
90
  offset: z.number().int().min(0).default(0).describe("Pagination offset.")
81
91
  });
92
+ export const tagTodosToolInputSchema = z.object({
93
+ tag: z.string().min(1).max(300).optional().describe("Tag name."),
94
+ tagId: z.string().min(6).max(128).optional().describe("Tag ID."),
95
+ limit: z
96
+ .number()
97
+ .int()
98
+ .min(1)
99
+ .max(MAX_PAGE_SIZE)
100
+ .default(DEFAULT_PAGE_SIZE)
101
+ .describe("Maximum items to return."),
102
+ offset: z.number().int().min(0).default(0).describe("Pagination offset.")
103
+ });
104
+ export const tagTodosInputSchema = tagTodosToolInputSchema
105
+ .refine((value) => value.tag !== undefined || value.tagId !== undefined, {
106
+ message: "Provide tag or tagId."
107
+ })
108
+ .refine((value) => !(value.tag !== undefined && value.tagId !== undefined), {
109
+ message: "Provide either tag or tagId, not both."
110
+ });
82
111
  export const addAreaInputSchema = z.object({
83
112
  title: z.string().min(1).max(300).describe("Area title."),
84
113
  confirm: z
@@ -86,6 +115,28 @@ export const addAreaInputSchema = z.object({
86
115
  .default(false)
87
116
  .describe("Set true to confirm this write operation.")
88
117
  });
118
+ export const addTagInputSchema = z.object({
119
+ title: z.string().min(1).max(300).describe("Tag title."),
120
+ confirm: z
121
+ .boolean()
122
+ .default(false)
123
+ .describe("Set true to confirm this write operation.")
124
+ });
125
+ export const updateTagInputSchema = z.object({
126
+ id: z.string().min(6).max(128).describe("Tag ID."),
127
+ title: z.string().min(1).max(300).describe("New tag title."),
128
+ confirm: z
129
+ .boolean()
130
+ .default(false)
131
+ .describe("Set true to confirm this write operation.")
132
+ });
133
+ export const deleteTagInputSchema = z.object({
134
+ id: z.string().min(6).max(128).describe("Tag ID."),
135
+ confirm: z
136
+ .boolean()
137
+ .default(false)
138
+ .describe("Set true to confirm this destructive operation.")
139
+ });
89
140
  export const addTodoToolInputSchema = z.object({
90
141
  title: z.string().min(1).max(300).describe("Todo title."),
91
142
  notes: z.string().max(20_000).optional().describe("Todo notes."),
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from "../errors.js";
1
2
  import { assertConfirm, assertSafeContainerName, assertSafeDate, assertSafeNotes, assertSafeTags, assertSafeTitle, assertThingsId } from "../utils/validators.js";
2
3
  export class ThingsService {
3
4
  appleScriptAdapter;
@@ -24,6 +25,9 @@ export class ThingsService {
24
25
  async listAreas(input) {
25
26
  return this.appleScriptAdapter.listAreas(input);
26
27
  }
28
+ async listTags(input) {
29
+ return this.appleScriptAdapter.listTags(input);
30
+ }
27
31
  async listAreaItems(input) {
28
32
  assertThingsId(input.areaId);
29
33
  const result = await this.appleScriptAdapter.listAreaItems(input);
@@ -42,6 +46,18 @@ export class ThingsService {
42
46
  items
43
47
  };
44
48
  }
49
+ async listTagTodos(input) {
50
+ if (input.tagId) {
51
+ assertThingsId(input.tagId);
52
+ }
53
+ assertSafeContainerName(input.tag);
54
+ const result = await this.appleScriptAdapter.listTagTodos(input);
55
+ const items = this.enrichSummaries(result.items);
56
+ return {
57
+ ...result,
58
+ items
59
+ };
60
+ }
45
61
  async searchTodos(input) {
46
62
  assertSafeTitle(input.query);
47
63
  const result = await this.appleScriptAdapter.searchTodos(input);
@@ -120,7 +136,17 @@ export class ThingsService {
120
136
  if (input.headingId) {
121
137
  assertThingsId(input.headingId);
122
138
  }
123
- const requiresUrlScheme = input.heading !== undefined || input.headingId !== undefined;
139
+ if (input.tags !== undefined && input.tags.length > 0) {
140
+ const existingTags = await this.appleScriptAdapter.listTagNames();
141
+ const existingTagSet = new Set(existingTags.map((tag) => tag.trim().toLowerCase()));
142
+ const missingTags = input.tags.filter((tag) => !existingTagSet.has(tag.trim().toLowerCase()));
143
+ if (missingTags.length > 0) {
144
+ throw new ValidationError(`Tags not found in Things: ${missingTags.join(", ")}. Create these tags first, then retry update.`);
145
+ }
146
+ }
147
+ const requiresUrlScheme = input.heading !== undefined ||
148
+ input.headingId !== undefined ||
149
+ input.tags !== undefined;
124
150
  if (requiresUrlScheme) {
125
151
  await this.urlSchemeAdapter.updateTodo(input);
126
152
  return {
@@ -178,6 +204,34 @@ export class ThingsService {
178
204
  message: `Area created (${areaId || "id unavailable"}).`
179
205
  };
180
206
  }
207
+ async addTag(input) {
208
+ assertConfirm(input.confirm);
209
+ assertSafeTitle(input.title);
210
+ const tagId = await this.appleScriptAdapter.addTag(input);
211
+ return {
212
+ ok: true,
213
+ message: `Tag created (${tagId || "id unavailable"}).`
214
+ };
215
+ }
216
+ async updateTag(input) {
217
+ assertConfirm(input.confirm);
218
+ assertThingsId(input.id);
219
+ assertSafeTitle(input.title);
220
+ await this.appleScriptAdapter.updateTag(input);
221
+ return {
222
+ ok: true,
223
+ message: "Tag updated."
224
+ };
225
+ }
226
+ async deleteTag(input) {
227
+ assertConfirm(input.confirm);
228
+ assertThingsId(input.id);
229
+ await this.appleScriptAdapter.deleteTag(input);
230
+ return {
231
+ ok: true,
232
+ message: "Tag deleted."
233
+ };
234
+ }
181
235
  async showItem(input) {
182
236
  assertThingsId(input.id);
183
237
  await this.urlSchemeAdapter.showItem(input.id);
@@ -1,4 +1,4 @@
1
- import { areaItemsInputSchema, emptyInputSchema, idInputSchema, listAreasInputSchema, listProjectsInputSchema, listTodosInputSchema, projectTodosInputSchema, searchTodosInputSchema, showItemInputSchema } from "../schemas/tool-schemas.js";
1
+ import { areaItemsInputSchema, emptyInputSchema, idInputSchema, listAreasInputSchema, listProjectsInputSchema, listTagsInputSchema, tagTodosInputSchema, listTodosInputSchema, projectTodosInputSchema, searchTodosInputSchema, showItemInputSchema } from "../schemas/tool-schemas.js";
2
2
  import { toToolError, toToolSuccess } from "../utils/tool-response.js";
3
3
  export function registerReadTools(server, service) {
4
4
  server.registerTool("things_get_server_status", {
@@ -80,6 +80,26 @@ export function registerReadTools(server, service) {
80
80
  return toToolError(error);
81
81
  }
82
82
  });
83
+ server.registerTool("things_list_tags", {
84
+ title: "List Things Tags",
85
+ description: "List tags in Things with pagination.",
86
+ inputSchema: listTagsInputSchema,
87
+ annotations: {
88
+ readOnlyHint: true,
89
+ destructiveHint: false,
90
+ idempotentHint: true,
91
+ openWorldHint: false
92
+ }
93
+ }, async (params) => {
94
+ try {
95
+ const input = listTagsInputSchema.parse(params);
96
+ const data = await service.listTags(input);
97
+ return toToolSuccess(data);
98
+ }
99
+ catch (error) {
100
+ return toToolError(error);
101
+ }
102
+ });
83
103
  server.registerTool("things_list_area_items", {
84
104
  title: "List Things Area Items",
85
105
  description: "List items (projects/todos) under an area.",
@@ -120,6 +140,26 @@ export function registerReadTools(server, service) {
120
140
  return toToolError(error);
121
141
  }
122
142
  });
143
+ server.registerTool("things_list_tag_todos", {
144
+ title: "List Things Tag Todos",
145
+ description: "List todos under a tag with pagination.",
146
+ inputSchema: tagTodosInputSchema,
147
+ annotations: {
148
+ readOnlyHint: true,
149
+ destructiveHint: false,
150
+ idempotentHint: true,
151
+ openWorldHint: false
152
+ }
153
+ }, async (params) => {
154
+ try {
155
+ const input = tagTodosInputSchema.parse(params);
156
+ const data = await service.listTagTodos(input);
157
+ return toToolSuccess(data);
158
+ }
159
+ catch (error) {
160
+ return toToolError(error);
161
+ }
162
+ });
123
163
  server.registerTool("things_search_todos", {
124
164
  title: "Search Things Todos",
125
165
  description: "Search todos by title with pagination.",
@@ -1,4 +1,4 @@
1
- import { addAreaInputSchema, addProjectToolInputSchema, addTodoToolInputSchema, addProjectInputSchema, addTodoInputSchema, completeTodoInputSchema, deleteTodoInputSchema, moveTodoToolInputSchema, moveTodoInputSchema, updateTodoToolInputSchema, updateTodoInputSchema } from "../schemas/tool-schemas.js";
1
+ import { addAreaInputSchema, addTagInputSchema, addProjectToolInputSchema, addTodoToolInputSchema, addProjectInputSchema, addTodoInputSchema, completeTodoInputSchema, deleteTagInputSchema, deleteTodoInputSchema, moveTodoToolInputSchema, moveTodoInputSchema, updateTagInputSchema, updateTodoToolInputSchema, updateTodoInputSchema } from "../schemas/tool-schemas.js";
2
2
  import { toToolError, toToolSuccess } from "../utils/tool-response.js";
3
3
  export function registerWriteTools(server, service) {
4
4
  server.registerTool("things_add_area", {
@@ -21,6 +21,26 @@ export function registerWriteTools(server, service) {
21
21
  return toToolError(error);
22
22
  }
23
23
  });
24
+ server.registerTool("things_add_tag", {
25
+ title: "Add Things Tag",
26
+ description: "Create a tag in Things. Requires confirm=true.",
27
+ inputSchema: addTagInputSchema,
28
+ annotations: {
29
+ readOnlyHint: false,
30
+ destructiveHint: false,
31
+ idempotentHint: false,
32
+ openWorldHint: false
33
+ }
34
+ }, async (params) => {
35
+ try {
36
+ const input = addTagInputSchema.parse(params);
37
+ const data = await service.addTag(input);
38
+ return toToolSuccess(data);
39
+ }
40
+ catch (error) {
41
+ return toToolError(error);
42
+ }
43
+ });
24
44
  server.registerTool("things_add_todo", {
25
45
  title: "Add Things Todo",
26
46
  description: "Create a todo in Things via URL scheme. Requires confirm=true.",
@@ -81,6 +101,26 @@ export function registerWriteTools(server, service) {
81
101
  return toToolError(error);
82
102
  }
83
103
  });
104
+ server.registerTool("things_update_tag", {
105
+ title: "Update Things Tag",
106
+ description: "Rename an existing tag. Requires confirm=true.",
107
+ inputSchema: updateTagInputSchema,
108
+ annotations: {
109
+ readOnlyHint: false,
110
+ destructiveHint: false,
111
+ idempotentHint: false,
112
+ openWorldHint: false
113
+ }
114
+ }, async (params) => {
115
+ try {
116
+ const input = updateTagInputSchema.parse(params);
117
+ const data = await service.updateTag(input);
118
+ return toToolSuccess(data);
119
+ }
120
+ catch (error) {
121
+ return toToolError(error);
122
+ }
123
+ });
84
124
  server.registerTool("things_complete_todo", {
85
125
  title: "Complete Things Todo",
86
126
  description: "Mark a todo as completed via URL scheme. Requires confirm=true.",
@@ -121,6 +161,26 @@ export function registerWriteTools(server, service) {
121
161
  return toToolError(error);
122
162
  }
123
163
  });
164
+ server.registerTool("things_delete_tag", {
165
+ title: "Delete Things Tag",
166
+ description: "Delete a tag. Requires confirm=true.",
167
+ inputSchema: deleteTagInputSchema,
168
+ annotations: {
169
+ readOnlyHint: false,
170
+ destructiveHint: true,
171
+ idempotentHint: true,
172
+ openWorldHint: false
173
+ }
174
+ }, async (params) => {
175
+ try {
176
+ const input = deleteTagInputSchema.parse(params);
177
+ const data = await service.deleteTag(input);
178
+ return toToolSuccess(data);
179
+ }
180
+ catch (error) {
181
+ return toToolError(error);
182
+ }
183
+ });
124
184
  server.registerTool("things_delete_todo", {
125
185
  title: "Delete Things Todo",
126
186
  description: "Delete a todo. Requires confirm=true.",
@@ -7,8 +7,12 @@ export function toToolSuccess(data) {
7
7
  }
8
8
  export function toToolError(error) {
9
9
  const message = error instanceof Error ? redactSensitive(error.message) : "Unknown error";
10
+ const causeText = typeof error === "object" && error !== null && "causeText" in error
11
+ ? redactSensitive(String(error.causeText ?? ""))
12
+ : "";
13
+ const details = causeText.length > 0 ? `${message}\nCause: ${causeText}` : message;
10
14
  return {
11
15
  isError: true,
12
- content: [{ type: "text", text: `Error: ${message}` }]
16
+ content: [{ type: "text", text: `Error: ${details}` }]
13
17
  };
14
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "things-mcp-server",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server for controlling Things on macOS",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",