things-api 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -12
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/clock.d.ts +8 -0
- package/dist/cli/clock.js +24 -0
- package/dist/cli/clock.js.map +1 -0
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +129 -75
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +45 -0
- package/dist/cli/commands/install-skill.js +207 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +74 -35
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +459 -165
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +8 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +53 -30
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +20 -13
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +388 -161
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +55 -2
- package/dist/cli/glyphs.js +114 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +405 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +64 -26
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +45 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +9 -8
- package/dist/cli/period.js +56 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +77 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +333 -103
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +21 -0
- package/dist/cli/skill-check.js +82 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +62 -0
- package/dist/cli/skill.js +142 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +63 -0
- package/dist/cli/tag-filters.js +59 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +163 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/version.d.ts +4 -0
- package/dist/cli/version.js +18 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +160 -24
- package/dist/client.js +132 -19
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +97 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +36 -5
- package/dist/index.js +38 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +1038 -436
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/clock.d.ts +61 -0
- package/dist/model/clock.js +71 -0
- package/dist/model/clock.js.map +1 -0
- package/dist/model/dates.d.ts +26 -3
- package/dist/model/dates.js +84 -3
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +48 -10
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +9 -2
- package/dist/model/mappers.js +32 -9
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +55 -17
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +11 -4
- package/dist/read/detail.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/log-boundary.d.ts +1 -1
- package/dist/read/log-boundary.js +13 -3
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +45 -12
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +152 -7
- package/dist/read/queries.js +277 -36
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/snapshot.d.ts +1 -1
- package/dist/read/snapshot.js +9 -2
- package/dist/read/snapshot.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +147 -22
- package/dist/read/views.js +282 -80
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +71 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +8 -1
- package/dist/write/automation-probe.js +16 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +5 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +3 -1
- package/dist/write/commands.js +602 -49
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +87 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +73 -2
- package/dist/write/pipeline.js +218 -43
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.js +8 -1
- package/dist/write/planner.js.map +1 -1
- package/dist/write/pre-state.d.ts +105 -3
- package/dist/write/pre-state.js +144 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/recurrence-rule-blob.d.ts +65 -0
- package/dist/write/recurrence-rule-blob.js +150 -0
- package/dist/write/recurrence-rule-blob.js.map +1 -0
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +15 -1
- package/dist/write/vectors/registry.js +65 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1136 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +154 -3
- package/dist/write/vectors/ui-certification.d.ts +54 -0
- package/dist/write/vectors/ui-certification.js +43 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +83 -0
- package/dist/write/vectors/ui-recipes.js +610 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +152 -0
- package/dist/write/vectors/ui.js +704 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +141 -4
- package/dist/write/verify/delta.js +199 -21
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +21 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +18 -3
- package/skills/things-cli/SKILL.md +53 -0
- package/skills/things-cli/references/contracts.md +41 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +51 -0
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
package/dist/mcp/server.js
CHANGED
|
@@ -13,37 +13,38 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
-
import { openThings } from "../
|
|
17
|
-
import { PKG_VERSION } from "../contracts.js";
|
|
18
|
-
import { diagnose } from "../diagnose.js";
|
|
19
|
-
import { AREA_PREVIEW_LIMIT, DEFAULT_LIST_LIMIT, PROJECT_PREVIEW_LIMIT, capAreaSections, paginateList, paginateToday, previewSections, previewSomedaySections, } from "../read/pagination.js";
|
|
20
|
-
import { resolveCap } from "../read/caps.js";
|
|
21
|
-
import { ALL_DESC, AREA_LIMIT_DESC, DATE_FORMAT, LIMIT_DESC, PROJECT_LIMIT_DESC, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES, } from "../surface-copy.js";
|
|
22
|
-
import { capabilitiesTable } from "../write/capabilities.js";
|
|
23
|
-
import { OPERATION_KINDS } from "../write/operations.js";
|
|
24
|
-
import { BOUNCE_MAX_ITEMS } from "../write/reorder.js";
|
|
16
|
+
import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, isValidTimeZone, LIMIT_DESC, MCP_WHEN_LABELS, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
|
|
25
17
|
function jsonResult(data) {
|
|
26
18
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
27
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A read result: like {@link jsonResult}, but the entity payload passes through
|
|
22
|
+
* the omit-empty transform (docs/design/contracts.md) so an empty optional
|
|
23
|
+
* field is absent, not null/[]. Mutation results keep their own shape and use
|
|
24
|
+
* jsonResult directly.
|
|
25
|
+
*/
|
|
26
|
+
function readResult(data) {
|
|
27
|
+
return jsonResult(omitEmpty(data));
|
|
28
|
+
}
|
|
28
29
|
/**
|
|
29
30
|
* A read result carrying truncation metadata: the data (already limited) in
|
|
30
|
-
* the first content block, and a second block with the {@link
|
|
31
|
+
* the first content block, and a second block with the {@link Truncation}
|
|
31
32
|
* numbers plus a one-line note the agent can read when rows were dropped.
|
|
32
33
|
*/
|
|
33
|
-
function
|
|
34
|
-
const note =
|
|
35
|
-
? `showing ${
|
|
34
|
+
function truncatedResult(data, truncation) {
|
|
35
|
+
const note = truncation.truncated
|
|
36
|
+
? `showing ${truncation.shown} of ${truncation.total} items — pass limit (or all: true) to see more`
|
|
36
37
|
: undefined;
|
|
37
38
|
return {
|
|
38
39
|
content: [
|
|
39
|
-
{ type: "text", text: JSON.stringify(data) },
|
|
40
|
-
{ type: "text", text: JSON.stringify({
|
|
40
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
41
|
+
{ type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
|
|
41
42
|
],
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
46
|
* Grouped read result (anytime/someday): the per-block-truncated sections plus
|
|
46
|
-
* a second block carrying the {@link
|
|
47
|
+
* a second block carrying the {@link GroupedTruncation} counts and, when
|
|
47
48
|
* anything was hidden, a one-line note the agent can read.
|
|
48
49
|
*/
|
|
49
50
|
function groupedResult(data, grouped) {
|
|
@@ -52,20 +53,59 @@ function groupedResult(data, grouped) {
|
|
|
52
53
|
: undefined;
|
|
53
54
|
return {
|
|
54
55
|
content: [
|
|
55
|
-
{ type: "text", text: JSON.stringify(data) },
|
|
56
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
56
57
|
{ type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
|
|
57
58
|
],
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Resolve one MCP cap (limit / area_limit / project_limit) against `all` into a
|
|
63
|
+
* row cap for the client: `null` = every row, `"conflict"` when an explicit
|
|
64
|
+
* value is combined with `all: true` (the tool returns a usage error), else the
|
|
65
|
+
* value or the default. The client re-applies the same semantics on what it
|
|
66
|
+
* receives; this copy owns the conflict detection the MCP surface reports.
|
|
67
|
+
*/
|
|
68
|
+
function resolveCap(value, all, defaultLimit) {
|
|
69
|
+
if (all === true && value !== undefined)
|
|
70
|
+
return "conflict";
|
|
71
|
+
if (all === true)
|
|
72
|
+
return null;
|
|
73
|
+
return value ?? defaultLimit;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve MCP limit/all (flat read tools) into a row cap (null = every row).
|
|
77
|
+
* all:true wins: it lifts the cap and takes precedence over a limit passed
|
|
78
|
+
* alongside it — an explicit "everything" request resolves the contradiction
|
|
79
|
+
* rather than erroring — so the pair is never a usage error on the flat tools.
|
|
80
|
+
*/
|
|
61
81
|
function resolveLimit(args) {
|
|
62
|
-
|
|
82
|
+
if (args.all === true)
|
|
83
|
+
return null;
|
|
84
|
+
return args.limit ?? DEFAULT_LIST_LIMIT;
|
|
63
85
|
}
|
|
86
|
+
/** Precedence notes appended to `limit`/`all` wherever a flat tool accepts both. */
|
|
87
|
+
const LIMIT_IGNORED_NOTE = "ignored when all is set";
|
|
88
|
+
const ALL_WINS_NOTE = "wins over limit if both are set";
|
|
64
89
|
/** Shared limit/all input schema fragment for the flat read tools. */
|
|
65
90
|
const limitShape = {
|
|
66
|
-
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
67
|
-
all: z.boolean().optional().describe(ALL_DESC),
|
|
91
|
+
limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
92
|
+
all: z.boolean().optional().describe(`${ALL_DESC}; ${ALL_WINS_NOTE}`),
|
|
68
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* The per-call time-zone knob for date-sensitive tools: an IANA zone that
|
|
96
|
+
* evaluates every date boundary (today/evening/upcoming/logbook/overdue/…) for
|
|
97
|
+
* the consumer's calendar, overriding the server's THINGS_TZ for THIS call.
|
|
98
|
+
*/
|
|
99
|
+
const TZ_DESC = "IANA time zone (e.g. Asia/Tokyo) to evaluate date boundaries in for this call — " +
|
|
100
|
+
"overrides the server default. Reminder times stay wall-clock and are never shifted.";
|
|
101
|
+
const tzShape = { tz: z.string().optional().describe(TZ_DESC) };
|
|
102
|
+
/** A usage result when `tz` is present but not a recognized IANA zone; null when it is valid/absent. */
|
|
103
|
+
function badTz(tz) {
|
|
104
|
+
if (tz !== undefined && !isValidTimeZone(tz)) {
|
|
105
|
+
return usage(`tz is not a valid IANA time zone: "${tz}" — expected e.g. "America/New_York" or "Asia/Tokyo"`);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
69
109
|
function errorResult(error) {
|
|
70
110
|
return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
|
|
71
111
|
}
|
|
@@ -80,14 +120,14 @@ function mutationResult(result) {
|
|
|
80
120
|
return jsonResult(result);
|
|
81
121
|
case "blocked":
|
|
82
122
|
return errorResult({
|
|
83
|
-
code:
|
|
123
|
+
code: blockedCode(result),
|
|
84
124
|
message: result.detail,
|
|
85
125
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
86
126
|
remediation: result.remediation,
|
|
87
127
|
});
|
|
88
128
|
case "verify-failed":
|
|
89
129
|
return errorResult({
|
|
90
|
-
code:
|
|
130
|
+
code: verifyFailedCode(result),
|
|
91
131
|
message: result.detail,
|
|
92
132
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
93
133
|
...(result.hint !== undefined && { remediation: result.hint }),
|
|
@@ -109,19 +149,87 @@ function mutationResult(result) {
|
|
|
109
149
|
const READ_ONLY = { readOnlyHint: true };
|
|
110
150
|
const NON_DESTRUCTIVE = { destructiveHint: false };
|
|
111
151
|
const DESTRUCTIVE = { destructiveHint: true };
|
|
112
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The three tag-filter inputs shared by every tag-accepting tool. `tag` is an
|
|
154
|
+
* ARRAY — repeat a tag to AND several together (both keep hierarchy-descendant
|
|
155
|
+
* expansion, which `exact_tag` disables). In FLAT views (read_view, search, and
|
|
156
|
+
* the projects list of list_collections) `tag` honors container inheritance; in
|
|
157
|
+
* the SINGLE-CONTAINER tools (get_project, get_area) it matches a tag carried
|
|
158
|
+
* DIRECTLY on the item — that container's own inherited tags are ignored (every
|
|
159
|
+
* child inherits them, so an inheritance-inclusive match would be vacuous).
|
|
160
|
+
*/
|
|
161
|
+
const tagOnlyShape = {
|
|
113
162
|
tag: z
|
|
114
|
-
.string()
|
|
163
|
+
.array(z.string())
|
|
115
164
|
.optional()
|
|
116
|
-
.describe(`Filter by tag (${REF_FORMAT});
|
|
117
|
-
|
|
165
|
+
.describe(`Filter by tag (${REF_FORMAT}); repeat to AND several tags. Matches a direct, ` +
|
|
166
|
+
"container-inherited, or descendant tag (in a container tool the container's own " +
|
|
167
|
+
"inherited tags are ignored — the tag must be on the item itself)"),
|
|
168
|
+
exact_tag: z
|
|
169
|
+
.boolean()
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("Match only the named tag(s), not their nested children"),
|
|
172
|
+
untagged: z
|
|
173
|
+
.boolean()
|
|
174
|
+
.optional()
|
|
175
|
+
.describe("Only items with no tag (direct or inherited); not combinable with tag/exact_tag " +
|
|
176
|
+
"(in a container tool: no tag on the item itself, ignoring inherited tags)"),
|
|
118
177
|
};
|
|
178
|
+
const tagFilterShape = {
|
|
179
|
+
...tagOnlyShape,
|
|
180
|
+
overdue: z
|
|
181
|
+
.boolean()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe("Only open items past their deadline (due today is not overdue)"),
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Map the MCP tool's snake_case tag inputs onto the shared {@link TagPresence}
|
|
187
|
+
* shape (canonical CLI-flag spelling) so the one set of contract predicates —
|
|
188
|
+
* {@link hasTagPresence}, {@link tagFlagConflict}, {@link tagFilterFields},
|
|
189
|
+
* {@link validateViewArgs} — serves both surfaces. The only difference is
|
|
190
|
+
* `exact_tag` → `exactTag`.
|
|
191
|
+
*/
|
|
192
|
+
function tagPresence(args) {
|
|
193
|
+
return {
|
|
194
|
+
...(args.tag !== undefined && { tag: args.tag }),
|
|
195
|
+
...(args.exact_tag !== undefined && { exactTag: args.exact_tag }),
|
|
196
|
+
...(args.untagged !== undefined && { untagged: args.untagged }),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** The MCP-voiced usage copy for the tag-filter mutual-exclusivity conflict. */
|
|
200
|
+
const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
|
|
119
201
|
const dryRunShape = {
|
|
120
202
|
dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
|
|
121
203
|
};
|
|
204
|
+
/**
|
|
205
|
+
* The per-call opt-in for the tools that reach a change only by driving the
|
|
206
|
+
* local Things app's accessibility interface. Shared by `repeat`, the
|
|
207
|
+
* `convert_to_project` heading action, and the `areas` reorder scope.
|
|
208
|
+
*/
|
|
209
|
+
const driveGuiShape = {
|
|
210
|
+
dangerously_drive_gui: z
|
|
211
|
+
.boolean()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("Required: this drives the local Things app through its accessibility interface to " +
|
|
214
|
+
"make a change the app offers nowhere else. It briefly interacts with the app's UI " +
|
|
215
|
+
"on the machine running this server, and must be turned on first with `things config " +
|
|
216
|
+
"set ui-enabled true`. Intended for a dedicated always-on Mac."),
|
|
217
|
+
};
|
|
218
|
+
/** How a tag value may be expressed on any tag-accepting tool. */
|
|
219
|
+
const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
|
|
220
|
+
/** create_tags param, shared by every tag-accepting write tool. */
|
|
221
|
+
const createTagsShape = {
|
|
222
|
+
create_tags: z
|
|
223
|
+
.boolean()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("Create any named tag that does not exist yet (nesting parent/child) before applying, " +
|
|
226
|
+
"instead of stopping on an unknown tag"),
|
|
227
|
+
};
|
|
122
228
|
const containerRef = (ref) => ({ uuid: ref, title: ref });
|
|
123
229
|
/** Cap on project titles inlined into the server instructions. */
|
|
124
230
|
const INSTRUCTIONS_MAX_PROJECTS = 100;
|
|
231
|
+
/** A tag's display label: nested tags show `parent > child`. */
|
|
232
|
+
const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`;
|
|
125
233
|
/**
|
|
126
234
|
* Live-inventory preamble: conventions plus the user's actual areas, tags,
|
|
127
235
|
* and open projects, read once at server start so models can reference real
|
|
@@ -142,20 +250,31 @@ function buildInstructions(getClient) {
|
|
|
142
250
|
"rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
|
|
143
251
|
"add_area, add_project).",
|
|
144
252
|
`- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
|
|
145
|
-
`are ${REMINDER_FORMAT}
|
|
253
|
+
`are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
|
|
254
|
+
`below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
|
|
146
255
|
"- Every write tool accepts dry_run: true to preview the change without applying it. " +
|
|
147
|
-
"
|
|
148
|
-
"
|
|
256
|
+
"A preview creates no state, so later calls cannot reference an item that only appeared in " +
|
|
257
|
+
"a dry-run result. Operations with cascading or permanent effects require the explicit " +
|
|
258
|
+
"confirmation parameter named in their description; refused calls return an error saying " +
|
|
259
|
+
"what to pass.",
|
|
260
|
+
"- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
|
|
261
|
+
"tags inherited from its containing project and area. todaySection appears only for an item " +
|
|
262
|
+
"in Today, naming its section there (today or evening); an unscheduled start=active item is " +
|
|
263
|
+
"in Anytime and omits the field. Completing an item makes it findable in Logbook.",
|
|
264
|
+
"- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
|
|
265
|
+
`- Read results are compact: ${OMIT_EMPTY_NOTE}`,
|
|
149
266
|
];
|
|
150
267
|
try {
|
|
151
268
|
const c = getClient();
|
|
152
269
|
const areas = c.read.areas();
|
|
153
270
|
const tags = c.read.tags();
|
|
154
271
|
const projects = c.read.projects();
|
|
155
|
-
const
|
|
272
|
+
const clock = c.clockMeta();
|
|
156
273
|
const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
|
|
157
274
|
const overflow = projects.length - shown.length;
|
|
158
|
-
lines.push("", "Current inventory (read at server start — refresh with list_collections):",
|
|
275
|
+
lines.push("", "Current inventory (read at server start — refresh with list_collections):", ...(clock !== undefined
|
|
276
|
+
? [`- Calendar context at server start: ${JSON.stringify(clock)}`]
|
|
277
|
+
: []), `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
|
|
159
278
|
(overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
|
|
160
279
|
}
|
|
161
280
|
catch {
|
|
@@ -169,6 +288,24 @@ function buildInstructions(getClient) {
|
|
|
169
288
|
function checklistTarget(args) {
|
|
170
289
|
return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
|
|
171
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Derive the audit author for a client's writes from the MCP initialize
|
|
293
|
+
* handshake's clientInfo.name: lowercased, every run of non-alphanumerics
|
|
294
|
+
* collapsed to a single "-", leading/trailing dashes trimmed, capped at 32
|
|
295
|
+
* characters. An absent (or empty-after-sanitizing) client name falls back to
|
|
296
|
+
* the bare "mcp". The result is never caller-settable — it is the connecting
|
|
297
|
+
* client's own identity, so each client's writes are attributed to it.
|
|
298
|
+
*/
|
|
299
|
+
const MCP_ACTOR_PREFIX = "mcp";
|
|
300
|
+
function deriveMcpActor(clientName) {
|
|
301
|
+
const slug = (clientName ?? "")
|
|
302
|
+
.toLowerCase()
|
|
303
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
304
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
305
|
+
.slice(0, 32)
|
|
306
|
+
.replaceAll(/-+$/g, "");
|
|
307
|
+
return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
|
|
308
|
+
}
|
|
172
309
|
export function createThingsMcpServer(options = {}) {
|
|
173
310
|
// One lazily-opened client for the server's lifetime; SQLite read
|
|
174
311
|
// snapshots are per-statement, so fresh reads see external commits.
|
|
@@ -181,26 +318,81 @@ export function createThingsMcpServer(options = {}) {
|
|
|
181
318
|
return client;
|
|
182
319
|
};
|
|
183
320
|
const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
|
|
321
|
+
// The audit author for every write on this connection, derived once from the
|
|
322
|
+
// client's handshake identity (clientInfo.name). Read per call: clientInfo is
|
|
323
|
+
// populated when the initialize handshake completes, before any tool can run.
|
|
324
|
+
// Not caller-settable — no tool argument overrides it.
|
|
325
|
+
const mcpActor = () => deriveMcpActor(server.server.getClientVersion()?.name);
|
|
326
|
+
/** Translate the shared MCP write-tool args into pipeline WriteOptions. */
|
|
327
|
+
const writeOptions = (args) => ({
|
|
328
|
+
actor: mcpActor(),
|
|
329
|
+
...(options.maxDisruption !== undefined && { maxDisruption: options.maxDisruption }),
|
|
330
|
+
...(args.dry_run === true && { dryRun: true }),
|
|
331
|
+
...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
|
|
332
|
+
...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
|
|
333
|
+
...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
334
|
+
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
335
|
+
...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
336
|
+
...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
|
|
337
|
+
...(args.create_tags === true && { createTags: true }),
|
|
338
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
339
|
+
});
|
|
184
340
|
/** Run a handler, mapping environment/usage throws to tool errors. */
|
|
185
341
|
const guard = async (fn) => {
|
|
186
342
|
try {
|
|
187
343
|
return await fn();
|
|
188
344
|
}
|
|
189
345
|
catch (err) {
|
|
346
|
+
// An unresolved reference (ambiguous or not-found uuid/partial-uuid/name)
|
|
347
|
+
// carries machine-readable candidates so a consumer can disambiguate as
|
|
348
|
+
// data, not by re-parsing the prose message.
|
|
349
|
+
if (err instanceof ReferenceResolutionError) {
|
|
350
|
+
return errorResult({
|
|
351
|
+
code: err.code,
|
|
352
|
+
message: err.message,
|
|
353
|
+
details: { candidates: err.candidates },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
190
356
|
const message = err instanceof Error ? err.message : String(err);
|
|
191
357
|
const code = err instanceof RangeError ? "usage" : "environment";
|
|
192
358
|
return errorResult({ code, message });
|
|
193
359
|
}
|
|
194
360
|
};
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
361
|
+
/**
|
|
362
|
+
* A read handler wrapper: run through {@link guard}, then — on a successful
|
|
363
|
+
* result — append a meta block carrying any non-blocking schema warning (the
|
|
364
|
+
* same note the CLI prints), so a consumer sees when the Things database no
|
|
365
|
+
* longer matches the validated schema and its data may be incomplete. No
|
|
366
|
+
* block is added when the schema checks out or the read itself errored.
|
|
367
|
+
*/
|
|
368
|
+
const readGuard = async (fn, tz) => {
|
|
369
|
+
const result = await guard(fn);
|
|
370
|
+
if (result.isError === true)
|
|
371
|
+
return result;
|
|
372
|
+
let warnings = [];
|
|
373
|
+
let clock;
|
|
374
|
+
try {
|
|
375
|
+
const c = getClient();
|
|
376
|
+
warnings = schemaWarnings(c.schemaStatus());
|
|
377
|
+
// The clock honesty field for this call's effective zone (the per-call
|
|
378
|
+
// tz over the server default) — present only when a consumer zone /
|
|
379
|
+
// pinned now is in effect.
|
|
380
|
+
clock = c.clockMeta(tz);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
warnings = [];
|
|
384
|
+
}
|
|
385
|
+
const meta = {
|
|
386
|
+
...(warnings.length > 0 && { warnings }),
|
|
387
|
+
...(clock !== undefined && { clock }),
|
|
388
|
+
};
|
|
389
|
+
if (Object.keys(meta).length === 0)
|
|
390
|
+
return result;
|
|
391
|
+
return {
|
|
392
|
+
...result,
|
|
393
|
+
content: [...result.content, { type: "text", text: JSON.stringify({ meta }) }],
|
|
394
|
+
};
|
|
395
|
+
};
|
|
204
396
|
/** Resolve a uuid to to-do/project for the type-generic item tools. */
|
|
205
397
|
const itemType = (uuid) => {
|
|
206
398
|
const item = getClient().read.byUuid(uuid);
|
|
@@ -216,7 +408,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
216
408
|
description: "Read a Things list as the app presents it: today (split into Today and This " +
|
|
217
409
|
"Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
|
|
218
410
|
"horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
|
|
219
|
-
"anytime/someday return
|
|
411
|
+
"anytime/someday return sections in canonical order (area + items; null area = the " +
|
|
220
412
|
"top-level block); children of someday/future-scheduled projects are excluded " +
|
|
221
413
|
"from anytime — the project row represents them; someday lists each group's " +
|
|
222
414
|
"project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
|
|
@@ -224,10 +416,13 @@ export function createThingsMcpServer(options = {}) {
|
|
|
224
416
|
"anytime/someday always return every group and cap per block instead — " +
|
|
225
417
|
`area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
|
|
226
418
|
`project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
|
|
227
|
-
"all: true lifts every cap; the result's second block reports the counts."
|
|
419
|
+
"all: true lifts every cap; the result's second block reports the counts. " +
|
|
420
|
+
OMIT_EMPTY_NOTE,
|
|
228
421
|
inputSchema: {
|
|
229
422
|
view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
|
|
230
423
|
...tagFilterShape,
|
|
424
|
+
...tzShape,
|
|
425
|
+
evening: z.boolean().optional().describe("today only: show only the This Evening section"),
|
|
231
426
|
show_active_project_items: z
|
|
232
427
|
.union([z.boolean(), z.number().int().min(1)])
|
|
233
428
|
.optional()
|
|
@@ -249,7 +444,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
249
444
|
.int()
|
|
250
445
|
.min(1)
|
|
251
446
|
.optional()
|
|
252
|
-
.describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
|
|
447
|
+
.describe(`flat views only (not anytime/someday): ${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
253
448
|
area_limit: z
|
|
254
449
|
.number()
|
|
255
450
|
.int()
|
|
@@ -265,10 +460,24 @@ export function createThingsMcpServer(options = {}) {
|
|
|
265
460
|
all: z
|
|
266
461
|
.boolean()
|
|
267
462
|
.optional()
|
|
268
|
-
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"
|
|
463
|
+
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
|
|
464
|
+
ALL_WINS_NOTE),
|
|
269
465
|
},
|
|
270
466
|
annotations: READ_ONLY,
|
|
271
|
-
}, async (args) =>
|
|
467
|
+
}, async (args) => readGuard(() => {
|
|
468
|
+
const badZone = badTz(args.tz);
|
|
469
|
+
if (badZone !== null)
|
|
470
|
+
return badZone;
|
|
471
|
+
// Tag-conflict AND overdue-applicability both derive from the shared
|
|
472
|
+
// contract: read_view honors overdue only on today/inbox/anytime/someday
|
|
473
|
+
// (the current-work views), matching FILTER_CONTRACT.
|
|
474
|
+
const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
|
|
475
|
+
untaggedConflict: MCP_UNTAGGED_CONFLICT,
|
|
476
|
+
overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
|
|
477
|
+
overdueStatusWiden: "",
|
|
478
|
+
});
|
|
479
|
+
if (!validated.ok)
|
|
480
|
+
return usage(validated.message);
|
|
272
481
|
// show_active_project_items is the preferred name; active_project_items
|
|
273
482
|
// stays accepted as a compatibility alias.
|
|
274
483
|
const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
|
|
@@ -283,79 +492,90 @@ export function createThingsMcpServer(options = {}) {
|
|
|
283
492
|
if (args.view !== "someday" && showActiveProjectItems !== undefined) {
|
|
284
493
|
return usage("show_active_project_items applies only to someday");
|
|
285
494
|
}
|
|
495
|
+
if (args.view !== "today" && args.evening === true) {
|
|
496
|
+
return usage(`evening applies only to today, not ${args.view}`);
|
|
497
|
+
}
|
|
286
498
|
if (args.view === "someday" && args.project_limit !== undefined) {
|
|
287
499
|
return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
|
|
288
500
|
"to cap that section's project lists");
|
|
289
501
|
}
|
|
290
502
|
const limit = resolveLimit(args);
|
|
291
|
-
if (limit === "conflict")
|
|
292
|
-
return usage("pass at most one of limit / all");
|
|
293
503
|
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
294
504
|
const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
|
|
295
505
|
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
296
506
|
return usage("pass at most one of area_limit/project_limit / all");
|
|
297
507
|
}
|
|
298
508
|
const c = getClient();
|
|
299
|
-
const filter =
|
|
300
|
-
|
|
301
|
-
...(args.exact_tag === true && { exactTag: true }),
|
|
302
|
-
};
|
|
509
|
+
const filter = validated.filter;
|
|
510
|
+
const zone = args.tz !== undefined ? { zone: args.tz } : {};
|
|
303
511
|
switch (args.view) {
|
|
304
512
|
case "today": {
|
|
305
|
-
const {
|
|
306
|
-
|
|
513
|
+
const { view, truncation } = c.read.today({
|
|
514
|
+
...filter,
|
|
515
|
+
...zone,
|
|
516
|
+
...(args.evening === true && { eveningOnly: true }),
|
|
517
|
+
limit,
|
|
518
|
+
});
|
|
519
|
+
return truncatedResult(view, truncation);
|
|
307
520
|
}
|
|
308
521
|
case "inbox": {
|
|
309
|
-
const {
|
|
310
|
-
return
|
|
522
|
+
const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
|
|
523
|
+
return truncatedResult(items, truncation);
|
|
311
524
|
}
|
|
312
525
|
case "anytime": {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
526
|
+
const { view, grouped } = c.read.anytime({
|
|
527
|
+
...filter,
|
|
528
|
+
...zone,
|
|
529
|
+
areaLimit,
|
|
530
|
+
projectLimit,
|
|
531
|
+
});
|
|
532
|
+
return groupedResult(view, grouped);
|
|
316
533
|
}
|
|
317
534
|
case "upcoming": {
|
|
318
|
-
const {
|
|
535
|
+
const { items, truncation } = c.read.upcoming({
|
|
319
536
|
...filter,
|
|
537
|
+
...zone,
|
|
320
538
|
...(args.horizon !== undefined && { horizon: args.horizon }),
|
|
321
|
-
|
|
322
|
-
|
|
539
|
+
limit,
|
|
540
|
+
});
|
|
541
|
+
return truncatedResult(items, truncation);
|
|
323
542
|
}
|
|
324
543
|
case "someday": {
|
|
325
544
|
const active = showActiveProjectItems;
|
|
326
545
|
if (typeof active === "number" && args.all === true) {
|
|
327
546
|
return usage("pass at most one of a numeric show_active_project_items / all");
|
|
328
547
|
}
|
|
329
|
-
const
|
|
330
|
-
area: areaLimit,
|
|
331
|
-
// true = every item per project; a number caps each list.
|
|
332
|
-
project: typeof active === "number" ? active : null,
|
|
333
|
-
};
|
|
334
|
-
const { data, grouped } = previewSomedaySections(c.read.someday({
|
|
548
|
+
const { view, grouped } = c.read.someday({
|
|
335
549
|
...filter,
|
|
550
|
+
...zone,
|
|
336
551
|
...((active === true || typeof active === "number") && {
|
|
337
552
|
activeProjectItems: true,
|
|
338
553
|
}),
|
|
339
|
-
|
|
340
|
-
|
|
554
|
+
areaLimit,
|
|
555
|
+
// true = every item per project; a number caps each list.
|
|
556
|
+
projectLimit: typeof active === "number" ? active : null,
|
|
557
|
+
});
|
|
558
|
+
return groupedResult(view, grouped);
|
|
341
559
|
}
|
|
342
560
|
case "logbook": {
|
|
343
|
-
const {
|
|
344
|
-
return
|
|
561
|
+
const { items, truncation } = c.read.logbook({ ...filter, ...zone, limit });
|
|
562
|
+
return truncatedResult(items, truncation);
|
|
345
563
|
}
|
|
346
564
|
case "trash": {
|
|
347
|
-
const {
|
|
348
|
-
return
|
|
565
|
+
const { items, truncation } = c.read.trash({ ...zone, limit });
|
|
566
|
+
return truncatedResult(items, truncation);
|
|
349
567
|
}
|
|
350
568
|
}
|
|
351
|
-
}));
|
|
569
|
+
}, args.tz));
|
|
352
570
|
server.registerTool("search", {
|
|
353
571
|
description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
|
|
354
572
|
"include more with logged/trashed/all. Scope with project/area/tag — scope " +
|
|
355
|
-
"references must name existing items."
|
|
573
|
+
"references must name existing items. " +
|
|
574
|
+
OMIT_EMPTY_NOTE,
|
|
356
575
|
inputSchema: {
|
|
357
576
|
query: z.string(),
|
|
358
577
|
...tagFilterShape,
|
|
578
|
+
...tzShape,
|
|
359
579
|
project: z
|
|
360
580
|
.string()
|
|
361
581
|
.optional()
|
|
@@ -370,72 +590,121 @@ export function createThingsMcpServer(options = {}) {
|
|
|
370
590
|
all: z
|
|
371
591
|
.boolean()
|
|
372
592
|
.optional()
|
|
373
|
-
.describe(
|
|
374
|
-
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
593
|
+
.describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
|
|
594
|
+
limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
375
595
|
},
|
|
376
596
|
annotations: READ_ONLY,
|
|
377
|
-
}, async (args) =>
|
|
597
|
+
}, async (args) => readGuard(() => {
|
|
598
|
+
const badZone = badTz(args.tz);
|
|
599
|
+
if (badZone !== null)
|
|
600
|
+
return badZone;
|
|
601
|
+
// Tag-conflict AND the overdue/status-widening incompatibility both
|
|
602
|
+
// derive from the shared contract (search: statusWidening = true).
|
|
603
|
+
const validated = validateViewArgs("search", {
|
|
604
|
+
...tagPresence(args),
|
|
605
|
+
overdue: args.overdue,
|
|
606
|
+
logged: args.logged,
|
|
607
|
+
trashed: args.trashed,
|
|
608
|
+
all: args.all,
|
|
609
|
+
}, {
|
|
610
|
+
untaggedConflict: MCP_UNTAGGED_CONFLICT,
|
|
611
|
+
overdueRejected: "overdue does not apply to search",
|
|
612
|
+
overdueStatusWiden: "overdue lists open items; it does not combine with logged/trashed/all",
|
|
613
|
+
});
|
|
614
|
+
if (!validated.ok)
|
|
615
|
+
return usage(validated.message);
|
|
378
616
|
const limit = resolveLimit(args);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
...(args.tag !== undefined && { tag: args.tag }),
|
|
384
|
-
...(args.exact_tag === true && { exactTag: true }),
|
|
617
|
+
const { items, truncation } = getClient().read.search(args.query, {
|
|
618
|
+
limit,
|
|
619
|
+
...validated.filter,
|
|
620
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
385
621
|
...(args.project !== undefined && { project: args.project }),
|
|
386
622
|
...(args.area !== undefined && { area: args.area }),
|
|
387
623
|
...(args.type !== undefined && { type: args.type }),
|
|
388
624
|
...(args.logged === true && { logged: true }),
|
|
389
625
|
...(args.trashed === true && { trashed: true }),
|
|
390
626
|
...(args.all === true && { all: true }),
|
|
391
|
-
})
|
|
392
|
-
return
|
|
393
|
-
}));
|
|
627
|
+
});
|
|
628
|
+
return truncatedResult(items, truncation);
|
|
629
|
+
}, args.tz));
|
|
394
630
|
server.registerTool("changes_since", {
|
|
395
631
|
description: "List items created or modified since a moment — including trashed, logged, and " +
|
|
396
632
|
"repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
|
|
397
|
-
"areas, and checklist items do not mark the containing item as modified."
|
|
633
|
+
"areas, and checklist items do not mark the containing item as modified. " +
|
|
634
|
+
OMIT_EMPTY_NOTE,
|
|
398
635
|
inputSchema: {
|
|
399
636
|
since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
|
|
400
637
|
...limitShape,
|
|
638
|
+
...tzShape,
|
|
401
639
|
},
|
|
402
640
|
annotations: READ_ONLY,
|
|
403
|
-
}, async (args) =>
|
|
641
|
+
}, async (args) => readGuard(() => {
|
|
642
|
+
const badZone = badTz(args.tz);
|
|
643
|
+
if (badZone !== null)
|
|
644
|
+
return badZone;
|
|
404
645
|
const limit = resolveLimit(args);
|
|
405
|
-
if (limit === "conflict")
|
|
406
|
-
return usage("pass at most one of limit / all");
|
|
407
646
|
const since = new Date(args.since);
|
|
408
647
|
if (Number.isNaN(since.getTime())) {
|
|
409
648
|
return usage(`since is not a parseable date: ${args.since}`);
|
|
410
649
|
}
|
|
411
|
-
const {
|
|
412
|
-
|
|
413
|
-
|
|
650
|
+
const { items, truncation } = getClient().read.changes({
|
|
651
|
+
since,
|
|
652
|
+
limit,
|
|
653
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
654
|
+
});
|
|
655
|
+
return truncatedResult(items, truncation);
|
|
656
|
+
}, args.tz));
|
|
414
657
|
server.registerTool("get_item", {
|
|
415
658
|
description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
|
|
416
659
|
"(direct and inherited), checklist with per-item state, repeat schedule, and its " +
|
|
417
|
-
"project/area/heading."
|
|
660
|
+
"project/area/heading. " +
|
|
661
|
+
OMIT_EMPTY_NOTE,
|
|
418
662
|
inputSchema: { uuid: z.string() },
|
|
419
663
|
annotations: READ_ONLY,
|
|
420
|
-
}, async (args) =>
|
|
664
|
+
}, async (args) => readGuard(() => {
|
|
421
665
|
const item = getClient().read.byUuid(args.uuid);
|
|
422
666
|
return item === null
|
|
423
|
-
? errorResult({ code: "not-found", message:
|
|
424
|
-
:
|
|
667
|
+
? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
|
|
668
|
+
: readResult(item);
|
|
425
669
|
}));
|
|
426
670
|
server.registerTool("get_project", {
|
|
427
|
-
description: "One project's full contents: metadata plus its to-dos grouped under their headings."
|
|
428
|
-
|
|
671
|
+
description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
|
|
672
|
+
"The tag filters keep only the child to-dos matching by their own tags (a heading left " +
|
|
673
|
+
"with none is dropped). " +
|
|
674
|
+
OMIT_EMPTY_NOTE,
|
|
675
|
+
inputSchema: {
|
|
676
|
+
uuid: z.string().describe("Project uuid or unique name"),
|
|
677
|
+
...tagOnlyShape,
|
|
678
|
+
...tzShape,
|
|
679
|
+
overdue: z
|
|
680
|
+
.boolean()
|
|
681
|
+
.optional()
|
|
682
|
+
.describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
|
|
683
|
+
},
|
|
429
684
|
annotations: READ_ONLY,
|
|
430
|
-
}, async (args) =>
|
|
685
|
+
}, async (args) => readGuard(() => {
|
|
686
|
+
const badZone = badTz(args.tz);
|
|
687
|
+
if (badZone !== null)
|
|
688
|
+
return badZone;
|
|
689
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
690
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
691
|
+
return readResult(getClient().read.projectView(args.uuid, {
|
|
692
|
+
overdue: args.overdue === true,
|
|
693
|
+
...tagFilterFields(tagPresence(args)),
|
|
694
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
695
|
+
}));
|
|
696
|
+
}, args.tz));
|
|
431
697
|
server.registerTool("get_area", {
|
|
432
698
|
description: "One area's contents: metadata plus its direct to-dos (active first), its " +
|
|
433
|
-
"projects in
|
|
699
|
+
"projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
|
|
434
700
|
`The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
|
|
435
701
|
"by default (project_limit / area_limit adjust them; all: true lifts both); the " +
|
|
436
|
-
"second result block reports the counts."
|
|
702
|
+
"second result block reports the counts. " +
|
|
703
|
+
OMIT_EMPTY_NOTE,
|
|
437
704
|
inputSchema: {
|
|
438
705
|
ref: z.string().describe("Area uuid or unique name"),
|
|
706
|
+
...tagOnlyShape,
|
|
707
|
+
...tzShape,
|
|
439
708
|
area_limit: z
|
|
440
709
|
.number()
|
|
441
710
|
.int()
|
|
@@ -448,38 +717,89 @@ export function createThingsMcpServer(options = {}) {
|
|
|
448
717
|
.min(1)
|
|
449
718
|
.optional()
|
|
450
719
|
.describe(`maximum project rows to return (default ${AREA_PREVIEW_LIMIT})`),
|
|
720
|
+
overdue: z
|
|
721
|
+
.boolean()
|
|
722
|
+
.optional()
|
|
723
|
+
.describe("Keep only rows (loose to-dos AND child projects) whose own deadline is past (due today is not overdue); no descent into project contents"),
|
|
451
724
|
all: z.boolean().optional().describe("return both sections in full (no caps)"),
|
|
452
725
|
},
|
|
453
726
|
annotations: READ_ONLY,
|
|
454
|
-
}, async (args) =>
|
|
727
|
+
}, async (args) => readGuard(() => {
|
|
728
|
+
const badZone = badTz(args.tz);
|
|
729
|
+
if (badZone !== null)
|
|
730
|
+
return badZone;
|
|
731
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
732
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
455
733
|
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
456
734
|
const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
457
735
|
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
458
736
|
return usage("pass at most one of area_limit/project_limit / all");
|
|
459
737
|
}
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
738
|
+
const { view, grouped } = getClient().read.areaView(args.ref, {
|
|
739
|
+
overdue: args.overdue === true,
|
|
740
|
+
...tagFilterFields(tagPresence(args)),
|
|
741
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
742
|
+
areaLimit,
|
|
743
|
+
projectLimit,
|
|
744
|
+
});
|
|
745
|
+
return groupedResult(view, grouped);
|
|
746
|
+
}, args.tz));
|
|
464
747
|
server.registerTool("list_collections", {
|
|
465
748
|
description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
|
|
466
|
-
"refresh the inventory summarized in the server instructions."
|
|
467
|
-
|
|
749
|
+
"refresh the inventory summarized in the server instructions. The tag filters scope " +
|
|
750
|
+
"the projects list by each project's own tags (areas/tags reject them). " +
|
|
751
|
+
OMIT_EMPTY_NOTE,
|
|
752
|
+
inputSchema: {
|
|
753
|
+
kind: z.enum(["projects", "areas", "tags"]),
|
|
754
|
+
...tagOnlyShape,
|
|
755
|
+
...tzShape,
|
|
756
|
+
overdue: z
|
|
757
|
+
.boolean()
|
|
758
|
+
.optional()
|
|
759
|
+
.describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
|
|
760
|
+
},
|
|
468
761
|
annotations: READ_ONLY,
|
|
469
|
-
}, async (args) =>
|
|
762
|
+
}, async (args) => readGuard(() => {
|
|
763
|
+
const badZone = badTz(args.tz);
|
|
764
|
+
if (badZone !== null)
|
|
765
|
+
return badZone;
|
|
470
766
|
const c = getClient();
|
|
471
|
-
|
|
472
|
-
|
|
767
|
+
// areas/tags are not dated entities and have no per-row tag list to
|
|
768
|
+
// filter — overdue and the tag filters are vacuous there, rejected
|
|
769
|
+
// fail-closed (the same style read_view uses for the wrong views). The
|
|
770
|
+
// decision derives from the contract: only the `projects` list carries
|
|
771
|
+
// a deadline (overdue) and per-row (inheritance-inclusive) tags; the
|
|
772
|
+
// `areas` list rejects both, and `tags` has no contract row.
|
|
773
|
+
const kindSpec = args.kind === "projects"
|
|
774
|
+
? FILTER_CONTRACT.projects
|
|
775
|
+
: args.kind === "areas"
|
|
776
|
+
? FILTER_CONTRACT.areas
|
|
777
|
+
: null;
|
|
778
|
+
if (args.overdue === true && (kindSpec === null || !kindSpec.overdue)) {
|
|
779
|
+
return usage(`overdue applies only to projects, not ${args.kind}`);
|
|
780
|
+
}
|
|
781
|
+
if ((kindSpec === null || kindSpec.tag === "rejected") &&
|
|
782
|
+
(hasTagPresence(tagPresence(args)) || args.untagged === true)) {
|
|
783
|
+
return usage(`the tag filters apply only to projects, not ${args.kind}`);
|
|
784
|
+
}
|
|
785
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
786
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
787
|
+
return readResult(args.kind === "projects"
|
|
788
|
+
? c.read.projects({
|
|
789
|
+
overdue: args.overdue === true,
|
|
790
|
+
...tagFilterFields(tagPresence(args)),
|
|
791
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
792
|
+
})
|
|
473
793
|
: args.kind === "areas"
|
|
474
794
|
? c.read.areas()
|
|
475
795
|
: c.read.tags());
|
|
476
|
-
}));
|
|
796
|
+
}, args.tz));
|
|
477
797
|
// ---------------------------------------------------------------- to-dos
|
|
478
798
|
const whenSchema = z.string().optional().describe(WHEN_VALUES);
|
|
479
799
|
server.registerTool("add_todo", {
|
|
480
800
|
description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
|
|
481
801
|
"deadline, tag it, give it a checklist, and place it in a project or area " +
|
|
482
|
-
"(optionally under
|
|
802
|
+
"(optionally under a heading within that project). A reminder " +
|
|
483
803
|
"requires when = today, evening, or a date. Adding into a completed or canceled " +
|
|
484
804
|
"project reopens that project — pass acknowledge_project_reopen to confirm.",
|
|
485
805
|
inputSchema: {
|
|
@@ -488,7 +808,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
488
808
|
when: whenSchema,
|
|
489
809
|
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
490
810
|
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
491
|
-
tags: z.array(z.string()).optional().describe(
|
|
811
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
492
812
|
checklist_items: z.array(z.string()).optional(),
|
|
493
813
|
project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
|
|
494
814
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
@@ -497,86 +817,216 @@ export function createThingsMcpServer(options = {}) {
|
|
|
497
817
|
.boolean()
|
|
498
818
|
.optional()
|
|
499
819
|
.describe("Confirm adding into a completed/canceled project (this reopens it)"),
|
|
820
|
+
...createTagsShape,
|
|
821
|
+
...tzShape,
|
|
500
822
|
...dryRunShape,
|
|
501
823
|
},
|
|
502
824
|
annotations: NON_DESTRUCTIVE,
|
|
503
|
-
}, async (args) => guard(async () =>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
825
|
+
}, async (args) => guard(async () => {
|
|
826
|
+
const badZone = badTz(args.tz);
|
|
827
|
+
if (badZone !== null)
|
|
828
|
+
return badZone;
|
|
829
|
+
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
830
|
+
if (sugar.kind === "error")
|
|
831
|
+
return usage(sugar.message);
|
|
832
|
+
const when = sugar.kind === "split" ? sugar.when : args.when;
|
|
833
|
+
const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
|
|
834
|
+
return mutationResult(await getClient().write.addTodo({
|
|
835
|
+
title: args.title,
|
|
836
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
837
|
+
...(when !== undefined && { when: when }),
|
|
838
|
+
...(reminder !== undefined && { reminder }),
|
|
839
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
840
|
+
...(args.tags !== undefined && { tags: args.tags }),
|
|
841
|
+
...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
|
|
842
|
+
...(args.project !== undefined && { project: containerRef(args.project) }),
|
|
843
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
844
|
+
...(args.heading !== undefined && { heading: args.heading }),
|
|
845
|
+
}, writeOptions(args)));
|
|
846
|
+
}));
|
|
847
|
+
server.registerTool("update", {
|
|
848
|
+
description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
|
|
849
|
+
"kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
|
|
850
|
+
"existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
|
|
851
|
+
"deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
|
|
852
|
+
"one is set, schedule and deadline changes are unavailable for repeating items, and " +
|
|
853
|
+
"clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
|
|
854
|
+
"future date can only be changed, not cleared). kind area: title and/or tags (the full " +
|
|
855
|
+
"replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
|
|
856
|
+
"exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
|
|
857
|
+
"create_tags is set.",
|
|
523
858
|
inputSchema: {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
859
|
+
kind: z.enum(["todo", "project", "area", "tag"]),
|
|
860
|
+
uuid: z
|
|
861
|
+
.string()
|
|
862
|
+
.describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
|
|
863
|
+
"unique name"),
|
|
864
|
+
title: z.string().optional().describe("New title (any kind)"),
|
|
865
|
+
notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
|
|
866
|
+
append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
|
|
867
|
+
prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
|
|
529
868
|
when: whenSchema,
|
|
530
|
-
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
531
|
-
clear_reminder: z.boolean().optional(),
|
|
532
|
-
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
533
|
-
clear_deadline: z.boolean().optional(),
|
|
869
|
+
reminder: z.string().optional().describe(`todo/project: ${REMINDER_FORMAT}`),
|
|
870
|
+
clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
|
|
871
|
+
deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
|
|
872
|
+
clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
|
|
873
|
+
tags: z
|
|
874
|
+
.array(z.string())
|
|
875
|
+
.optional()
|
|
876
|
+
.describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
|
|
877
|
+
parent: z.string().optional().describe("tag: existing tag to nest under"),
|
|
878
|
+
unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
|
|
879
|
+
shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
|
|
880
|
+
clear_shortcut: z.boolean().optional().describe("tag: remove the keyboard shortcut"),
|
|
881
|
+
...createTagsShape,
|
|
882
|
+
...tzShape,
|
|
534
883
|
...dryRunShape,
|
|
535
884
|
},
|
|
536
885
|
annotations: NON_DESTRUCTIVE,
|
|
537
886
|
}, async (args) => guard(async () => {
|
|
538
|
-
const
|
|
539
|
-
if (
|
|
540
|
-
return
|
|
887
|
+
const badZone = badTz(args.tz);
|
|
888
|
+
if (badZone !== null)
|
|
889
|
+
return badZone;
|
|
890
|
+
const opts = writeOptions(args);
|
|
891
|
+
const c = getClient();
|
|
892
|
+
if (args.kind === "todo" || args.kind === "project") {
|
|
893
|
+
const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
|
|
894
|
+
if (notesModes.length > 1) {
|
|
895
|
+
return usage("notes, append_notes, prepend_notes are exclusive");
|
|
896
|
+
}
|
|
897
|
+
if (args.reminder !== undefined && args.clear_reminder === true) {
|
|
898
|
+
return usage("pass at most one of reminder / clear_reminder");
|
|
899
|
+
}
|
|
900
|
+
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
901
|
+
return usage("pass at most one of deadline / clear_deadline");
|
|
902
|
+
}
|
|
903
|
+
if (args.kind === "todo") {
|
|
904
|
+
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
905
|
+
if (sugar.kind === "error")
|
|
906
|
+
return usage(sugar.message);
|
|
907
|
+
const when = sugar.kind === "split" ? sugar.when : args.when;
|
|
908
|
+
const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
|
|
909
|
+
return mutationResult(await c.write.updateTodo(args.uuid, {
|
|
910
|
+
...(args.title !== undefined && { title: args.title }),
|
|
911
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
912
|
+
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
913
|
+
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
914
|
+
...(when !== undefined && { when: when }),
|
|
915
|
+
...(reminder !== undefined && { reminder }),
|
|
916
|
+
...(args.clear_reminder === true && { reminder: null }),
|
|
917
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
918
|
+
...(args.clear_deadline === true && { deadline: null }),
|
|
919
|
+
}, opts));
|
|
920
|
+
}
|
|
921
|
+
return mutationResult(await c.write.updateProject(args.uuid, {
|
|
922
|
+
...(args.title !== undefined && { title: args.title }),
|
|
923
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
924
|
+
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
925
|
+
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
926
|
+
...(args.when !== undefined && { when: args.when }),
|
|
927
|
+
...(args.reminder !== undefined && { reminder: args.reminder }),
|
|
928
|
+
...(args.clear_reminder === true && { reminder: null }),
|
|
929
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
930
|
+
...(args.clear_deadline === true && { deadline: null }),
|
|
931
|
+
}, opts));
|
|
541
932
|
}
|
|
542
|
-
if (args.
|
|
543
|
-
|
|
933
|
+
if (args.kind === "area") {
|
|
934
|
+
if (args.title === undefined && args.tags === undefined) {
|
|
935
|
+
return usage("kind area requires title and/or tags");
|
|
936
|
+
}
|
|
937
|
+
return mutationResult(await c.write.updateArea(args.uuid, {
|
|
938
|
+
...(args.title !== undefined && { title: args.title }),
|
|
939
|
+
...(args.tags !== undefined && { tags: args.tags }),
|
|
940
|
+
}, opts));
|
|
941
|
+
}
|
|
942
|
+
// kind tag
|
|
943
|
+
if (args.title === undefined &&
|
|
944
|
+
args.parent === undefined &&
|
|
945
|
+
args.unnest === undefined &&
|
|
946
|
+
args.shortcut === undefined &&
|
|
947
|
+
args.clear_shortcut === undefined) {
|
|
948
|
+
return usage("kind tag requires title, parent, unnest, shortcut, and/or clear_shortcut");
|
|
544
949
|
}
|
|
545
|
-
if (args.
|
|
546
|
-
return usage("
|
|
950
|
+
if (args.parent !== undefined && args.unnest === true) {
|
|
951
|
+
return usage("parent and unnest are exclusive");
|
|
547
952
|
}
|
|
548
|
-
|
|
953
|
+
if (args.shortcut !== undefined && args.clear_shortcut === true) {
|
|
954
|
+
return usage("shortcut and clear_shortcut are exclusive");
|
|
955
|
+
}
|
|
956
|
+
return mutationResult(await c.write.updateTag(args.uuid, {
|
|
549
957
|
...(args.title !== undefined && { title: args.title }),
|
|
550
|
-
...(args.
|
|
551
|
-
...(args.
|
|
552
|
-
...(args.
|
|
553
|
-
...(args.
|
|
554
|
-
|
|
555
|
-
...(args.clear_reminder === true && { reminder: null }),
|
|
556
|
-
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
557
|
-
...(args.clear_deadline === true && { deadline: null }),
|
|
558
|
-
}, writeOptions(args)));
|
|
958
|
+
...(args.parent !== undefined && { parent: args.parent }),
|
|
959
|
+
...(args.unnest === true && { unnest: true }),
|
|
960
|
+
...(args.shortcut !== undefined && { shortcut: args.shortcut }),
|
|
961
|
+
...(args.clear_shortcut === true && { clearShortcut: true }),
|
|
962
|
+
}, opts));
|
|
559
963
|
}));
|
|
560
|
-
server.registerTool("
|
|
561
|
-
description: "Set a to-do's status: completed, canceled, or open
|
|
562
|
-
"completed/canceled
|
|
964
|
+
server.registerTool("set_status", {
|
|
965
|
+
description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
|
|
966
|
+
"(reopening a completed/canceled item). Not available for repeating to-dos. " +
|
|
967
|
+
"scope project, completing or canceling requires a children policy: 'require-resolved' " +
|
|
968
|
+
"errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
|
|
969
|
+
"the project (canceling never alters already-completed children). scope project, status " +
|
|
970
|
+
"open, restore_children also reopens the to-dos that were resolved with the project.",
|
|
563
971
|
inputSchema: {
|
|
564
|
-
|
|
972
|
+
scope: z.enum(["todo", "project"]),
|
|
973
|
+
uuid: z
|
|
974
|
+
.string()
|
|
975
|
+
.describe("The item — a to-do by uuid; a project also accepts a unique name"),
|
|
565
976
|
status: z.enum(["completed", "canceled", "open"]),
|
|
977
|
+
children: z
|
|
978
|
+
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
979
|
+
.optional()
|
|
980
|
+
.describe("scope project, completed/canceled: what to do with the project's open to-dos"),
|
|
981
|
+
restore_children: z
|
|
982
|
+
.boolean()
|
|
983
|
+
.optional()
|
|
984
|
+
.describe("scope project, open only: also reopen the to-dos resolved with the project"),
|
|
566
985
|
...dryRunShape,
|
|
567
986
|
},
|
|
568
987
|
annotations: NON_DESTRUCTIVE,
|
|
569
988
|
}, async (args) => guard(async () => {
|
|
570
989
|
const c = getClient();
|
|
571
990
|
const opts = writeOptions(args);
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
991
|
+
if (args.scope === "todo") {
|
|
992
|
+
if (args.children !== undefined || args.restore_children !== undefined) {
|
|
993
|
+
return usage("children/restore_children apply only to scope project");
|
|
994
|
+
}
|
|
995
|
+
return mutationResult(args.status === "completed"
|
|
996
|
+
? await c.write.completeTodo(args.uuid, opts)
|
|
997
|
+
: args.status === "canceled"
|
|
998
|
+
? await c.write.cancelTodo(args.uuid, opts)
|
|
999
|
+
: await c.write.reopenTodo(args.uuid, opts));
|
|
1000
|
+
}
|
|
1001
|
+
// scope project
|
|
1002
|
+
if (args.status !== "open" && args.restore_children !== undefined) {
|
|
1003
|
+
return usage("restore_children applies only to status 'open'");
|
|
1004
|
+
}
|
|
1005
|
+
if (args.status === "completed") {
|
|
1006
|
+
if (args.children !== "require-resolved" && args.children !== "auto-complete") {
|
|
1007
|
+
return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
|
|
1008
|
+
}
|
|
1009
|
+
return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
|
|
1010
|
+
}
|
|
1011
|
+
if (args.status === "canceled") {
|
|
1012
|
+
if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
|
|
1013
|
+
return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
|
|
1014
|
+
}
|
|
1015
|
+
return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
|
|
1016
|
+
}
|
|
1017
|
+
if (args.children !== undefined) {
|
|
1018
|
+
return usage("children applies only to status 'completed' or 'canceled'");
|
|
1019
|
+
}
|
|
1020
|
+
const outcome = await c.write.reopenProject(args.uuid, {
|
|
1021
|
+
...opts,
|
|
1022
|
+
...(args.restore_children === true && { restoreChildren: true }),
|
|
1023
|
+
});
|
|
1024
|
+
return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
|
|
1025
|
+
? jsonResult(outcome)
|
|
1026
|
+
: mutationResult(outcome.project);
|
|
577
1027
|
}));
|
|
578
1028
|
server.registerTool("move_todo", {
|
|
579
|
-
description: "Move a to-do. Pass exactly one destination: a project
|
|
1029
|
+
description: "Move a to-do. Pass exactly one destination: a project or area (optionally an " +
|
|
580
1030
|
"existing heading within the project), to_inbox, or detach. Moving to the Inbox " +
|
|
581
1031
|
"removes any schedule; detach removes the project/area/heading assignment while " +
|
|
582
1032
|
"keeping the schedule. Moving into a completed or canceled project reopens that " +
|
|
@@ -618,11 +1068,12 @@ export function createThingsMcpServer(options = {}) {
|
|
|
618
1068
|
server.registerTool("set_tags", {
|
|
619
1069
|
description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
|
|
620
1070
|
"the given list — an empty list removes all tags; mode 'add' merges with the current " +
|
|
621
|
-
"tags. Tags must
|
|
1071
|
+
"tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
|
|
622
1072
|
inputSchema: {
|
|
623
1073
|
uuid: z.string(),
|
|
624
|
-
tags: z.array(z.string()).describe(
|
|
1074
|
+
tags: z.array(z.string()).describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
625
1075
|
mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
|
|
1076
|
+
...createTagsShape,
|
|
626
1077
|
...dryRunShape,
|
|
627
1078
|
},
|
|
628
1079
|
annotations: NON_DESTRUCTIVE,
|
|
@@ -728,17 +1179,44 @@ export function createThingsMcpServer(options = {}) {
|
|
|
728
1179
|
return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
|
|
729
1180
|
}));
|
|
730
1181
|
// ------------------------------------------------- to-dos AND projects
|
|
731
|
-
server.registerTool("
|
|
732
|
-
description: "
|
|
733
|
-
"
|
|
734
|
-
"
|
|
735
|
-
|
|
1182
|
+
server.registerTool("delete", {
|
|
1183
|
+
description: "Delete a to-do/project (kind item), an area, or a tag — kind selects which. " +
|
|
1184
|
+
"kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
|
|
1185
|
+
"the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
|
|
1186
|
+
"repeating to-dos). kind area: PERMANENT — areas do not go to the Trash, so this cannot " +
|
|
1187
|
+
"be undone and requires dangerously_permanent; the area's to-dos move to the Trash and " +
|
|
1188
|
+
"its projects remain, no longer assigned to any area. kind tag: PERMANENT — requires " +
|
|
1189
|
+
"dangerously_permanent; the tag is removed from every item, and if it has nested child " +
|
|
1190
|
+
"tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
|
|
1191
|
+
inputSchema: {
|
|
1192
|
+
kind: z.enum(["item", "area", "tag"]),
|
|
1193
|
+
uuid: z
|
|
1194
|
+
.string()
|
|
1195
|
+
.describe("The target — an item by uuid; an area or tag also accepts a unique name"),
|
|
1196
|
+
dangerously_permanent: z
|
|
1197
|
+
.boolean()
|
|
1198
|
+
.optional()
|
|
1199
|
+
.describe("kind area/tag: confirm permanent, unrecoverable deletion"),
|
|
1200
|
+
acknowledge_tag_subtree: z
|
|
1201
|
+
.boolean()
|
|
1202
|
+
.optional()
|
|
1203
|
+
.describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
|
|
1204
|
+
...dryRunShape,
|
|
1205
|
+
},
|
|
736
1206
|
annotations: DESTRUCTIVE,
|
|
737
1207
|
}, async (args) => guard(async () => {
|
|
738
1208
|
const c = getClient();
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
:
|
|
1209
|
+
const opts = writeOptions(args);
|
|
1210
|
+
switch (args.kind) {
|
|
1211
|
+
case "item":
|
|
1212
|
+
return mutationResult(itemType(args.uuid) === "to-do"
|
|
1213
|
+
? await c.write.deleteTodo(args.uuid, opts)
|
|
1214
|
+
: await c.write.deleteProject(args.uuid, opts));
|
|
1215
|
+
case "area":
|
|
1216
|
+
return mutationResult(await c.write.deleteArea(args.uuid, opts));
|
|
1217
|
+
case "tag":
|
|
1218
|
+
return mutationResult(await c.write.deleteTag(args.uuid, opts));
|
|
1219
|
+
}
|
|
742
1220
|
}));
|
|
743
1221
|
server.registerTool("restore_item", {
|
|
744
1222
|
description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
|
|
@@ -785,17 +1263,80 @@ export function createThingsMcpServer(options = {}) {
|
|
|
785
1263
|
...(args.creation_date !== undefined && { creationDate: args.creation_date }),
|
|
786
1264
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
787
1265
|
}, writeOptions(args)))));
|
|
788
|
-
server.registerTool("
|
|
789
|
-
description: "
|
|
790
|
-
"
|
|
791
|
-
"with `things setup shortcuts
|
|
1266
|
+
server.registerTool("heading", {
|
|
1267
|
+
description: "Manage a project heading — action selects which. create: a new heading in an existing " +
|
|
1268
|
+
"project (project + title; returns its uuid; uses the Things proxy shortcuts, set up " +
|
|
1269
|
+
"once with `things setup shortcuts`). rename: rename in place (uuid + title; works on " +
|
|
1270
|
+
"archived headings). archive: retire a heading so it leaves the active project view " +
|
|
1271
|
+
"(reversible with action unarchive); with open children pass children — complete or " +
|
|
1272
|
+
"cancel resolve them with the heading in one cascade, reparent moves them to the project " +
|
|
1273
|
+
"root keeping them open. unarchive: bring an archived heading back; restore_children also " +
|
|
1274
|
+
"reopens the children the archive resolved with it. convert_to_project: promote a to-do " +
|
|
1275
|
+
"or heading into a new project — this REPLACES the original and cannot be undone (a " +
|
|
1276
|
+
"converted heading's to-dos move under the new project), and requires " +
|
|
1277
|
+
"dangerously_drive_gui.",
|
|
792
1278
|
inputSchema: {
|
|
793
|
-
|
|
794
|
-
|
|
1279
|
+
action: z.enum(["create", "rename", "archive", "unarchive", "convert_to_project"]),
|
|
1280
|
+
project: z.string().optional().describe(`create: existing project (${REF_FORMAT})`),
|
|
1281
|
+
uuid: z
|
|
1282
|
+
.string()
|
|
1283
|
+
.optional()
|
|
1284
|
+
.describe("rename/archive/unarchive/convert_to_project: the target's uuid"),
|
|
1285
|
+
title: z.string().optional().describe("create: the new heading; rename: the new title"),
|
|
1286
|
+
children: z
|
|
1287
|
+
.enum(["complete", "cancel", "reparent"])
|
|
1288
|
+
.optional()
|
|
1289
|
+
.describe("archive: required when the heading has open children"),
|
|
1290
|
+
restore_children: z
|
|
1291
|
+
.boolean()
|
|
1292
|
+
.optional()
|
|
1293
|
+
.describe("unarchive: also reopen the children archived with the heading"),
|
|
1294
|
+
...driveGuiShape,
|
|
795
1295
|
...dryRunShape,
|
|
796
1296
|
},
|
|
797
|
-
annotations:
|
|
798
|
-
}, async (args) => guard(async () =>
|
|
1297
|
+
annotations: DESTRUCTIVE,
|
|
1298
|
+
}, async (args) => guard(async () => {
|
|
1299
|
+
const c = getClient();
|
|
1300
|
+
const opts = writeOptions(args);
|
|
1301
|
+
switch (args.action) {
|
|
1302
|
+
case "create":
|
|
1303
|
+
if (args.project === undefined || args.title === undefined) {
|
|
1304
|
+
return usage('action "create" requires project and title');
|
|
1305
|
+
}
|
|
1306
|
+
return mutationResult(await c.write.createHeading(containerRef(args.project), args.title, opts));
|
|
1307
|
+
case "rename":
|
|
1308
|
+
if (args.uuid === undefined || args.title === undefined) {
|
|
1309
|
+
return usage('action "rename" requires uuid and title');
|
|
1310
|
+
}
|
|
1311
|
+
return mutationResult(await c.write.renameHeading(args.uuid, args.title, opts));
|
|
1312
|
+
case "archive": {
|
|
1313
|
+
if (args.uuid === undefined)
|
|
1314
|
+
return usage('action "archive" requires uuid');
|
|
1315
|
+
const r = await c.write.archiveHeading(args.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
|
|
1316
|
+
return r.heading.kind === "ok" || r.heading.kind === "dry-run"
|
|
1317
|
+
? jsonResult(r)
|
|
1318
|
+
: mutationResult(r.heading);
|
|
1319
|
+
}
|
|
1320
|
+
case "unarchive": {
|
|
1321
|
+
if (args.uuid === undefined)
|
|
1322
|
+
return usage('action "unarchive" requires uuid');
|
|
1323
|
+
const r = await c.write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
|
|
1324
|
+
return r.heading.kind === "ok" || r.heading.kind === "dry-run"
|
|
1325
|
+
? jsonResult(r)
|
|
1326
|
+
: mutationResult(r.heading);
|
|
1327
|
+
}
|
|
1328
|
+
case "convert_to_project": {
|
|
1329
|
+
if (args.uuid === undefined) {
|
|
1330
|
+
return usage('action "convert_to_project" requires uuid');
|
|
1331
|
+
}
|
|
1332
|
+
const item = c.read.byUuid(args.uuid);
|
|
1333
|
+
if (item === null)
|
|
1334
|
+
throw new RangeError(`no item with uuid ${args.uuid}`);
|
|
1335
|
+
const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
|
|
1336
|
+
return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}));
|
|
799
1340
|
server.registerTool("clear_reminder", {
|
|
800
1341
|
description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
|
|
801
1342
|
"Things proxy shortcuts when installed (in place, and the only path for a repeating " +
|
|
@@ -804,48 +1345,190 @@ export function createThingsMcpServer(options = {}) {
|
|
|
804
1345
|
inputSchema: { uuid: z.string(), ...dryRunShape },
|
|
805
1346
|
annotations: NON_DESTRUCTIVE,
|
|
806
1347
|
}, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1348
|
+
// -------------------------------------------- GUI-driven (Accessibility)
|
|
1349
|
+
const WEEKDAY_ENUM = z.enum([
|
|
1350
|
+
"sunday",
|
|
1351
|
+
"monday",
|
|
1352
|
+
"tuesday",
|
|
1353
|
+
"wednesday",
|
|
1354
|
+
"thursday",
|
|
1355
|
+
"friday",
|
|
1356
|
+
"saturday",
|
|
1357
|
+
]);
|
|
1358
|
+
// The base rule (also used by create_repeating_project, which stays minimal —
|
|
1359
|
+
// its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
|
|
1360
|
+
const baseRepeatShape = {
|
|
1361
|
+
frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
|
|
1362
|
+
interval: z.number().int().min(1).max(99).describe("Every N units (1–99)"),
|
|
1363
|
+
};
|
|
1364
|
+
const repeatRuleShape = {
|
|
1365
|
+
...baseRepeatShape,
|
|
1366
|
+
after_completion: z
|
|
1367
|
+
.boolean()
|
|
1368
|
+
.optional()
|
|
1369
|
+
.describe("Repeat N units AFTER each occurrence is completed, instead of on a fixed schedule"),
|
|
1370
|
+
weekdays: z.array(WEEKDAY_ENUM).optional().describe("Weekly only: the weekdays it repeats on"),
|
|
1371
|
+
monthly_day: z
|
|
1372
|
+
.union([z.number().int(), z.literal("last")])
|
|
1373
|
+
.optional()
|
|
1374
|
+
.describe('Monthly/yearly only: a day of the month (1–31, or "last")'),
|
|
1375
|
+
monthly_weekday: WEEKDAY_ENUM.optional().describe("Monthly/yearly only: a weekday for an nth-weekday rule (with monthly_ordinal)"),
|
|
1376
|
+
monthly_ordinal: z
|
|
1377
|
+
.union([z.number().int(), z.literal("last")])
|
|
1378
|
+
.optional()
|
|
1379
|
+
.describe('Monthly/yearly only: which weekday (1–5, or "last") with monthly_weekday'),
|
|
1380
|
+
yearly_month: z
|
|
1381
|
+
.number()
|
|
1382
|
+
.int()
|
|
1383
|
+
.min(1)
|
|
1384
|
+
.max(12)
|
|
1385
|
+
.optional()
|
|
1386
|
+
.describe("Yearly only: the month (1–12)"),
|
|
1387
|
+
ends_after: z.number().int().optional().describe("Stop after N occurrences"),
|
|
1388
|
+
ends_on: z.string().optional().describe("YYYY-MM-DD — stop after this date"),
|
|
1389
|
+
reminder: z.string().optional().describe("HH:mm — a reminder time on each occurrence"),
|
|
1390
|
+
deadline: z.boolean().optional().describe("Give each occurrence a deadline"),
|
|
1391
|
+
start_days_earlier: z
|
|
1392
|
+
.number()
|
|
1393
|
+
.int()
|
|
1394
|
+
.optional()
|
|
1395
|
+
.describe("With deadline: start each occurrence N days before its deadline"),
|
|
1396
|
+
};
|
|
1397
|
+
// oxlint-disable-next-line consistent-function-scoping -- kept beside repeatRuleShape it mirrors
|
|
1398
|
+
const repeatExtras = (a, frequency) => {
|
|
1399
|
+
const fields = {};
|
|
1400
|
+
if (a.after_completion === true)
|
|
1401
|
+
fields.afterCompletion = true;
|
|
1402
|
+
if (a.weekdays !== undefined)
|
|
1403
|
+
fields.weekdays = a.weekdays;
|
|
1404
|
+
const anchor = a.monthly_day !== undefined
|
|
1405
|
+
? { day: a.monthly_day }
|
|
1406
|
+
: a.monthly_weekday !== undefined || a.monthly_ordinal !== undefined
|
|
1407
|
+
? { weekday: a.monthly_weekday, ordinal: a.monthly_ordinal }
|
|
1408
|
+
: undefined;
|
|
1409
|
+
if (frequency === "monthly" && anchor !== undefined)
|
|
1410
|
+
fields.monthly = anchor;
|
|
1411
|
+
if (frequency === "yearly" && (a.yearly_month !== undefined || anchor !== undefined)) {
|
|
1412
|
+
fields.yearly = { month: a.yearly_month, ...anchor };
|
|
1413
|
+
}
|
|
1414
|
+
if (a.ends_after !== undefined)
|
|
1415
|
+
fields.ends = { kind: "after", count: a.ends_after };
|
|
1416
|
+
else if (a.ends_on !== undefined)
|
|
1417
|
+
fields.ends = { kind: "on-date", date: a.ends_on };
|
|
1418
|
+
if (a.reminder !== undefined)
|
|
1419
|
+
fields.reminder = a.reminder;
|
|
1420
|
+
if (a.deadline === true)
|
|
1421
|
+
fields.deadline = true;
|
|
1422
|
+
if (a.start_days_earlier !== undefined)
|
|
1423
|
+
fields.startDaysEarlier = a.start_days_earlier;
|
|
1424
|
+
return fields;
|
|
1425
|
+
};
|
|
1426
|
+
server.registerTool("repeat", {
|
|
1427
|
+
description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
|
|
1428
|
+
"interface — every action needs dangerously_drive_gui. action start: turn a plain item " +
|
|
1429
|
+
"into a repeating one — this REPLACES it with a new series and cannot be undone (give " +
|
|
1430
|
+
"frequency + interval and optionally the weekday set, monthly/yearly day, end bound, " +
|
|
1431
|
+
"reminder, or per-occurrence deadline); returns a repeating block with instanceUuid (the " +
|
|
1432
|
+
"visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
|
|
1433
|
+
"change a repeating item's rule in place, keeping the same item (undoable — it restores " +
|
|
1434
|
+
"the previous rule). action pause/resume: stop or restart its new occurrences, keeping " +
|
|
1435
|
+
"the rule. action create (scope project only): create a project and make it repeating in " +
|
|
1436
|
+
"one call — the project is created first and PERSISTS even if the make-repeating step " +
|
|
1437
|
+
"refuses; give an area to place it or omit it to create in Someday (only frequency and " +
|
|
1438
|
+
"interval are supported); returns the new project's uuid.",
|
|
819
1439
|
inputSchema: {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1440
|
+
scope: z.enum(["todo", "project"]),
|
|
1441
|
+
action: z.enum(["start", "reschedule", "pause", "resume", "create"]),
|
|
1442
|
+
uuid: z
|
|
1443
|
+
.string()
|
|
823
1444
|
.optional()
|
|
824
|
-
.describe("
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
:
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1445
|
+
.describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
|
|
1446
|
+
title: z.string().optional().describe("create (project): the new project's title"),
|
|
1447
|
+
notes: z.string().optional().describe("create (project): notes"),
|
|
1448
|
+
area: z.string().optional().describe(`create (project): destination area (${REF_FORMAT})`),
|
|
1449
|
+
project_deadline: z
|
|
1450
|
+
.string()
|
|
1451
|
+
.optional()
|
|
1452
|
+
.describe(`create (project): the project's due date — ${DATE_FORMAT}`),
|
|
1453
|
+
todos: z
|
|
1454
|
+
.array(z.string())
|
|
1455
|
+
.optional()
|
|
1456
|
+
.describe("create (project): initial child to-do titles"),
|
|
1457
|
+
frequency: z
|
|
1458
|
+
.enum(["daily", "weekly", "monthly", "yearly"])
|
|
1459
|
+
.optional()
|
|
1460
|
+
.describe("start/reschedule/create: how often it repeats"),
|
|
1461
|
+
interval: z
|
|
1462
|
+
.number()
|
|
1463
|
+
.int()
|
|
1464
|
+
.min(1)
|
|
1465
|
+
.max(99)
|
|
1466
|
+
.optional()
|
|
1467
|
+
.describe("start/reschedule/create: every N units (1–99)"),
|
|
1468
|
+
after_completion: repeatRuleShape.after_completion,
|
|
1469
|
+
weekdays: repeatRuleShape.weekdays,
|
|
1470
|
+
monthly_day: repeatRuleShape.monthly_day,
|
|
1471
|
+
monthly_weekday: repeatRuleShape.monthly_weekday,
|
|
1472
|
+
monthly_ordinal: repeatRuleShape.monthly_ordinal,
|
|
1473
|
+
yearly_month: repeatRuleShape.yearly_month,
|
|
1474
|
+
ends_after: repeatRuleShape.ends_after,
|
|
1475
|
+
ends_on: repeatRuleShape.ends_on,
|
|
1476
|
+
reminder: repeatRuleShape.reminder,
|
|
1477
|
+
deadline: repeatRuleShape.deadline,
|
|
1478
|
+
start_days_earlier: repeatRuleShape.start_days_earlier,
|
|
1479
|
+
...driveGuiShape,
|
|
841
1480
|
...dryRunShape,
|
|
842
1481
|
},
|
|
843
|
-
annotations:
|
|
1482
|
+
annotations: DESTRUCTIVE,
|
|
844
1483
|
}, async (args) => guard(async () => {
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1484
|
+
const c = getClient();
|
|
1485
|
+
const opts = writeOptions(args);
|
|
1486
|
+
const { frequency, interval } = args;
|
|
1487
|
+
if (args.action === "create") {
|
|
1488
|
+
if (args.scope !== "project")
|
|
1489
|
+
return usage('action "create" requires scope "project"');
|
|
1490
|
+
if (args.title === undefined)
|
|
1491
|
+
return usage('action "create" requires title');
|
|
1492
|
+
if (frequency === undefined || interval === undefined) {
|
|
1493
|
+
return usage('action "create" requires frequency and interval');
|
|
1494
|
+
}
|
|
1495
|
+
return mutationResult(await c.write.createRepeatingProject({
|
|
1496
|
+
title: args.title,
|
|
1497
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
1498
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
1499
|
+
...(args.project_deadline !== undefined && { deadline: args.project_deadline }),
|
|
1500
|
+
...(args.todos !== undefined && { todos: args.todos }),
|
|
1501
|
+
frequency,
|
|
1502
|
+
interval,
|
|
1503
|
+
}, opts));
|
|
1504
|
+
}
|
|
1505
|
+
if (args.uuid === undefined)
|
|
1506
|
+
return usage(`action "${args.action}" requires uuid`);
|
|
1507
|
+
if (args.action === "pause" || args.action === "resume") {
|
|
1508
|
+
const op = args.scope === "todo"
|
|
1509
|
+
? args.action === "pause"
|
|
1510
|
+
? "todo.pause-repeat"
|
|
1511
|
+
: "todo.resume-repeat"
|
|
1512
|
+
: args.action === "pause"
|
|
1513
|
+
? "project.pause-repeat"
|
|
1514
|
+
: "project.resume-repeat";
|
|
1515
|
+
return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
|
|
1516
|
+
}
|
|
1517
|
+
// start | reschedule
|
|
1518
|
+
if (frequency === undefined || interval === undefined) {
|
|
1519
|
+
return usage(`action "${args.action}" requires frequency and interval`);
|
|
1520
|
+
}
|
|
1521
|
+
const extras = repeatExtras(args, frequency);
|
|
1522
|
+
if (args.scope === "todo") {
|
|
1523
|
+
const op = args.action === "start" ? "todo.make-repeating" : "todo.reschedule-repeat";
|
|
1524
|
+
return mutationResult(await c.write.run(op, { uuid: args.uuid, frequency, interval, ...extras }, opts));
|
|
1525
|
+
}
|
|
1526
|
+
// scope project
|
|
1527
|
+
if (args.action === "reschedule") {
|
|
1528
|
+
return mutationResult(await c.write.run("project.reschedule-repeat", { uuid: args.uuid, frequency, interval, ...extras }, opts));
|
|
1529
|
+
}
|
|
1530
|
+
// scope project, action start → the dedicated make-repeating method
|
|
1531
|
+
return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
|
|
849
1532
|
}));
|
|
850
1533
|
server.registerTool("duplicate_item", {
|
|
851
1534
|
description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
|
|
@@ -869,114 +1552,28 @@ export function createThingsMcpServer(options = {}) {
|
|
|
869
1552
|
when: whenSchema,
|
|
870
1553
|
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
871
1554
|
todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
|
|
872
|
-
...
|
|
873
|
-
},
|
|
874
|
-
annotations: NON_DESTRUCTIVE,
|
|
875
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
|
|
876
|
-
title: args.title,
|
|
877
|
-
...(args.notes !== undefined && { notes: args.notes }),
|
|
878
|
-
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
879
|
-
...(args.when !== undefined && { when: args.when }),
|
|
880
|
-
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
881
|
-
...(args.todos !== undefined && { todos: args.todos }),
|
|
882
|
-
}, writeOptions(args)))));
|
|
883
|
-
server.registerTool("update_project", {
|
|
884
|
-
description: "Update a project's title, notes, schedule, reminder, or deadline. " +
|
|
885
|
-
"append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
|
|
886
|
-
"Changing the schedule keeps an existing reminder unless the call sets a new one. " +
|
|
887
|
-
"clear_reminder works while the project is scheduled for today or this evening; a " +
|
|
888
|
-
"reminder on a future date can only be changed, not cleared.",
|
|
889
|
-
inputSchema: {
|
|
890
|
-
uuid: z.string(),
|
|
891
|
-
title: z.string().optional(),
|
|
892
|
-
notes: z.string().optional().describe("Replaces the whole notes body"),
|
|
893
|
-
append_notes: z.string().optional(),
|
|
894
|
-
prepend_notes: z.string().optional(),
|
|
895
|
-
when: whenSchema,
|
|
896
|
-
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
897
|
-
clear_reminder: z.boolean().optional(),
|
|
898
|
-
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
899
|
-
clear_deadline: z.boolean().optional(),
|
|
1555
|
+
...tzShape,
|
|
900
1556
|
...dryRunShape,
|
|
901
1557
|
},
|
|
902
1558
|
annotations: NON_DESTRUCTIVE,
|
|
903
1559
|
}, async (args) => guard(async () => {
|
|
904
|
-
const
|
|
905
|
-
if (
|
|
906
|
-
return
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
return usage("pass at most one of reminder / clear_reminder");
|
|
910
|
-
}
|
|
911
|
-
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
912
|
-
return usage("pass at most one of deadline / clear_deadline");
|
|
913
|
-
}
|
|
914
|
-
return mutationResult(await getClient().write.updateProject(args.uuid, {
|
|
915
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1560
|
+
const badZone = badTz(args.tz);
|
|
1561
|
+
if (badZone !== null)
|
|
1562
|
+
return badZone;
|
|
1563
|
+
return mutationResult(await getClient().write.addProject({
|
|
1564
|
+
title: args.title,
|
|
916
1565
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
917
|
-
...(args.
|
|
918
|
-
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
1566
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
919
1567
|
...(args.when !== undefined && { when: args.when }),
|
|
920
|
-
...(args.reminder !== undefined && { reminder: args.reminder }),
|
|
921
|
-
...(args.clear_reminder === true && { reminder: null }),
|
|
922
1568
|
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
923
|
-
...(args.
|
|
1569
|
+
...(args.todos !== undefined && { todos: args.todos }),
|
|
924
1570
|
}, writeOptions(args)));
|
|
925
1571
|
}));
|
|
926
|
-
server.registerTool("set_project_status", {
|
|
927
|
-
description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
|
|
928
|
-
"children policy: 'require-resolved' errors if open to-dos remain; " +
|
|
929
|
-
"'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
|
|
930
|
-
"never alters already-completed children). status 'open' reopens a completed or " +
|
|
931
|
-
"canceled project; its children stay completed/canceled unless restore_children " +
|
|
932
|
-
"also reopens the ones that were resolved together with the project.",
|
|
933
|
-
inputSchema: {
|
|
934
|
-
uuid: z.string().describe("Project uuid"),
|
|
935
|
-
status: z.enum(["completed", "canceled", "open"]),
|
|
936
|
-
children: z
|
|
937
|
-
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
938
|
-
.optional()
|
|
939
|
-
.describe("Required for completed/canceled: what to do with the project's open to-dos"),
|
|
940
|
-
restore_children: z
|
|
941
|
-
.boolean()
|
|
942
|
-
.optional()
|
|
943
|
-
.describe("open only: also reopen the to-dos that were resolved with the project"),
|
|
944
|
-
...dryRunShape,
|
|
945
|
-
},
|
|
946
|
-
annotations: NON_DESTRUCTIVE,
|
|
947
|
-
}, async (args) => guard(async () => {
|
|
948
|
-
const c = getClient();
|
|
949
|
-
if (args.status !== "open" && args.restore_children !== undefined) {
|
|
950
|
-
return usage("restore_children applies only to status 'open'");
|
|
951
|
-
}
|
|
952
|
-
if (args.status === "completed") {
|
|
953
|
-
if (args.children !== "require-resolved" && args.children !== "auto-complete") {
|
|
954
|
-
return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
|
|
955
|
-
}
|
|
956
|
-
return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
|
|
957
|
-
}
|
|
958
|
-
if (args.status === "canceled") {
|
|
959
|
-
if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
|
|
960
|
-
return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
|
|
961
|
-
}
|
|
962
|
-
return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
|
|
963
|
-
}
|
|
964
|
-
if (args.children !== undefined) {
|
|
965
|
-
return usage("children applies only to status 'completed' or 'canceled'");
|
|
966
|
-
}
|
|
967
|
-
const outcome = await c.write.reopenProject(args.uuid, {
|
|
968
|
-
...writeOptions(args),
|
|
969
|
-
...(args.restore_children === true && { restoreChildren: true }),
|
|
970
|
-
});
|
|
971
|
-
return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
|
|
972
|
-
? jsonResult(outcome)
|
|
973
|
-
: mutationResult(outcome.project);
|
|
974
|
-
}));
|
|
975
1572
|
server.registerTool("move_project", {
|
|
976
1573
|
description: "Move a project into an area, or detach it from its current area. Pass exactly " +
|
|
977
1574
|
"one of area / detach. The project's status and schedule are unaffected.",
|
|
978
1575
|
inputSchema: {
|
|
979
|
-
uuid: z.string().describe(
|
|
1576
|
+
uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
|
|
980
1577
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
981
1578
|
detach: z.boolean().optional().describe("Remove the current area assignment"),
|
|
982
1579
|
...dryRunShape,
|
|
@@ -993,46 +1590,15 @@ export function createThingsMcpServer(options = {}) {
|
|
|
993
1590
|
}));
|
|
994
1591
|
// ----------------------------------------------------------------- areas
|
|
995
1592
|
server.registerTool("add_area", {
|
|
996
|
-
description: "Create an area. Tags
|
|
1593
|
+
description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
|
|
997
1594
|
inputSchema: {
|
|
998
1595
|
title: z.string(),
|
|
999
|
-
tags: z.array(z.string()).optional().describe(
|
|
1596
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
1597
|
+
...createTagsShape,
|
|
1000
1598
|
...dryRunShape,
|
|
1001
1599
|
},
|
|
1002
1600
|
annotations: NON_DESTRUCTIVE,
|
|
1003
1601
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
|
|
1004
|
-
server.registerTool("update_area", {
|
|
1005
|
-
description: "Rename an area and/or replace its tags (the full set; tags must name existing tags).",
|
|
1006
|
-
inputSchema: {
|
|
1007
|
-
target: z.string().describe(`Area to update (${REF_FORMAT})`),
|
|
1008
|
-
title: z.string().optional().describe("New name"),
|
|
1009
|
-
tags: z.array(z.string()).optional().describe("Existing tag names (full replacement)"),
|
|
1010
|
-
...dryRunShape,
|
|
1011
|
-
},
|
|
1012
|
-
annotations: NON_DESTRUCTIVE,
|
|
1013
|
-
}, async (args) => guard(async () => {
|
|
1014
|
-
if (args.title === undefined && args.tags === undefined) {
|
|
1015
|
-
return usage("pass title and/or tags");
|
|
1016
|
-
}
|
|
1017
|
-
return mutationResult(await getClient().write.updateArea(args.target, {
|
|
1018
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1019
|
-
...(args.tags !== undefined && { tags: args.tags }),
|
|
1020
|
-
}, writeOptions(args)));
|
|
1021
|
-
}));
|
|
1022
|
-
server.registerTool("delete_area", {
|
|
1023
|
-
description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
|
|
1024
|
-
"undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
|
|
1025
|
-
"projects remain, no longer assigned to any area.",
|
|
1026
|
-
inputSchema: {
|
|
1027
|
-
target: z.string().describe(`Area to delete (${REF_FORMAT})`),
|
|
1028
|
-
dangerously_permanent: z
|
|
1029
|
-
.boolean()
|
|
1030
|
-
.optional()
|
|
1031
|
-
.describe("Confirm permanent, unrecoverable deletion"),
|
|
1032
|
-
...dryRunShape,
|
|
1033
|
-
},
|
|
1034
|
-
annotations: DESTRUCTIVE,
|
|
1035
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
|
|
1036
1602
|
// ------------------------------------------------------------------ tags
|
|
1037
1603
|
server.registerTool("add_tag", {
|
|
1038
1604
|
description: "Create a tag, optionally nested under an existing parent tag.",
|
|
@@ -1043,61 +1609,6 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1043
1609
|
},
|
|
1044
1610
|
annotations: NON_DESTRUCTIVE,
|
|
1045
1611
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
|
|
1046
|
-
server.registerTool("update_tag", {
|
|
1047
|
-
description: "Rename a tag (existing assignments follow the rename), nest it under another " +
|
|
1048
|
-
"existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
|
|
1049
|
-
"parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
|
|
1050
|
-
inputSchema: {
|
|
1051
|
-
target: z.string().describe(`Tag to update (${REF_FORMAT})`),
|
|
1052
|
-
title: z.string().optional().describe("New name"),
|
|
1053
|
-
parent: z.string().optional().describe("Existing tag to nest under"),
|
|
1054
|
-
unnest: z.boolean().optional().describe("Move the tag to the top level"),
|
|
1055
|
-
shortcut: z.string().optional().describe("Keyboard shortcut character"),
|
|
1056
|
-
clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
|
|
1057
|
-
...dryRunShape,
|
|
1058
|
-
},
|
|
1059
|
-
annotations: NON_DESTRUCTIVE,
|
|
1060
|
-
}, async (args) => guard(async () => {
|
|
1061
|
-
if (args.title === undefined &&
|
|
1062
|
-
args.parent === undefined &&
|
|
1063
|
-
args.unnest === undefined &&
|
|
1064
|
-
args.shortcut === undefined &&
|
|
1065
|
-
args.clear_shortcut === undefined) {
|
|
1066
|
-
return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
|
|
1067
|
-
}
|
|
1068
|
-
if (args.parent !== undefined && args.unnest === true) {
|
|
1069
|
-
return usage("parent and unnest are exclusive");
|
|
1070
|
-
}
|
|
1071
|
-
if (args.shortcut !== undefined && args.clear_shortcut === true) {
|
|
1072
|
-
return usage("shortcut and clear_shortcut are exclusive");
|
|
1073
|
-
}
|
|
1074
|
-
return mutationResult(await getClient().write.updateTag(args.target, {
|
|
1075
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1076
|
-
...(args.parent !== undefined && { parent: args.parent }),
|
|
1077
|
-
...(args.unnest === true && { unnest: true }),
|
|
1078
|
-
...(args.shortcut !== undefined && { shortcut: args.shortcut }),
|
|
1079
|
-
...(args.clear_shortcut === true && { clearShortcut: true }),
|
|
1080
|
-
}, writeOptions(args)));
|
|
1081
|
-
}));
|
|
1082
|
-
server.registerTool("delete_tag", {
|
|
1083
|
-
description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
|
|
1084
|
-
"requires dangerously_permanent. The tag is removed from every item. If the tag " +
|
|
1085
|
-
"has nested child tags they are ALL permanently deleted with it — pass " +
|
|
1086
|
-
"acknowledge_tag_subtree to confirm.",
|
|
1087
|
-
inputSchema: {
|
|
1088
|
-
target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
|
|
1089
|
-
dangerously_permanent: z
|
|
1090
|
-
.boolean()
|
|
1091
|
-
.optional()
|
|
1092
|
-
.describe("Confirm permanent, unrecoverable deletion"),
|
|
1093
|
-
acknowledge_tag_subtree: z
|
|
1094
|
-
.boolean()
|
|
1095
|
-
.optional()
|
|
1096
|
-
.describe("Confirm permanent deletion of ALL nested child tags too"),
|
|
1097
|
-
...dryRunShape,
|
|
1098
|
-
},
|
|
1099
|
-
annotations: DESTRUCTIVE,
|
|
1100
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
|
|
1101
1612
|
// -------------------------------------------------- generic + discovery
|
|
1102
1613
|
server.registerTool("run_operation", {
|
|
1103
1614
|
description: "Run any cataloged operation by kind — the generic entry for operations without a " +
|
|
@@ -1133,10 +1644,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1133
1644
|
params: z.record(z.string(), z.unknown()),
|
|
1134
1645
|
options: z
|
|
1135
1646
|
.object({
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1647
|
+
acknowledge_checklist_reset: z.boolean().optional(),
|
|
1648
|
+
acknowledge_project_reopen: z.boolean().optional(),
|
|
1649
|
+
dangerously_permanent: z.boolean().optional(),
|
|
1650
|
+
acknowledge_tag_subtree: z.boolean().optional(),
|
|
1140
1651
|
})
|
|
1141
1652
|
.optional(),
|
|
1142
1653
|
}))
|
|
@@ -1146,10 +1657,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1146
1657
|
},
|
|
1147
1658
|
annotations: DESTRUCTIVE,
|
|
1148
1659
|
}, async (args) => guard(async () => {
|
|
1149
|
-
|
|
1660
|
+
// Map each op's snake_case acknowledgements into the batch engine's
|
|
1661
|
+
// option names, and apply the process-wide disruption ceiling — batch
|
|
1662
|
+
// takes it per-op, and MCP exposes no per-op override, so it is uniform.
|
|
1663
|
+
const ceiling = options.maxDisruption;
|
|
1664
|
+
const ops = args.ops.map((op) => {
|
|
1665
|
+
const o = op.options;
|
|
1666
|
+
const opts = {
|
|
1667
|
+
...(o?.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
|
|
1668
|
+
...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
1669
|
+
...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1670
|
+
...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
1671
|
+
...(ceiling !== undefined && { maxDisruption: ceiling }),
|
|
1672
|
+
};
|
|
1673
|
+
return {
|
|
1674
|
+
op: op.op,
|
|
1675
|
+
params: op.params,
|
|
1676
|
+
...(Object.keys(opts).length > 0 && { options: opts }),
|
|
1677
|
+
};
|
|
1678
|
+
});
|
|
1679
|
+
const results = await getClient().write.batch(ops, {
|
|
1150
1680
|
...(args.dry_run === true && { dryRun: true }),
|
|
1151
1681
|
...(args.fail_fast === true && { failFast: true }),
|
|
1152
|
-
actor:
|
|
1682
|
+
actor: mcpActor(),
|
|
1153
1683
|
});
|
|
1154
1684
|
return jsonResult(results);
|
|
1155
1685
|
}));
|
|
@@ -1157,13 +1687,16 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1157
1687
|
description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
|
|
1158
1688
|
"area-less someday projects — one kind per call), a " +
|
|
1159
1689
|
"project's to-dos, a project's headings (scope=headings — children move with " +
|
|
1160
|
-
"their heading), an area, or the top-level
|
|
1690
|
+
"their heading), an area, or the top-level projects (scope=projects — " +
|
|
1161
1691
|
"each project takes a brief someday/anytime round-trip) — the given uuids move " +
|
|
1162
1692
|
"to the TOP in the given order; unlisted items keep their relative order below. " +
|
|
1163
1693
|
"Today/inbox/someday/project/headings/area ordering must first be enabled once " +
|
|
1164
1694
|
"via `things config set allow-experimental true`. This Evening and " +
|
|
1165
1695
|
`scope=projects handle at most ${BOUNCE_MAX_ITEMS} items per call. An area's ` +
|
|
1166
|
-
"to-dos and projects are ordered separately — one kind per call."
|
|
1696
|
+
"to-dos and projects are ordered separately — one kind per call. " +
|
|
1697
|
+
"scope=areas instead moves the sidebar areas themselves: give target plus exactly " +
|
|
1698
|
+
"one of before/after/position, and pass dangerously_drive_gui (it drives the local " +
|
|
1699
|
+
"Things app). Every other scope takes uuids.",
|
|
1167
1700
|
inputSchema: {
|
|
1168
1701
|
scope: z.enum([
|
|
1169
1702
|
"today",
|
|
@@ -1174,41 +1707,108 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1174
1707
|
"headings",
|
|
1175
1708
|
"area",
|
|
1176
1709
|
"projects",
|
|
1710
|
+
"areas",
|
|
1177
1711
|
]),
|
|
1178
1712
|
container: z
|
|
1179
1713
|
.string()
|
|
1180
1714
|
.optional()
|
|
1181
1715
|
.describe(`Project/area (${REF_FORMAT}) — required for those scopes`),
|
|
1182
|
-
uuids: z
|
|
1716
|
+
uuids: z
|
|
1717
|
+
.array(z.string())
|
|
1718
|
+
.optional()
|
|
1719
|
+
.describe("Desired order, top first (may be a subset) — required for every scope but areas"),
|
|
1183
1720
|
strategy: z.enum(["native", "bounce"]).optional(),
|
|
1721
|
+
target: z.string().optional().describe(`scope areas: the area to move (${REF_FORMAT})`),
|
|
1722
|
+
before: z
|
|
1723
|
+
.string()
|
|
1724
|
+
.optional()
|
|
1725
|
+
.describe(`scope areas: place it immediately above this area (${REF_FORMAT})`),
|
|
1726
|
+
after: z
|
|
1727
|
+
.string()
|
|
1728
|
+
.optional()
|
|
1729
|
+
.describe(`scope areas: place it immediately below this area (${REF_FORMAT})`),
|
|
1730
|
+
position: z
|
|
1731
|
+
.enum(["first", "last"])
|
|
1732
|
+
.optional()
|
|
1733
|
+
.describe("scope areas: move it to the top or bottom of the area list"),
|
|
1734
|
+
...driveGuiShape,
|
|
1184
1735
|
...dryRunShape,
|
|
1185
1736
|
},
|
|
1186
1737
|
annotations: NON_DESTRUCTIVE,
|
|
1187
|
-
}, async (args) => guard(async () =>
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1738
|
+
}, async (args) => guard(async () => {
|
|
1739
|
+
if (args.scope === "areas") {
|
|
1740
|
+
if (args.target === undefined)
|
|
1741
|
+
return usage('scope "areas" requires target');
|
|
1742
|
+
return mutationResult(await getClient().write.run("area.reorder", {
|
|
1743
|
+
target: args.target,
|
|
1744
|
+
...(args.before !== undefined && { before: args.before }),
|
|
1745
|
+
...(args.after !== undefined && { after: args.after }),
|
|
1746
|
+
...(args.position !== undefined && { position: args.position }),
|
|
1747
|
+
}, writeOptions(args)));
|
|
1748
|
+
}
|
|
1749
|
+
if (args.uuids === undefined)
|
|
1750
|
+
return usage(`scope "${args.scope}" requires uuids`);
|
|
1751
|
+
return mutationResult(await getClient().write.reorder({
|
|
1752
|
+
scope: args.scope,
|
|
1753
|
+
uuids: args.uuids,
|
|
1754
|
+
...(args.container !== undefined && { container: containerRef(args.container) }),
|
|
1755
|
+
...(args.strategy !== undefined && { strategy: args.strategy }),
|
|
1756
|
+
}, writeOptions(args)));
|
|
1757
|
+
}));
|
|
1193
1758
|
server.registerTool("undo", {
|
|
1194
|
-
description: "Undo the last N changes
|
|
1195
|
-
"
|
|
1196
|
-
"
|
|
1197
|
-
|
|
1198
|
-
"
|
|
1199
|
-
"
|
|
1759
|
+
description: "Undo the last N changes, newest first (changes made directly in the Things app " +
|
|
1760
|
+
"cannot be undone here). By default this undoes only changes made through THIS " +
|
|
1761
|
+
"connection — this client's own writes; it will not touch the user's own edits, or " +
|
|
1762
|
+
'another client\'s, unless you pass by="*" (all authors) or a specific author name; ' +
|
|
1763
|
+
"pass a txn token to undo one exact change. Some changes cannot be reversed — " +
|
|
1764
|
+
"permanent deletions, or changes whose prior state is unknown — and are reported as " +
|
|
1765
|
+
"irreversible; a to-do brought back from an undone delete returns to the Inbox " +
|
|
1766
|
+
"without its schedule. Undoing the creation of an area or tag deletes it " +
|
|
1767
|
+
"permanently — requires dangerously_permanent. An undo is refused when the item " +
|
|
1768
|
+
"changed outside this interface since (its list or project, status, schedule, " +
|
|
1769
|
+
"trashed state, or a field like the title moved) — pass " +
|
|
1770
|
+
"acknowledge_out_of_band_changes to overwrite it anyway.",
|
|
1200
1771
|
inputSchema: {
|
|
1201
1772
|
last: z.number().int().min(1).optional().describe("How many to unwind (default 1)"),
|
|
1773
|
+
by: z
|
|
1774
|
+
.string()
|
|
1775
|
+
.optional()
|
|
1776
|
+
.describe('Whose changes to undo: an exact author name, or "*" for everyone. Defaults to ' +
|
|
1777
|
+
"this client's own writes (only changes made through this connection). Matches " +
|
|
1778
|
+
"exactly. Selects WHICH changes to undo, and never a change already undone. Not " +
|
|
1779
|
+
"combinable with txn."),
|
|
1780
|
+
txn: z
|
|
1781
|
+
.string()
|
|
1782
|
+
.optional()
|
|
1783
|
+
.describe("Undo exactly the one change with this undo token (the undoToken field returned " +
|
|
1784
|
+
"by the mutation); immune to interleaving. Not combinable with last/by."),
|
|
1202
1785
|
dangerously_permanent: z.boolean().optional(),
|
|
1786
|
+
acknowledge_out_of_band_changes: z
|
|
1787
|
+
.boolean()
|
|
1788
|
+
.optional()
|
|
1789
|
+
.describe("Proceed even when the item changed outside this interface since (in the Things app " +
|
|
1790
|
+
"or by another tool) — overwrites whatever the out-of-band change left, instead of " +
|
|
1791
|
+
"refusing."),
|
|
1203
1792
|
...dryRunShape,
|
|
1204
1793
|
},
|
|
1205
1794
|
annotations: DESTRUCTIVE,
|
|
1206
1795
|
}, async (args) => guard(async () => {
|
|
1796
|
+
if (args.txn !== undefined && (args.last !== undefined || args.by !== undefined)) {
|
|
1797
|
+
return usage("txn cannot be combined with last or by");
|
|
1798
|
+
}
|
|
1207
1799
|
const items = await getClient().write.undo({
|
|
1208
1800
|
...(args.last !== undefined && { last: args.last }),
|
|
1801
|
+
...(args.txn !== undefined && { txn: args.txn }),
|
|
1802
|
+
// Asymmetric default: agents must not clobber the user's own edits (or
|
|
1803
|
+
// another client's) without explicitly opting in via by:"*". Scoped to
|
|
1804
|
+
// this client's own handshake identity, so each session undoes its own.
|
|
1805
|
+
...(args.txn === undefined && { by: args.by ?? mcpActor() }),
|
|
1209
1806
|
...(args.dry_run === true && { dryRun: true }),
|
|
1210
1807
|
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1211
|
-
|
|
1808
|
+
...(args.acknowledge_out_of_band_changes === true && {
|
|
1809
|
+
acknowledgeOutOfBandChanges: true,
|
|
1810
|
+
}),
|
|
1811
|
+
actor: mcpActor(),
|
|
1212
1812
|
});
|
|
1213
1813
|
return jsonResult(items);
|
|
1214
1814
|
}));
|
|
@@ -1226,8 +1826,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1226
1826
|
server.registerTool("doctor", {
|
|
1227
1827
|
description: "Check the environment: whether the Things app and its database are reachable, " +
|
|
1228
1828
|
"whether changes can be made, any one-time setup still needed (macOS permissions, " +
|
|
1229
|
-
"the app's 'Enable Things URLs' setting),
|
|
1230
|
-
"the last successful write,
|
|
1829
|
+
"the app's 'Enable Things URLs' setting), whether the environment changed since " +
|
|
1830
|
+
"the last successful write, and a sync-health summary (whether the app is running, how " +
|
|
1831
|
+
"recently the data changed, and — when a Things Cloud account is attached — the last " +
|
|
1832
|
+
"sync attempt), with steps to fix.",
|
|
1231
1833
|
inputSchema: {
|
|
1232
1834
|
probe_automation: z
|
|
1233
1835
|
.boolean()
|