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 +12 -3
- package/dist/adapters/apple-script-adapter.js +275 -0
- package/dist/adapters/url-scheme-adapter.js +1 -1
- package/dist/schemas/tool-schemas.js +51 -0
- package/dist/services/things-service.js +55 -1
- package/dist/tools/register-read-tools.js +41 -1
- package/dist/tools/register-write-tools.js +61 -1
- package/dist/utils/tool-response.js +5 -1
- package/package.json +1 -1
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
|
|
126
|
-
- AppleScript is used for read operations, plus write operations `add_area`, `delete_todo`, and
|
|
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",
|
|
@@ -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
|
-
|
|
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: ${
|
|
16
|
+
content: [{ type: "text", text: `Error: ${details}` }]
|
|
13
17
|
};
|
|
14
18
|
}
|