things-api 0.10.0 → 0.12.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 +50 -13
- package/dist/audit/schema.d.ts +7 -0
- 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 +18 -11
- package/dist/cli/commands/area.js +61 -74
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -2
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +60 -0
- package/dist/cli/commands/install-skill.js +256 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +1 -0
- package/dist/cli/commands/project.js +27 -34
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +144 -44
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +7 -2
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +38 -14
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +15 -9
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +797 -174
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +14 -3
- package/dist/cli/did-you-mean.js +26 -3
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/glyphs.d.ts +32 -2
- package/dist/cli/glyphs.js +83 -12
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +121 -16
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +33 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +46 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +2 -8
- package/dist/cli/period.js +44 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +43 -5
- package/dist/cli/read-driver.js +91 -15
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +19 -8
- package/dist/cli/render.js +70 -27
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +2 -2
- package/dist/cli/resolve-invocation.js +3 -3
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +27 -0
- package/dist/cli/skill-check.js +99 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +70 -0
- package/dist/cli/skill.js +152 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +6 -2
- package/dist/cli/tag-filters.js +8 -6
- package/dist/cli/tag-filters.js.map +1 -1
- package/dist/cli/verb-hint.js +28 -3
- package/dist/cli/verb-hint.js.map +1 -1
- 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/client.d.ts +171 -27
- package/dist/client.js +279 -40
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +66 -2
- package/dist/config.js +120 -12
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +186 -28
- package/dist/contracts.js +22 -1
- package/dist/contracts.js.map +1 -1
- package/dist/diagnose.d.ts +34 -2
- package/dist/diagnose.js +36 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +25 -9
- package/dist/index.js +22 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +1157 -684
- 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 +60 -3
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +4 -2
- package/dist/model/mappers.js +66 -9
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/recurrence.d.ts +8 -1
- package/dist/model/recurrence.js.map +1 -1
- package/dist/read/area-filter.d.ts +76 -0
- package/dist/read/area-filter.js +59 -0
- package/dist/read/area-filter.js.map +1 -0
- package/dist/read/area-view.d.ts +12 -12
- package/dist/read/area-view.js +58 -34
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +26 -12
- package/dist/read/detail.js.map +1 -1
- 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 +18 -0
- package/dist/read/predicates.js +19 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +32 -16
- package/dist/read/project-view.js +80 -45
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/pseudo-area.d.ts +29 -0
- package/dist/read/pseudo-area.js +27 -0
- package/dist/read/pseudo-area.js.map +1 -0
- package/dist/read/queries.d.ts +108 -19
- package/dist/read/queries.js +212 -35
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +126 -0
- package/dist/read/scope.js +162 -0
- package/dist/read/scope.js.map +1 -0
- package/dist/read/search-rank.d.ts +2 -5
- package/dist/read/shape.d.ts +162 -0
- package/dist/read/shape.js +686 -0
- package/dist/read/shape.js.map +1 -0
- package/dist/read/show-target.d.ts +9 -1
- package/dist/read/show-target.js +45 -4
- package/dist/read/show-target.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/stage.d.ts +199 -0
- package/dist/read/stage.js +125 -0
- package/dist/read/stage.js.map +1 -0
- package/dist/read/truncation.d.ts +8 -6
- package/dist/read/truncation.js +35 -10
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +61 -20
- package/dist/read/views.js +240 -82
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +9 -0
- package/dist/surface-copy.js +9 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/accessibility-probe.d.ts +1 -1
- package/dist/write/accessibility-probe.js +8 -0
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.d.ts +1 -1
- package/dist/write/automation-probe.js +8 -0
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.d.ts +49 -10
- package/dist/write/batch.js +423 -70
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +1 -1
- package/dist/write/commands.js +316 -66
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +94 -10
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.d.ts +10 -1
- package/dist/write/heading.js +35 -5
- package/dist/write/heading.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +2 -2
- package/dist/write/make-repeating-project.js +9 -9
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.d.ts +130 -0
- package/dist/write/move.js +1533 -0
- package/dist/write/move.js.map +1 -0
- package/dist/write/operations.d.ts +105 -19
- package/dist/write/operations.js +56 -7
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +31 -0
- package/dist/write/opid.js +30 -0
- package/dist/write/opid.js.map +1 -0
- package/dist/write/pipeline.d.ts +122 -3
- package/dist/write/pipeline.js +309 -52
- 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 +197 -6
- package/dist/write/pre-state.js +427 -20
- 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/reorder.d.ts +23 -3
- package/dist/write/reorder.js +1871 -85
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/reversibility.js +19 -7
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.d.ts +31 -0
- package/dist/write/scope-guard.js +162 -0
- package/dist/write/scope-guard.js.map +1 -0
- package/dist/write/undo.js +120 -13
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +16 -7
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +5 -7
- package/dist/write/vectors/registry.js +49 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +1 -1
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1192 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +46 -0
- package/dist/write/vectors/ui-certification.d.ts +10 -4
- package/dist/write/vectors/ui-certification.js +43 -10
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-drag.d.ts +21 -1
- package/dist/write/vectors/ui-drag.js +138 -27
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +34 -1
- package/dist/write/vectors/ui-recipes.js +217 -6
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +40 -1
- package/dist/write/vectors/ui.js +196 -39
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +127 -2
- package/dist/write/verify/delta.js +255 -16
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +20 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +24 -3
- package/schema/envelope.schema.json +383 -0
- package/skills/things-cli/SKILL.md +70 -0
- package/skills/things-cli/references/banner.md +35 -0
- package/skills/things-cli/references/contracts.md +62 -0
- package/skills/things-cli/references/errors.md +49 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +60 -0
- package/skills/things-cli/references/ordering.md +71 -0
package/dist/mcp/server.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP surface over ThingsClient — the third thin layer (CLI, library, MCP),
|
|
3
|
-
* all consuming the same client.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* all consuming the same client. Read tools return the library's JSON objects
|
|
4
|
+
* verbatim; mutation/move tools frame their result the same way the CLI's wire
|
|
5
|
+
* envelope does (docs/contract.md) — a success drops the internal discriminator
|
|
6
|
+
* (via mutationWireData), a dry-run emits the bare plan, and batch lines flatten
|
|
7
|
+
* to the string-`outcome` JSONL shape. Mutation failures surface as MCP tool
|
|
8
|
+
* errors carrying the machine-readable code + the remediation text the guards
|
|
9
|
+
* produce. Nothing here contains Things logic.
|
|
7
10
|
*
|
|
8
11
|
* Tool descriptions and the server instructions follow the consumer-voice
|
|
9
12
|
* contract in docs/design/surface-copy.md: behavior and side effects only,
|
|
@@ -13,7 +16,7 @@
|
|
|
13
16
|
*/
|
|
14
17
|
import { z } from "zod";
|
|
15
18
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
-
import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode,
|
|
19
|
+
import { ALL_DESC, AREA_FILTER_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, FULL_DESC, hasTagPresence, isLooseRef, isValidTimeZone, LIMIT_DESC, looseShadowNotice, MCP_WHEN_LABELS, mutationWireData, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OP_ID_RE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, shapeReadPayload, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
|
|
17
20
|
function jsonResult(data) {
|
|
18
21
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
19
22
|
}
|
|
@@ -44,17 +47,18 @@ function truncatedResult(data, truncation) {
|
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* Grouped read result (anytime/someday): the per-block-truncated sections plus
|
|
47
|
-
* a second block carrying the {@link
|
|
48
|
-
* anything was hidden, a one-line note the
|
|
50
|
+
* a second block carrying the unified {@link Truncation} counts (its `blocks`
|
|
51
|
+
* hold the per-block nesting) and, when anything was hidden, a one-line note the
|
|
52
|
+
* agent can read.
|
|
49
53
|
*/
|
|
50
|
-
function groupedResult(data,
|
|
51
|
-
const note =
|
|
54
|
+
function groupedResult(data, truncation) {
|
|
55
|
+
const note = truncation.truncated
|
|
52
56
|
? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
|
|
53
57
|
: undefined;
|
|
54
58
|
return {
|
|
55
59
|
content: [
|
|
56
60
|
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
57
|
-
{ type: "text", text: JSON.stringify({
|
|
61
|
+
{ type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
|
|
58
62
|
],
|
|
59
63
|
};
|
|
60
64
|
}
|
|
@@ -72,27 +76,60 @@ function resolveCap(value, all, defaultLimit) {
|
|
|
72
76
|
return null;
|
|
73
77
|
return value ?? defaultLimit;
|
|
74
78
|
}
|
|
75
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Resolve MCP limit/all (flat read tools) into a row cap (null = every row).
|
|
81
|
+
* all:true wins: it lifts the cap and takes precedence over a limit passed
|
|
82
|
+
* alongside it — an explicit "everything" request resolves the contradiction
|
|
83
|
+
* rather than erroring — so the pair is never a usage error on the flat tools.
|
|
84
|
+
*/
|
|
76
85
|
function resolveLimit(args) {
|
|
77
|
-
|
|
86
|
+
if (args.all === true)
|
|
87
|
+
return null;
|
|
88
|
+
return args.limit ?? DEFAULT_LIST_LIMIT;
|
|
78
89
|
}
|
|
90
|
+
/** Precedence notes appended to `limit`/`all` wherever a flat tool accepts both. */
|
|
91
|
+
const LIMIT_IGNORED_NOTE = "ignored when all is set";
|
|
92
|
+
const ALL_WINS_NOTE = "wins over limit if both are set";
|
|
79
93
|
/** Shared limit/all input schema fragment for the flat read tools. */
|
|
80
94
|
const limitShape = {
|
|
81
|
-
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
82
|
-
all: z.boolean().optional().describe(ALL_DESC),
|
|
95
|
+
limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
96
|
+
all: z.boolean().optional().describe(`${ALL_DESC}; ${ALL_WINS_NOTE}`),
|
|
83
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* The per-call time-zone knob for date-sensitive tools: an IANA zone that
|
|
100
|
+
* evaluates every date boundary (today/evening/upcoming/logbook/overdue/…) for
|
|
101
|
+
* the consumer's calendar, overriding the server's THINGS_TZ for THIS call.
|
|
102
|
+
*/
|
|
103
|
+
const TZ_DESC = "IANA time zone (e.g. Asia/Tokyo) to evaluate date boundaries in for this call — " +
|
|
104
|
+
"overrides the server default. Reminder times stay wall-clock and are never shifted.";
|
|
105
|
+
const tzShape = { tz: z.string().optional().describe(TZ_DESC) };
|
|
106
|
+
/** A usage result when `tz` is present but not a recognized IANA zone; null when it is valid/absent. */
|
|
107
|
+
function badTz(tz) {
|
|
108
|
+
if (tz !== undefined && !isValidTimeZone(tz)) {
|
|
109
|
+
return usage(`tz is not a valid IANA time zone: "${tz}" — expected e.g. "America/New_York" or "Asia/Tokyo"`);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
84
113
|
function errorResult(error) {
|
|
85
114
|
return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
|
|
86
115
|
}
|
|
87
116
|
function usage(message) {
|
|
88
117
|
return errorResult({ code: "usage", message });
|
|
89
118
|
}
|
|
90
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Map a mutation outcome to an MCP result (errors carry remediation). Framing
|
|
121
|
+
* mirrors the CLI wire (docs/contract.md, phase-2 alignment): a SUCCESS drops
|
|
122
|
+
* the internal success discriminator via {@link mutationWireData} (call success
|
|
123
|
+
* is the tool result's own not-an-error signal — the payload carries no `kind`),
|
|
124
|
+
* and a dry-run returns the bare plan (the CLI's `mutation-plan` data). Failures
|
|
125
|
+
* stay tool errors carrying the machine-readable code.
|
|
126
|
+
*/
|
|
91
127
|
function mutationResult(result) {
|
|
92
128
|
switch (result.kind) {
|
|
93
129
|
case "ok":
|
|
130
|
+
return jsonResult(mutationWireData(result));
|
|
94
131
|
case "dry-run":
|
|
95
|
-
return jsonResult(result);
|
|
132
|
+
return jsonResult(result.plan);
|
|
96
133
|
case "blocked":
|
|
97
134
|
return errorResult({
|
|
98
135
|
code: blockedCode(result),
|
|
@@ -121,6 +158,89 @@ function mutationResult(result) {
|
|
|
121
158
|
});
|
|
122
159
|
}
|
|
123
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Map a move/reorder orchestrator outcome to an MCP result (spec §4/§7). Framing
|
|
163
|
+
* mirrors {@link mutationResult}: a `move-ok` drops the internal discriminator
|
|
164
|
+
* (the placement-honesty `note` and every `membership`/`placement` field — with
|
|
165
|
+
* their own `touched`/`warnings` disclosures — pass through), and a dry-run
|
|
166
|
+
* returns the bare plan.
|
|
167
|
+
*/
|
|
168
|
+
function moveResult(result) {
|
|
169
|
+
switch (result.kind) {
|
|
170
|
+
case "move-ok":
|
|
171
|
+
return jsonResult(mutationWireData(result));
|
|
172
|
+
case "move-dry-run":
|
|
173
|
+
return jsonResult(result.plan);
|
|
174
|
+
case "move-refused":
|
|
175
|
+
return errorResult({
|
|
176
|
+
code: result.refusal,
|
|
177
|
+
message: result.detail,
|
|
178
|
+
...(result.remediation !== undefined && { remediation: result.remediation }),
|
|
179
|
+
...(result.candidates !== undefined && { details: { candidates: result.candidates } }),
|
|
180
|
+
});
|
|
181
|
+
case "move-leg-failed":
|
|
182
|
+
return errorResult({
|
|
183
|
+
code: "verify-failed",
|
|
184
|
+
message: result.detail,
|
|
185
|
+
remediation: `completed ${result.completed.length} leg(s) before the failure`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Flatten one batch line to the wire shape (docs/contract.md — the JSONL grammar
|
|
191
|
+
* shared with the CLI): the internal `outcome` union object collapses to one
|
|
192
|
+
* level. `outcome` becomes its tag as a plain string (`"ok"`, `"blocked"`,
|
|
193
|
+
* `"dry-run"`, `"already-applied"`, …), and every variant field (uuid, detail,
|
|
194
|
+
* plan, expected, observed, …) sits as a sibling of the line-level keys. The
|
|
195
|
+
* outcome's own `op` duplicates the line-level `op`, so it is dropped — no
|
|
196
|
+
* variant field name collides with a line-level key.
|
|
197
|
+
*/
|
|
198
|
+
function flattenBatchLine(r) {
|
|
199
|
+
const { index, op, outcome, tempId, boundUuid, opId } = r;
|
|
200
|
+
const { kind, op: _outcomeOp, ...variant } = outcome;
|
|
201
|
+
return {
|
|
202
|
+
index,
|
|
203
|
+
op,
|
|
204
|
+
outcome: kind,
|
|
205
|
+
...variant,
|
|
206
|
+
...(tempId !== undefined && { tempId }),
|
|
207
|
+
...(boundUuid !== undefined && { boundUuid }),
|
|
208
|
+
...(opId !== undefined && { opId }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Build a MovePosition from the shared MCP position args (null when none). */
|
|
212
|
+
function movePositionArgs(args) {
|
|
213
|
+
const chosen = [
|
|
214
|
+
args.first === true,
|
|
215
|
+
args.last === true,
|
|
216
|
+
args.before !== undefined,
|
|
217
|
+
args.after !== undefined,
|
|
218
|
+
].filter(Boolean).length;
|
|
219
|
+
if (chosen > 1)
|
|
220
|
+
return "conflict";
|
|
221
|
+
if (args.first === true)
|
|
222
|
+
return { at: "first" };
|
|
223
|
+
if (args.last === true)
|
|
224
|
+
return { at: "last" };
|
|
225
|
+
if (args.before !== undefined)
|
|
226
|
+
return { before: args.before };
|
|
227
|
+
if (args.after !== undefined)
|
|
228
|
+
return { after: args.after };
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
/** The shared position input schema for the move tools. */
|
|
232
|
+
const positionShape = {
|
|
233
|
+
first: z.boolean().optional().describe("place the block at the top of its bucket"),
|
|
234
|
+
last: z.boolean().optional().describe("place the block at the bottom of its bucket"),
|
|
235
|
+
before: z
|
|
236
|
+
.string()
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("place the block immediately before this item (same bucket)"),
|
|
239
|
+
after: z
|
|
240
|
+
.string()
|
|
241
|
+
.optional()
|
|
242
|
+
.describe("place the block immediately after this item (same bucket)"),
|
|
243
|
+
};
|
|
124
244
|
const READ_ONLY = { readOnlyHint: true };
|
|
125
245
|
const NON_DESTRUCTIVE = { destructiveHint: false };
|
|
126
246
|
const DESTRUCTIVE = { destructiveHint: true };
|
|
@@ -176,6 +296,35 @@ const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
|
|
|
176
296
|
const dryRunShape = {
|
|
177
297
|
dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
|
|
178
298
|
};
|
|
299
|
+
/**
|
|
300
|
+
* The per-call idempotency key for a single write tool — the analogue of a batch
|
|
301
|
+
* line's op_id. A resubmission carrying the same key is recognized as already
|
|
302
|
+
* applied (a prior verified change with that key) and is not re-run. Spread into
|
|
303
|
+
* the single-mutation write tools (not the variadic move/reorder tools, whose
|
|
304
|
+
* idempotency is the batch-shaped per-line op_id).
|
|
305
|
+
*/
|
|
306
|
+
const opIdShape = {
|
|
307
|
+
op_id: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe("Idempotency key: a resubmission with the same key is recognized as already applied " +
|
|
311
|
+
"and not re-run (matches [A-Za-z0-9_-], 1-64 chars)"),
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* The per-call opt-in for the tools that reach a change only by driving the
|
|
315
|
+
* local Things app's accessibility interface. Shared by `repeat`, the
|
|
316
|
+
* `convert_to_project` tool, the heading tool's `promote_heading` action, and
|
|
317
|
+
* the `areas` reorder scope.
|
|
318
|
+
*/
|
|
319
|
+
const driveGuiShape = {
|
|
320
|
+
dangerously_drive_gui: z
|
|
321
|
+
.boolean()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe("Required: this drives the local Things app through its accessibility interface to " +
|
|
324
|
+
"make a change the app offers nowhere else. It briefly interacts with the app's UI " +
|
|
325
|
+
"on the machine running this server, and must be turned on first with `things config " +
|
|
326
|
+
"set ui-enabled true`. Intended for a dedicated always-on Mac."),
|
|
327
|
+
};
|
|
179
328
|
/** How a tag value may be expressed on any tag-accepting tool. */
|
|
180
329
|
const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
|
|
181
330
|
/** create_tags param, shared by every tag-accepting write tool. */
|
|
@@ -187,6 +336,20 @@ const createTagsShape = {
|
|
|
187
336
|
"instead of stopping on an unknown tag"),
|
|
188
337
|
};
|
|
189
338
|
const containerRef = (ref) => ({ uuid: ref, title: ref });
|
|
339
|
+
/**
|
|
340
|
+
* The `loose` reserved-word disclosure for the area-filtered read tools
|
|
341
|
+
* (read_view, search — the same advisory the CLI surfaces, #333/#346). `loose`
|
|
342
|
+
* ALWAYS addresses the null area; when a real area named "Loose" shadows the
|
|
343
|
+
* reserved word this names it (by uuid) so it stays targetable. Returns
|
|
344
|
+
* undefined when the ref is not the reserved word, or nothing shadows it.
|
|
345
|
+
* (get_area reads its disclosure from the area-view result's own `notice`.)
|
|
346
|
+
*/
|
|
347
|
+
function looseAreaWarnings(c, areaRef) {
|
|
348
|
+
if (areaRef === undefined || !isLooseRef(areaRef))
|
|
349
|
+
return undefined;
|
|
350
|
+
const shadow = c.read.areas().find((a) => isLooseRef(a.title));
|
|
351
|
+
return shadow !== undefined ? [looseShadowNotice(shadow.uuid)] : undefined;
|
|
352
|
+
}
|
|
190
353
|
/** Cap on project titles inlined into the server instructions. */
|
|
191
354
|
const INSTRUCTIONS_MAX_PROJECTS = 100;
|
|
192
355
|
/** A tag's display label: nested tags show `parent > child`. */
|
|
@@ -211,20 +374,39 @@ function buildInstructions(getClient) {
|
|
|
211
374
|
"rather than being guessed at. Create missing tags/areas/projects first (add_tag, " +
|
|
212
375
|
"add_area, add_project).",
|
|
213
376
|
`- Scheduling vocabulary: when = ${WHEN_VALUES}; deadlines are ${DATE_FORMAT}; reminders ` +
|
|
214
|
-
`are ${REMINDER_FORMAT}
|
|
377
|
+
`are ${REMINDER_FORMAT}. Resolve relative calendar phrases against the Calendar context ` +
|
|
378
|
+
`below (or a date-sensitive read result's meta.clock.today), then pass the explicit date.`,
|
|
215
379
|
"- Every write tool accepts dry_run: true to preview the change without applying it. " +
|
|
216
|
-
"
|
|
217
|
-
"
|
|
380
|
+
"A preview creates no state, so later calls cannot reference an item that only appeared in " +
|
|
381
|
+
"a dry-run result. Operations with cascading or permanent effects require the explicit " +
|
|
382
|
+
"confirmation parameter named in their description; refused calls return an error saying " +
|
|
383
|
+
"what to pass.",
|
|
384
|
+
"- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
|
|
385
|
+
"tags inherited from its containing project and area. The today and evening markers are " +
|
|
386
|
+
"present only for an item in Today (evening implies today); an unscheduled start=active item " +
|
|
387
|
+
"is in Anytime and carries neither. Completing an item makes it findable in Logbook.",
|
|
388
|
+
"- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
|
|
218
389
|
`- Read results are compact: ${OMIT_EMPTY_NOTE}`,
|
|
219
390
|
];
|
|
220
391
|
try {
|
|
221
392
|
const c = getClient();
|
|
393
|
+
// Under a container scope the inventory below is already limited to in-scope
|
|
394
|
+
// containers (the client filters areas/projects), so out-of-scope names are
|
|
395
|
+
// never embedded here. State the scope plainly so the agent knows its bounds.
|
|
396
|
+
if (c.scope !== undefined) {
|
|
397
|
+
lines.push("", `Scope: this server is limited to the ${c.scope.kind} "${c.scope.title}". Only items ` +
|
|
398
|
+
"within it are readable, and every change is confined to it; a reference naming anything " +
|
|
399
|
+
"outside it returns not-found, exactly as a nonexistent reference does.");
|
|
400
|
+
}
|
|
222
401
|
const areas = c.read.areas();
|
|
223
402
|
const tags = c.read.tags();
|
|
224
403
|
const projects = c.read.projects();
|
|
404
|
+
const clock = c.clockMeta();
|
|
225
405
|
const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
|
|
226
406
|
const overflow = projects.length - shown.length;
|
|
227
|
-
lines.push("", "Current inventory (read at server start — refresh with list_collections):",
|
|
407
|
+
lines.push("", "Current inventory (read at server start — refresh with list_collections):", ...(clock !== undefined
|
|
408
|
+
? [`- Calendar context at server start: ${JSON.stringify(clock)}`]
|
|
409
|
+
: []), `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
|
|
228
410
|
(overflow > 0 ? `; …and ${overflow} more (list_collections for all)` : ""));
|
|
229
411
|
}
|
|
230
412
|
catch {
|
|
@@ -256,6 +438,13 @@ function deriveMcpActor(clientName) {
|
|
|
256
438
|
.replaceAll(/-+$/g, "");
|
|
257
439
|
return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
|
|
258
440
|
}
|
|
441
|
+
/** Validate an op_id (throwing RangeError → mapped to a `usage` tool error by `guard`). */
|
|
442
|
+
function assertOpId(opId) {
|
|
443
|
+
if (!OP_ID_RE.test(opId)) {
|
|
444
|
+
throw new RangeError("op_id must match [A-Za-z0-9_-] and be 1-64 characters");
|
|
445
|
+
}
|
|
446
|
+
return opId;
|
|
447
|
+
}
|
|
259
448
|
export function createThingsMcpServer(options = {}) {
|
|
260
449
|
// One lazily-opened client for the server's lifetime; SQLite read
|
|
261
450
|
// snapshots are per-statement, so fresh reads see external commits.
|
|
@@ -263,6 +452,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
263
452
|
const getClient = () => {
|
|
264
453
|
client ??= openThings({
|
|
265
454
|
...(options.dbPath !== undefined && { dbPath: options.dbPath }),
|
|
455
|
+
...(options.scope !== undefined && { scope: options.scope }),
|
|
266
456
|
...options.openOptions,
|
|
267
457
|
});
|
|
268
458
|
return client;
|
|
@@ -283,8 +473,11 @@ export function createThingsMcpServer(options = {}) {
|
|
|
283
473
|
...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
284
474
|
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
285
475
|
...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
476
|
+
...(args.allow_non_empty_area === true && { allowNonEmptyArea: true }),
|
|
286
477
|
...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
|
|
287
478
|
...(args.create_tags === true && { createTags: true }),
|
|
479
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
480
|
+
...(args.op_id !== undefined && { opId: assertOpId(args.op_id) }),
|
|
288
481
|
});
|
|
289
482
|
/** Run a handler, mapping environment/usage throws to tool errors. */
|
|
290
483
|
const guard = async (fn) => {
|
|
@@ -313,23 +506,52 @@ export function createThingsMcpServer(options = {}) {
|
|
|
313
506
|
* same note the CLI prints), so a consumer sees when the Things database no
|
|
314
507
|
* longer matches the validated schema and its data may be incomplete. No
|
|
315
508
|
* block is added when the schema checks out or the read itself errored.
|
|
509
|
+
*
|
|
510
|
+
* `extraWarnings` folds in the read's own advisories (the `loose` reserved-word
|
|
511
|
+
* shadow disclosure, an area-view's placement `notice`) so they reach the
|
|
512
|
+
* consumer in the SAME `meta.warnings` array the CLI surfaces — schema warnings
|
|
513
|
+
* first, then the read-specific notices.
|
|
316
514
|
*/
|
|
317
|
-
const readGuard = async (fn) => {
|
|
515
|
+
const readGuard = async (fn, tz, extraMeta, extraWarnings) => {
|
|
318
516
|
const result = await guard(fn);
|
|
319
517
|
if (result.isError === true)
|
|
320
518
|
return result;
|
|
321
519
|
let warnings = [];
|
|
520
|
+
let clock;
|
|
322
521
|
try {
|
|
323
|
-
|
|
522
|
+
const c = getClient();
|
|
523
|
+
warnings = schemaWarnings(c.schemaStatus());
|
|
524
|
+
// The clock honesty field for this call's effective zone (the per-call
|
|
525
|
+
// tz over the server default) — present only when a consumer zone /
|
|
526
|
+
// pinned now is in effect.
|
|
527
|
+
clock = c.clockMeta(tz);
|
|
324
528
|
}
|
|
325
529
|
catch {
|
|
326
530
|
warnings = [];
|
|
327
531
|
}
|
|
328
|
-
|
|
532
|
+
// Read-specific advisories the handler resolved (loose-shadow / area notice)
|
|
533
|
+
// — merged AFTER the schema warnings, defensive against a DB read throwing.
|
|
534
|
+
let readNotices = [];
|
|
535
|
+
try {
|
|
536
|
+
readNotices = extraWarnings?.() ?? [];
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
readNotices = [];
|
|
540
|
+
}
|
|
541
|
+
const allWarnings = [...warnings, ...readNotices];
|
|
542
|
+
// Extra additive meta the handler resolved during the read (e.g. the active
|
|
543
|
+
// `area` filter) — evaluated AFTER fn ran, so a value it populated is seen.
|
|
544
|
+
const extra = extraMeta?.() ?? {};
|
|
545
|
+
const meta = {
|
|
546
|
+
...(allWarnings.length > 0 && { warnings: allWarnings }),
|
|
547
|
+
...(clock !== undefined && { clock }),
|
|
548
|
+
...extra,
|
|
549
|
+
};
|
|
550
|
+
if (Object.keys(meta).length === 0)
|
|
329
551
|
return result;
|
|
330
552
|
return {
|
|
331
553
|
...result,
|
|
332
|
-
content: [...result.content, { type: "text", text: JSON.stringify({ meta
|
|
554
|
+
content: [...result.content, { type: "text", text: JSON.stringify({ meta }) }],
|
|
333
555
|
};
|
|
334
556
|
};
|
|
335
557
|
/** Resolve a uuid to to-do/project for the type-generic item tools. */
|
|
@@ -360,6 +582,11 @@ export function createThingsMcpServer(options = {}) {
|
|
|
360
582
|
inputSchema: {
|
|
361
583
|
view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
|
|
362
584
|
...tagFilterShape,
|
|
585
|
+
...tzShape,
|
|
586
|
+
area: z
|
|
587
|
+
.string()
|
|
588
|
+
.optional()
|
|
589
|
+
.describe(`today/anytime/someday/upcoming/logbook only: ${AREA_FILTER_DESC}`),
|
|
363
590
|
evening: z.boolean().optional().describe("today only: show only the This Evening section"),
|
|
364
591
|
show_active_project_items: z
|
|
365
592
|
.union([z.boolean(), z.number().int().min(1)])
|
|
@@ -382,7 +609,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
382
609
|
.int()
|
|
383
610
|
.min(1)
|
|
384
611
|
.optional()
|
|
385
|
-
.describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
|
|
612
|
+
.describe(`flat views only (not anytime/someday): ${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
386
613
|
area_limit: z
|
|
387
614
|
.number()
|
|
388
615
|
.int()
|
|
@@ -398,102 +625,143 @@ export function createThingsMcpServer(options = {}) {
|
|
|
398
625
|
all: z
|
|
399
626
|
.boolean()
|
|
400
627
|
.optional()
|
|
401
|
-
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"
|
|
628
|
+
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps); " +
|
|
629
|
+
ALL_WINS_NOTE),
|
|
630
|
+
full: z.boolean().optional().describe(FULL_DESC),
|
|
402
631
|
},
|
|
403
632
|
annotations: READ_ONLY,
|
|
404
|
-
}, async (args) =>
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
return usage(`evening applies only to today, not ${args.view}`);
|
|
431
|
-
}
|
|
432
|
-
if (args.view === "someday" && args.project_limit !== undefined) {
|
|
433
|
-
return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
|
|
434
|
-
"to cap that section's project lists");
|
|
435
|
-
}
|
|
436
|
-
const limit = resolveLimit(args);
|
|
437
|
-
if (limit === "conflict")
|
|
438
|
-
return usage("pass at most one of limit / all");
|
|
439
|
-
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
440
|
-
const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
|
|
441
|
-
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
442
|
-
return usage("pass at most one of area_limit/project_limit / all");
|
|
443
|
-
}
|
|
444
|
-
const c = getClient();
|
|
445
|
-
const filter = validated.filter;
|
|
446
|
-
switch (args.view) {
|
|
447
|
-
case "today": {
|
|
448
|
-
const { view, truncation } = c.read.today({
|
|
449
|
-
...filter,
|
|
450
|
-
...(args.evening === true && { eveningOnly: true }),
|
|
451
|
-
limit,
|
|
452
|
-
});
|
|
453
|
-
return truncatedResult(view, truncation);
|
|
633
|
+
}, async (args) => {
|
|
634
|
+
// The active `area` scope, resolved during the read and surfaced as the
|
|
635
|
+
// additive `meta.filter` (the readGuard extra-meta thunk reads it after fn).
|
|
636
|
+
let filterMeta;
|
|
637
|
+
const full = args.full === true;
|
|
638
|
+
return readGuard(() => {
|
|
639
|
+
const badZone = badTz(args.tz);
|
|
640
|
+
if (badZone !== null)
|
|
641
|
+
return badZone;
|
|
642
|
+
// Tag-conflict AND overdue-applicability both derive from the shared
|
|
643
|
+
// contract: read_view honors overdue only on today/inbox/anytime/someday
|
|
644
|
+
// (the current-work views), matching FILTER_CONTRACT.
|
|
645
|
+
const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
|
|
646
|
+
untaggedConflict: MCP_UNTAGGED_CONFLICT,
|
|
647
|
+
overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
|
|
648
|
+
overdueStatusWiden: "",
|
|
649
|
+
});
|
|
650
|
+
if (!validated.ok)
|
|
651
|
+
return usage(validated.message);
|
|
652
|
+
// show_active_project_items is the preferred name; active_project_items
|
|
653
|
+
// stays accepted as a compatibility alias.
|
|
654
|
+
const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
|
|
655
|
+
const isGrouped = args.view === "anytime" || args.view === "someday";
|
|
656
|
+
if (isGrouped && args.limit !== undefined) {
|
|
657
|
+
return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
|
|
658
|
+
`${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
|
|
454
659
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
return truncatedResult(items, truncation);
|
|
660
|
+
if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
|
|
661
|
+
return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
|
|
458
662
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
return groupedResult(view, grouped);
|
|
663
|
+
if (args.view !== "someday" && showActiveProjectItems !== undefined) {
|
|
664
|
+
return usage("show_active_project_items applies only to someday");
|
|
462
665
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
...filter,
|
|
466
|
-
...(args.horizon !== undefined && { horizon: args.horizon }),
|
|
467
|
-
limit,
|
|
468
|
-
});
|
|
469
|
-
return truncatedResult(items, truncation);
|
|
666
|
+
if (args.view !== "today" && args.evening === true) {
|
|
667
|
+
return usage(`evening applies only to today, not ${args.view}`);
|
|
470
668
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
const { view, grouped } = c.read.someday({
|
|
477
|
-
...filter,
|
|
478
|
-
...((active === true || typeof active === "number") && {
|
|
479
|
-
activeProjectItems: true,
|
|
480
|
-
}),
|
|
481
|
-
areaLimit,
|
|
482
|
-
// true = every item per project; a number caps each list.
|
|
483
|
-
projectLimit: typeof active === "number" ? active : null,
|
|
484
|
-
});
|
|
485
|
-
return groupedResult(view, grouped);
|
|
669
|
+
// The `area` filter applies to the area-carrying views only; inbox
|
|
670
|
+
// (area-less captures) and trash have no area to scope by.
|
|
671
|
+
if ((args.view === "inbox" || args.view === "trash") && args.area !== undefined) {
|
|
672
|
+
return usage(`area applies to today/anytime/someday/upcoming/logbook, not ${args.view}`);
|
|
486
673
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
674
|
+
if (args.view === "someday" && args.project_limit !== undefined) {
|
|
675
|
+
return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
|
|
676
|
+
"to cap that section's project lists");
|
|
490
677
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
678
|
+
const limit = resolveLimit(args);
|
|
679
|
+
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
680
|
+
const projectLimit = resolveCap(args.project_limit, args.all, PROJECT_PREVIEW_LIMIT);
|
|
681
|
+
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
682
|
+
return usage("pass at most one of area_limit/project_limit / all");
|
|
494
683
|
}
|
|
495
|
-
|
|
496
|
-
|
|
684
|
+
const c = getClient();
|
|
685
|
+
const filter = validated.filter;
|
|
686
|
+
const zone = args.tz !== undefined ? { zone: args.tz } : {};
|
|
687
|
+
// The `area` scope, threaded into the area-carrying views; the client
|
|
688
|
+
// resolves it, applies the post-filter, and hands back the target for
|
|
689
|
+
// the `meta.filter` annotation (captured into `filterMeta`).
|
|
690
|
+
const area = args.area !== undefined ? { area: args.area } : {};
|
|
691
|
+
switch (args.view) {
|
|
692
|
+
case "today": {
|
|
693
|
+
const { view, truncation, filter: fm, } = c.read.today({
|
|
694
|
+
...filter,
|
|
695
|
+
...zone,
|
|
696
|
+
...area,
|
|
697
|
+
...(args.evening === true && { eveningOnly: true }),
|
|
698
|
+
limit,
|
|
699
|
+
});
|
|
700
|
+
filterMeta = fm;
|
|
701
|
+
return truncatedResult(shapeReadPayload("today", view, full), truncation);
|
|
702
|
+
}
|
|
703
|
+
case "inbox": {
|
|
704
|
+
const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
|
|
705
|
+
return truncatedResult(shapeReadPayload("inbox", items, full), truncation);
|
|
706
|
+
}
|
|
707
|
+
case "anytime": {
|
|
708
|
+
const { view, truncation, filter: fm, } = c.read.anytime({
|
|
709
|
+
...filter,
|
|
710
|
+
...zone,
|
|
711
|
+
...area,
|
|
712
|
+
areaLimit,
|
|
713
|
+
projectLimit,
|
|
714
|
+
});
|
|
715
|
+
filterMeta = fm;
|
|
716
|
+
return groupedResult(shapeReadPayload("anytime", view, full), truncation);
|
|
717
|
+
}
|
|
718
|
+
case "upcoming": {
|
|
719
|
+
const { items, truncation, filter: fm, } = c.read.upcoming({
|
|
720
|
+
...filter,
|
|
721
|
+
...zone,
|
|
722
|
+
...area,
|
|
723
|
+
...(args.horizon !== undefined && { horizon: args.horizon }),
|
|
724
|
+
limit,
|
|
725
|
+
});
|
|
726
|
+
filterMeta = fm;
|
|
727
|
+
return truncatedResult(shapeReadPayload("upcoming", items, full), truncation);
|
|
728
|
+
}
|
|
729
|
+
case "someday": {
|
|
730
|
+
const active = showActiveProjectItems;
|
|
731
|
+
if (typeof active === "number" && args.all === true) {
|
|
732
|
+
return usage("pass at most one of a numeric show_active_project_items / all");
|
|
733
|
+
}
|
|
734
|
+
const { view, truncation, filter: fm, } = c.read.someday({
|
|
735
|
+
...filter,
|
|
736
|
+
...zone,
|
|
737
|
+
...area,
|
|
738
|
+
...((active === true || typeof active === "number") && {
|
|
739
|
+
activeProjectItems: true,
|
|
740
|
+
}),
|
|
741
|
+
areaLimit,
|
|
742
|
+
// true = every item per project; a number caps each list.
|
|
743
|
+
projectLimit: typeof active === "number" ? active : null,
|
|
744
|
+
});
|
|
745
|
+
filterMeta = fm;
|
|
746
|
+
return groupedResult(shapeReadPayload("someday", view, full), truncation);
|
|
747
|
+
}
|
|
748
|
+
case "logbook": {
|
|
749
|
+
const { items, truncation, filter: fm, } = c.read.logbook({
|
|
750
|
+
...filter,
|
|
751
|
+
...zone,
|
|
752
|
+
...area,
|
|
753
|
+
limit,
|
|
754
|
+
});
|
|
755
|
+
filterMeta = fm;
|
|
756
|
+
return truncatedResult(shapeReadPayload("logbook", items, full), truncation);
|
|
757
|
+
}
|
|
758
|
+
case "trash": {
|
|
759
|
+
const { items, truncation } = c.read.trash({ ...zone, limit });
|
|
760
|
+
return truncatedResult(shapeReadPayload("trash", items, full), truncation);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
|
|
764
|
+
});
|
|
497
765
|
server.registerTool("search", {
|
|
498
766
|
description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
|
|
499
767
|
"include more with logged/trashed/all. Scope with project/area/tag — scope " +
|
|
@@ -502,6 +770,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
502
770
|
inputSchema: {
|
|
503
771
|
query: z.string(),
|
|
504
772
|
...tagFilterShape,
|
|
773
|
+
...tzShape,
|
|
505
774
|
project: z
|
|
506
775
|
.string()
|
|
507
776
|
.optional()
|
|
@@ -509,18 +778,22 @@ export function createThingsMcpServer(options = {}) {
|
|
|
509
778
|
area: z
|
|
510
779
|
.string()
|
|
511
780
|
.optional()
|
|
512
|
-
.describe(`Restrict to one area's direct
|
|
781
|
+
.describe(`Restrict to one area's subtree — its direct items plus its projects' children (${REF_FORMAT}, or \`loose\` for area-less)`),
|
|
513
782
|
type: z.enum(["to-do", "project"]).optional(),
|
|
514
783
|
logged: z.boolean().optional().describe("Also include completed/canceled items"),
|
|
515
784
|
trashed: z.boolean().optional().describe("Also include trashed items"),
|
|
516
785
|
all: z
|
|
517
786
|
.boolean()
|
|
518
787
|
.optional()
|
|
519
|
-
.describe(
|
|
520
|
-
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
788
|
+
.describe(`Everything, unbounded: open + logged + trashed, no row limit; ${ALL_WINS_NOTE}`),
|
|
789
|
+
limit: z.number().int().min(1).optional().describe(`${LIMIT_DESC}; ${LIMIT_IGNORED_NOTE}`),
|
|
790
|
+
full: z.boolean().optional().describe(FULL_DESC),
|
|
521
791
|
},
|
|
522
792
|
annotations: READ_ONLY,
|
|
523
793
|
}, async (args) => readGuard(() => {
|
|
794
|
+
const badZone = badTz(args.tz);
|
|
795
|
+
if (badZone !== null)
|
|
796
|
+
return badZone;
|
|
524
797
|
// Tag-conflict AND the overdue/status-widening incompatibility both
|
|
525
798
|
// derive from the shared contract (search: statusWidening = true).
|
|
526
799
|
const validated = validateViewArgs("search", {
|
|
@@ -537,11 +810,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
537
810
|
if (!validated.ok)
|
|
538
811
|
return usage(validated.message);
|
|
539
812
|
const limit = resolveLimit(args);
|
|
540
|
-
if (limit === "conflict")
|
|
541
|
-
return usage("pass at most one of limit / all");
|
|
542
813
|
const { items, truncation } = getClient().read.search(args.query, {
|
|
543
814
|
limit,
|
|
544
815
|
...validated.filter,
|
|
816
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
545
817
|
...(args.project !== undefined && { project: args.project }),
|
|
546
818
|
...(args.area !== undefined && { area: args.area }),
|
|
547
819
|
...(args.type !== undefined && { type: args.type }),
|
|
@@ -549,8 +821,8 @@ export function createThingsMcpServer(options = {}) {
|
|
|
549
821
|
...(args.trashed === true && { trashed: true }),
|
|
550
822
|
...(args.all === true && { all: true }),
|
|
551
823
|
});
|
|
552
|
-
return truncatedResult(items, truncation);
|
|
553
|
-
}));
|
|
824
|
+
return truncatedResult(shapeReadPayload("search", items, args.full === true), truncation);
|
|
825
|
+
}, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
|
|
554
826
|
server.registerTool("changes_since", {
|
|
555
827
|
description: "List items created or modified since a moment — including trashed, logged, and " +
|
|
556
828
|
"repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
|
|
@@ -559,19 +831,26 @@ export function createThingsMcpServer(options = {}) {
|
|
|
559
831
|
inputSchema: {
|
|
560
832
|
since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
|
|
561
833
|
...limitShape,
|
|
834
|
+
...tzShape,
|
|
835
|
+
full: z.boolean().optional().describe(FULL_DESC),
|
|
562
836
|
},
|
|
563
837
|
annotations: READ_ONLY,
|
|
564
838
|
}, async (args) => readGuard(() => {
|
|
839
|
+
const badZone = badTz(args.tz);
|
|
840
|
+
if (badZone !== null)
|
|
841
|
+
return badZone;
|
|
565
842
|
const limit = resolveLimit(args);
|
|
566
|
-
if (limit === "conflict")
|
|
567
|
-
return usage("pass at most one of limit / all");
|
|
568
843
|
const since = new Date(args.since);
|
|
569
844
|
if (Number.isNaN(since.getTime())) {
|
|
570
845
|
return usage(`since is not a parseable date: ${args.since}`);
|
|
571
846
|
}
|
|
572
|
-
const { items, truncation } = getClient().read.changes({
|
|
573
|
-
|
|
574
|
-
|
|
847
|
+
const { items, truncation } = getClient().read.changes({
|
|
848
|
+
since,
|
|
849
|
+
limit,
|
|
850
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
851
|
+
});
|
|
852
|
+
return truncatedResult(shapeReadPayload("changes", items, args.full === true), truncation);
|
|
853
|
+
}, args.tz));
|
|
575
854
|
server.registerTool("get_item", {
|
|
576
855
|
description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
|
|
577
856
|
"(direct and inherited), checklist with per-item state, repeat schedule, and its " +
|
|
@@ -583,7 +862,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
583
862
|
const item = getClient().read.byUuid(args.uuid);
|
|
584
863
|
return item === null
|
|
585
864
|
? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
|
|
586
|
-
: readResult(item);
|
|
865
|
+
: readResult(shapeReadPayload("detail", item, false));
|
|
587
866
|
}));
|
|
588
867
|
server.registerTool("get_project", {
|
|
589
868
|
description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
|
|
@@ -593,23 +872,31 @@ export function createThingsMcpServer(options = {}) {
|
|
|
593
872
|
inputSchema: {
|
|
594
873
|
uuid: z.string().describe("Project uuid or unique name"),
|
|
595
874
|
...tagOnlyShape,
|
|
875
|
+
...tzShape,
|
|
596
876
|
overdue: z
|
|
597
877
|
.boolean()
|
|
598
878
|
.optional()
|
|
599
879
|
.describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
|
|
880
|
+
full: z.boolean().optional().describe(FULL_DESC),
|
|
600
881
|
},
|
|
601
882
|
annotations: READ_ONLY,
|
|
602
883
|
}, async (args) => readGuard(() => {
|
|
884
|
+
const badZone = badTz(args.tz);
|
|
885
|
+
if (badZone !== null)
|
|
886
|
+
return badZone;
|
|
603
887
|
if (tagFlagConflict(tagPresence(args)))
|
|
604
888
|
return usage(MCP_UNTAGGED_CONFLICT);
|
|
605
|
-
return readResult(getClient().read.projectView(args.uuid, {
|
|
889
|
+
return readResult(shapeReadPayload("project-view", getClient().read.projectView(args.uuid, {
|
|
606
890
|
overdue: args.overdue === true,
|
|
607
891
|
...tagFilterFields(tagPresence(args)),
|
|
608
|
-
|
|
609
|
-
|
|
892
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
893
|
+
}), args.full === true));
|
|
894
|
+
}, args.tz));
|
|
610
895
|
server.registerTool("get_area", {
|
|
611
896
|
description: "One area's contents: metadata plus its direct to-dos (active first), its " +
|
|
612
|
-
"projects in canonical order, later (scheduled/repeating/someday)
|
|
897
|
+
"projects in canonical order, and later (scheduled/repeating/someday). The area " +
|
|
898
|
+
"logbook is not returned here — read it with read_view logbook + area; trashed " +
|
|
899
|
+
"rows live in read_view trash. " +
|
|
613
900
|
`The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
|
|
614
901
|
"by default (project_limit / area_limit adjust them; all: true lifts both); the " +
|
|
615
902
|
"second result block reports the counts. " +
|
|
@@ -617,6 +904,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
617
904
|
inputSchema: {
|
|
618
905
|
ref: z.string().describe("Area uuid or unique name"),
|
|
619
906
|
...tagOnlyShape,
|
|
907
|
+
...tzShape,
|
|
620
908
|
area_limit: z
|
|
621
909
|
.number()
|
|
622
910
|
.int()
|
|
@@ -634,24 +922,36 @@ export function createThingsMcpServer(options = {}) {
|
|
|
634
922
|
.optional()
|
|
635
923
|
.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"),
|
|
636
924
|
all: z.boolean().optional().describe("return both sections in full (no caps)"),
|
|
925
|
+
full: z.boolean().optional().describe(FULL_DESC),
|
|
637
926
|
},
|
|
638
927
|
annotations: READ_ONLY,
|
|
639
|
-
}, async (args) =>
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
areaLimit,
|
|
651
|
-
projectLimit,
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
928
|
+
}, async (args) => {
|
|
929
|
+
// The `loose` pseudo-area shadow disclosure the area-view read resolves —
|
|
930
|
+
// captured after fn runs and folded into meta.warnings (parity with the
|
|
931
|
+
// CLI's `area show loose` notice).
|
|
932
|
+
let areaNotice;
|
|
933
|
+
return readGuard(() => {
|
|
934
|
+
const badZone = badTz(args.tz);
|
|
935
|
+
if (badZone !== null)
|
|
936
|
+
return badZone;
|
|
937
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
938
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
939
|
+
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
940
|
+
const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
941
|
+
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
942
|
+
return usage("pass at most one of area_limit/project_limit / all");
|
|
943
|
+
}
|
|
944
|
+
const { view, truncation, notice } = getClient().read.areaView(args.ref, {
|
|
945
|
+
overdue: args.overdue === true,
|
|
946
|
+
...tagFilterFields(tagPresence(args)),
|
|
947
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
948
|
+
areaLimit,
|
|
949
|
+
projectLimit,
|
|
950
|
+
});
|
|
951
|
+
areaNotice = notice;
|
|
952
|
+
return groupedResult(shapeReadPayload("area-view", view, args.full === true), truncation);
|
|
953
|
+
}, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
|
|
954
|
+
});
|
|
655
955
|
server.registerTool("list_collections", {
|
|
656
956
|
description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
|
|
657
957
|
"refresh the inventory summarized in the server instructions. The tag filters scope " +
|
|
@@ -660,13 +960,18 @@ export function createThingsMcpServer(options = {}) {
|
|
|
660
960
|
inputSchema: {
|
|
661
961
|
kind: z.enum(["projects", "areas", "tags"]),
|
|
662
962
|
...tagOnlyShape,
|
|
963
|
+
...tzShape,
|
|
663
964
|
overdue: z
|
|
664
965
|
.boolean()
|
|
665
966
|
.optional()
|
|
666
967
|
.describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
|
|
968
|
+
full: z.boolean().optional().describe(`projects only: ${FULL_DESC}`),
|
|
667
969
|
},
|
|
668
970
|
annotations: READ_ONLY,
|
|
669
971
|
}, async (args) => readGuard(() => {
|
|
972
|
+
const badZone = badTz(args.tz);
|
|
973
|
+
if (badZone !== null)
|
|
974
|
+
return badZone;
|
|
670
975
|
const c = getClient();
|
|
671
976
|
// areas/tags are not dated entities and have no per-row tag list to
|
|
672
977
|
// filter — overdue and the tag filters are vacuous there, rejected
|
|
@@ -688,21 +993,22 @@ export function createThingsMcpServer(options = {}) {
|
|
|
688
993
|
}
|
|
689
994
|
if (tagFlagConflict(tagPresence(args)))
|
|
690
995
|
return usage(MCP_UNTAGGED_CONFLICT);
|
|
691
|
-
return readResult(args.kind === "projects"
|
|
996
|
+
return readResult(shapeReadPayload(args.kind, args.kind === "projects"
|
|
692
997
|
? c.read.projects({
|
|
693
998
|
overdue: args.overdue === true,
|
|
694
999
|
...tagFilterFields(tagPresence(args)),
|
|
1000
|
+
...(args.tz !== undefined && { zone: args.tz }),
|
|
695
1001
|
})
|
|
696
1002
|
: args.kind === "areas"
|
|
697
1003
|
? c.read.areas()
|
|
698
|
-
: c.read.tags());
|
|
699
|
-
}));
|
|
1004
|
+
: c.read.tags(), args.full === true));
|
|
1005
|
+
}, args.tz));
|
|
700
1006
|
// ---------------------------------------------------------------- to-dos
|
|
701
1007
|
const whenSchema = z.string().optional().describe(WHEN_VALUES);
|
|
702
1008
|
server.registerTool("add_todo", {
|
|
703
1009
|
description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
|
|
704
1010
|
"deadline, tag it, give it a checklist, and place it in a project or area " +
|
|
705
|
-
"(optionally under
|
|
1011
|
+
"(optionally under a heading within that project). A reminder " +
|
|
706
1012
|
"requires when = today, evening, or a date. Adding into a completed or canceled " +
|
|
707
1013
|
"project reopens that project — pass acknowledge_project_reopen to confirm.",
|
|
708
1014
|
inputSchema: {
|
|
@@ -721,10 +1027,15 @@ export function createThingsMcpServer(options = {}) {
|
|
|
721
1027
|
.optional()
|
|
722
1028
|
.describe("Confirm adding into a completed/canceled project (this reopens it)"),
|
|
723
1029
|
...createTagsShape,
|
|
1030
|
+
...tzShape,
|
|
724
1031
|
...dryRunShape,
|
|
1032
|
+
...opIdShape,
|
|
725
1033
|
},
|
|
726
1034
|
annotations: NON_DESTRUCTIVE,
|
|
727
1035
|
}, async (args) => guard(async () => {
|
|
1036
|
+
const badZone = badTz(args.tz);
|
|
1037
|
+
if (badZone !== null)
|
|
1038
|
+
return badZone;
|
|
728
1039
|
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
729
1040
|
if (sugar.kind === "error")
|
|
730
1041
|
return usage(sugar.message);
|
|
@@ -743,90 +1054,228 @@ export function createThingsMcpServer(options = {}) {
|
|
|
743
1054
|
...(args.heading !== undefined && { heading: args.heading }),
|
|
744
1055
|
}, writeOptions(args)));
|
|
745
1056
|
}));
|
|
746
|
-
server.registerTool("
|
|
747
|
-
description: "
|
|
748
|
-
"append_notes/prepend_notes add a line to the
|
|
749
|
-
"
|
|
750
|
-
"
|
|
751
|
-
"
|
|
752
|
-
"
|
|
753
|
-
"
|
|
1057
|
+
server.registerTool("update", {
|
|
1058
|
+
description: "Edit an existing to-do, project, area, or tag — kind selects which. " +
|
|
1059
|
+
"kind todo/project: title, notes (or append_notes/prepend_notes to add a line to the " +
|
|
1060
|
+
"existing body, exclusive with notes), schedule (when), reminder/clear_reminder, and " +
|
|
1061
|
+
"deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
|
|
1062
|
+
"one is set, schedule and deadline changes are unavailable for repeating items, and " +
|
|
1063
|
+
"clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
|
|
1064
|
+
"future date can only be changed, not cleared). kind area: title and/or tags (the full " +
|
|
1065
|
+
"replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
|
|
1066
|
+
"exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
|
|
1067
|
+
"create_tags is set.",
|
|
754
1068
|
inputSchema: {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1069
|
+
kind: z.enum(["todo", "project", "area", "tag"]),
|
|
1070
|
+
uuid: z
|
|
1071
|
+
.string()
|
|
1072
|
+
.describe("The item to update — a to-do by uuid; a project, area, or tag also accepts a " +
|
|
1073
|
+
"unique name"),
|
|
1074
|
+
title: z.string().optional().describe("New title (any kind)"),
|
|
1075
|
+
notes: z.string().optional().describe("todo/project: replaces the whole notes body"),
|
|
1076
|
+
append_notes: z.string().optional().describe("todo/project: add a line after the notes"),
|
|
1077
|
+
prepend_notes: z.string().optional().describe("todo/project: add a line before the notes"),
|
|
760
1078
|
when: whenSchema,
|
|
761
|
-
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
762
|
-
clear_reminder: z.boolean().optional(),
|
|
763
|
-
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
764
|
-
clear_deadline: z.boolean().optional(),
|
|
1079
|
+
reminder: z.string().optional().describe(`todo/project: ${REMINDER_FORMAT}`),
|
|
1080
|
+
clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
|
|
1081
|
+
deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
|
|
1082
|
+
clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
|
|
1083
|
+
tags: z
|
|
1084
|
+
.array(z.string())
|
|
1085
|
+
.optional()
|
|
1086
|
+
.describe(`area: replace the tag set (full) — ${TAG_REF_FORMAT}`),
|
|
1087
|
+
parent: z.string().optional().describe("tag: existing tag to nest under"),
|
|
1088
|
+
unnest: z.boolean().optional().describe("tag: move the tag to the top level"),
|
|
1089
|
+
shortcut: z.string().optional().describe("tag: keyboard shortcut character"),
|
|
1090
|
+
clear_shortcut: z.boolean().optional().describe("tag: remove the keyboard shortcut"),
|
|
1091
|
+
...createTagsShape,
|
|
1092
|
+
...tzShape,
|
|
765
1093
|
...dryRunShape,
|
|
1094
|
+
...opIdShape,
|
|
766
1095
|
},
|
|
767
1096
|
annotations: NON_DESTRUCTIVE,
|
|
768
1097
|
}, async (args) => guard(async () => {
|
|
769
|
-
const
|
|
770
|
-
if (
|
|
771
|
-
return
|
|
1098
|
+
const badZone = badTz(args.tz);
|
|
1099
|
+
if (badZone !== null)
|
|
1100
|
+
return badZone;
|
|
1101
|
+
const opts = writeOptions(args);
|
|
1102
|
+
const c = getClient();
|
|
1103
|
+
if (args.kind === "todo" || args.kind === "project") {
|
|
1104
|
+
const notesModes = [args.notes, args.append_notes, args.prepend_notes].filter((v) => v !== undefined);
|
|
1105
|
+
if (notesModes.length > 1) {
|
|
1106
|
+
return usage("notes, append_notes, prepend_notes are exclusive");
|
|
1107
|
+
}
|
|
1108
|
+
if (args.reminder !== undefined && args.clear_reminder === true) {
|
|
1109
|
+
return usage("pass at most one of reminder / clear_reminder");
|
|
1110
|
+
}
|
|
1111
|
+
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
1112
|
+
return usage("pass at most one of deadline / clear_deadline");
|
|
1113
|
+
}
|
|
1114
|
+
if (args.kind === "todo") {
|
|
1115
|
+
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
1116
|
+
if (sugar.kind === "error")
|
|
1117
|
+
return usage(sugar.message);
|
|
1118
|
+
const when = sugar.kind === "split" ? sugar.when : args.when;
|
|
1119
|
+
const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
|
|
1120
|
+
return mutationResult(await c.write.updateTodo(args.uuid, {
|
|
1121
|
+
...(args.title !== undefined && { title: args.title }),
|
|
1122
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
1123
|
+
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
1124
|
+
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
1125
|
+
...(when !== undefined && { when: when }),
|
|
1126
|
+
...(reminder !== undefined && { reminder }),
|
|
1127
|
+
...(args.clear_reminder === true && { reminder: null }),
|
|
1128
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
1129
|
+
...(args.clear_deadline === true && { deadline: null }),
|
|
1130
|
+
}, opts));
|
|
1131
|
+
}
|
|
1132
|
+
return mutationResult(await c.write.updateProject(args.uuid, {
|
|
1133
|
+
...(args.title !== undefined && { title: args.title }),
|
|
1134
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
1135
|
+
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
1136
|
+
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
1137
|
+
...(args.when !== undefined && { when: args.when }),
|
|
1138
|
+
...(args.reminder !== undefined && { reminder: args.reminder }),
|
|
1139
|
+
...(args.clear_reminder === true && { reminder: null }),
|
|
1140
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
1141
|
+
...(args.clear_deadline === true && { deadline: null }),
|
|
1142
|
+
}, opts));
|
|
1143
|
+
}
|
|
1144
|
+
if (args.kind === "area") {
|
|
1145
|
+
if (args.title === undefined && args.tags === undefined) {
|
|
1146
|
+
return usage("kind area requires title and/or tags");
|
|
1147
|
+
}
|
|
1148
|
+
return mutationResult(await c.write.updateArea(args.uuid, {
|
|
1149
|
+
...(args.title !== undefined && { title: args.title }),
|
|
1150
|
+
...(args.tags !== undefined && { tags: args.tags }),
|
|
1151
|
+
}, opts));
|
|
1152
|
+
}
|
|
1153
|
+
// kind tag
|
|
1154
|
+
if (args.title === undefined &&
|
|
1155
|
+
args.parent === undefined &&
|
|
1156
|
+
args.unnest === undefined &&
|
|
1157
|
+
args.shortcut === undefined &&
|
|
1158
|
+
args.clear_shortcut === undefined) {
|
|
1159
|
+
return usage("kind tag requires title, parent, unnest, shortcut, and/or clear_shortcut");
|
|
772
1160
|
}
|
|
773
|
-
if (args.
|
|
774
|
-
return usage("
|
|
1161
|
+
if (args.parent !== undefined && args.unnest === true) {
|
|
1162
|
+
return usage("parent and unnest are exclusive");
|
|
775
1163
|
}
|
|
776
|
-
if (args.
|
|
777
|
-
return usage("
|
|
1164
|
+
if (args.shortcut !== undefined && args.clear_shortcut === true) {
|
|
1165
|
+
return usage("shortcut and clear_shortcut are exclusive");
|
|
778
1166
|
}
|
|
779
|
-
|
|
780
|
-
if (sugar.kind === "error")
|
|
781
|
-
return usage(sugar.message);
|
|
782
|
-
const when = sugar.kind === "split" ? sugar.when : args.when;
|
|
783
|
-
const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
|
|
784
|
-
return mutationResult(await getClient().write.updateTodo(args.uuid, {
|
|
1167
|
+
return mutationResult(await c.write.updateTag(args.uuid, {
|
|
785
1168
|
...(args.title !== undefined && { title: args.title }),
|
|
786
|
-
...(args.
|
|
787
|
-
...(args.
|
|
788
|
-
...(args.
|
|
789
|
-
...(
|
|
790
|
-
|
|
791
|
-
...(args.clear_reminder === true && { reminder: null }),
|
|
792
|
-
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
793
|
-
...(args.clear_deadline === true && { deadline: null }),
|
|
794
|
-
}, writeOptions(args)));
|
|
1169
|
+
...(args.parent !== undefined && { parent: args.parent }),
|
|
1170
|
+
...(args.unnest === true && { unnest: true }),
|
|
1171
|
+
...(args.shortcut !== undefined && { shortcut: args.shortcut }),
|
|
1172
|
+
...(args.clear_shortcut === true && { clearShortcut: true }),
|
|
1173
|
+
}, opts));
|
|
795
1174
|
}));
|
|
796
|
-
server.registerTool("
|
|
797
|
-
description: "Set a to-do's status: completed, canceled, or open
|
|
798
|
-
"completed/canceled
|
|
1175
|
+
server.registerTool("set_status", {
|
|
1176
|
+
description: "Set a to-do's or project's status (scope selects which): completed, canceled, or open " +
|
|
1177
|
+
"(reopening a completed/canceled item). Not available for repeating to-dos. " +
|
|
1178
|
+
"scope project, completing or canceling requires a children policy: 'require-resolved' " +
|
|
1179
|
+
"errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
|
|
1180
|
+
"the project (canceling never alters already-completed children). scope project, status " +
|
|
1181
|
+
"open, restore_children also reopens the to-dos that were resolved with the project.",
|
|
799
1182
|
inputSchema: {
|
|
800
|
-
|
|
1183
|
+
scope: z.enum(["todo", "project"]),
|
|
1184
|
+
uuid: z
|
|
1185
|
+
.string()
|
|
1186
|
+
.describe("The item — a to-do by uuid; a project also accepts a unique name"),
|
|
801
1187
|
status: z.enum(["completed", "canceled", "open"]),
|
|
1188
|
+
children: z
|
|
1189
|
+
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
1190
|
+
.optional()
|
|
1191
|
+
.describe("scope project, completed/canceled: what to do with the project's open to-dos"),
|
|
1192
|
+
restore_children: z
|
|
1193
|
+
.boolean()
|
|
1194
|
+
.optional()
|
|
1195
|
+
.describe("scope project, open only: also reopen the to-dos resolved with the project"),
|
|
802
1196
|
...dryRunShape,
|
|
1197
|
+
...opIdShape,
|
|
803
1198
|
},
|
|
804
1199
|
annotations: NON_DESTRUCTIVE,
|
|
805
1200
|
}, async (args) => guard(async () => {
|
|
806
1201
|
const c = getClient();
|
|
807
1202
|
const opts = writeOptions(args);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1203
|
+
if (args.scope === "todo") {
|
|
1204
|
+
if (args.children !== undefined || args.restore_children !== undefined) {
|
|
1205
|
+
return usage("children/restore_children apply only to scope project");
|
|
1206
|
+
}
|
|
1207
|
+
return mutationResult(args.status === "completed"
|
|
1208
|
+
? await c.write.completeTodo(args.uuid, opts)
|
|
1209
|
+
: args.status === "canceled"
|
|
1210
|
+
? await c.write.cancelTodo(args.uuid, opts)
|
|
1211
|
+
: await c.write.reopenTodo(args.uuid, opts));
|
|
1212
|
+
}
|
|
1213
|
+
// scope project
|
|
1214
|
+
if (args.status !== "open" && args.restore_children !== undefined) {
|
|
1215
|
+
return usage("restore_children applies only to status 'open'");
|
|
1216
|
+
}
|
|
1217
|
+
if (args.status === "completed") {
|
|
1218
|
+
if (args.children !== "require-resolved" && args.children !== "auto-complete") {
|
|
1219
|
+
return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
|
|
1220
|
+
}
|
|
1221
|
+
return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
|
|
1222
|
+
}
|
|
1223
|
+
if (args.status === "canceled") {
|
|
1224
|
+
if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
|
|
1225
|
+
return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
|
|
1226
|
+
}
|
|
1227
|
+
return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
|
|
1228
|
+
}
|
|
1229
|
+
if (args.children !== undefined) {
|
|
1230
|
+
return usage("children applies only to status 'completed' or 'canceled'");
|
|
1231
|
+
}
|
|
1232
|
+
// Reopening a project is a multi-leg compound — single-op idempotency
|
|
1233
|
+
// (op_id) does not apply to it in phase 1 (every other set_status path
|
|
1234
|
+
// is a single mutation). Refuse rather than silently drop the key.
|
|
1235
|
+
if (args.op_id !== undefined) {
|
|
1236
|
+
return usage("op_id is not available when reopening a project (a multi-leg compound in phase 1) — " +
|
|
1237
|
+
"use the batch tool with a per-line op_id");
|
|
1238
|
+
}
|
|
1239
|
+
const outcome = await c.write.reopenProject(args.uuid, {
|
|
1240
|
+
...opts,
|
|
1241
|
+
...(args.restore_children === true && { restoreChildren: true }),
|
|
1242
|
+
});
|
|
1243
|
+
return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
|
|
1244
|
+
? jsonResult(outcome)
|
|
1245
|
+
: mutationResult(outcome.project);
|
|
813
1246
|
}));
|
|
814
1247
|
server.registerTool("move_todo", {
|
|
815
|
-
description: "Move
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
1248
|
+
description: "Move one or more to-dos as an ordered block (spec §4). MOVE changes WHAT a to-do " +
|
|
1249
|
+
"belongs to (membership somewhere); to REARRANGE to-dos that already share a container " +
|
|
1250
|
+
"without changing membership, call this with a position (first/last/before/after) and " +
|
|
1251
|
+
"NO destination — that is an in-place reorder, anchored at the earliest movee's slot, " +
|
|
1252
|
+
"and unmentioned siblings keep their order. The uuids order is the order they land " +
|
|
1253
|
+
"(name them backwards to reverse). Pass at most one destination: to_project, to_heading " +
|
|
1254
|
+
"(within to_project, or the movees' shared project), to_area, no_heading (leave the " +
|
|
1255
|
+
"heading, stay in the project), loose (leave heading, project, AND area), or to_inbox. " +
|
|
1256
|
+
"An anchor (before/after) positions but never migrates — an anchor-only move that would " +
|
|
1257
|
+
"cross containers is refused, as is a --before/--after whose movees span buckets. " +
|
|
1258
|
+
"Membership always succeeds; top-of-bucket placement is guaranteed only where a reorder " +
|
|
1259
|
+
"protocol exists (the result's placementClass states which). Moving into a " +
|
|
1260
|
+
"completed/canceled project reopens it — pass acknowledge_project_reopen.",
|
|
820
1261
|
inputSchema: {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1262
|
+
uuids: z.array(z.string()).describe("The to-dos to move, in the order they should land"),
|
|
1263
|
+
to_project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
|
|
1264
|
+
to_heading: z
|
|
1265
|
+
.string()
|
|
1266
|
+
.optional()
|
|
1267
|
+
.describe("Destination heading (exact title or uuid; within to_project or the shared project)"),
|
|
1268
|
+
to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
1269
|
+
no_heading: z
|
|
827
1270
|
.boolean()
|
|
828
1271
|
.optional()
|
|
829
|
-
.describe("
|
|
1272
|
+
.describe("Leave the heading but stay in the current project (unheaded block)"),
|
|
1273
|
+
loose: z
|
|
1274
|
+
.boolean()
|
|
1275
|
+
.optional()
|
|
1276
|
+
.describe("Detach from heading, project, AND area, keeping the schedule"),
|
|
1277
|
+
to_inbox: z.boolean().optional().describe("Move back to the Inbox (removes any schedule)"),
|
|
1278
|
+
...positionShape,
|
|
830
1279
|
acknowledge_project_reopen: z
|
|
831
1280
|
.boolean()
|
|
832
1281
|
.optional()
|
|
@@ -835,21 +1284,37 @@ export function createThingsMcpServer(options = {}) {
|
|
|
835
1284
|
},
|
|
836
1285
|
annotations: NON_DESTRUCTIVE,
|
|
837
1286
|
}, async (args) => guard(async () => {
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1287
|
+
const dests = [];
|
|
1288
|
+
if (args.to_heading !== undefined) {
|
|
1289
|
+
dests.push({
|
|
1290
|
+
kind: "heading",
|
|
1291
|
+
sel: args.to_heading,
|
|
1292
|
+
...(args.to_project !== undefined && { project: containerRef(args.to_project) }),
|
|
1293
|
+
});
|
|
842
1294
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1295
|
+
else if (args.to_project !== undefined) {
|
|
1296
|
+
dests.push({ kind: "project", ref: containerRef(args.to_project) });
|
|
1297
|
+
}
|
|
1298
|
+
if (args.to_area !== undefined)
|
|
1299
|
+
dests.push({ kind: "area", ref: containerRef(args.to_area) });
|
|
1300
|
+
if (args.no_heading === true)
|
|
1301
|
+
dests.push({ kind: "no-heading" });
|
|
1302
|
+
if (args.loose === true)
|
|
1303
|
+
dests.push({ kind: "loose" });
|
|
1304
|
+
if (args.to_inbox === true)
|
|
1305
|
+
dests.push({ kind: "inbox" });
|
|
1306
|
+
if (dests.length > 1) {
|
|
1307
|
+
return usage("pass at most one destination (to_project/to_heading/to_area/no_heading/loose/to_inbox)");
|
|
1308
|
+
}
|
|
1309
|
+
const position = movePositionArgs(args);
|
|
1310
|
+
if (position === "conflict")
|
|
1311
|
+
return usage("pass at most one of first/last/before/after");
|
|
1312
|
+
const request = {
|
|
1313
|
+
uuids: args.uuids,
|
|
1314
|
+
...(dests[0] !== undefined && { destination: dests[0] }),
|
|
1315
|
+
...(position !== undefined && { position }),
|
|
1316
|
+
};
|
|
1317
|
+
return moveResult(await getClient().write.moveTodos(request, writeOptions(args)));
|
|
853
1318
|
}));
|
|
854
1319
|
server.registerTool("set_tags", {
|
|
855
1320
|
description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
|
|
@@ -861,6 +1326,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
861
1326
|
mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
|
|
862
1327
|
...createTagsShape,
|
|
863
1328
|
...dryRunShape,
|
|
1329
|
+
...opIdShape,
|
|
864
1330
|
},
|
|
865
1331
|
annotations: NON_DESTRUCTIVE,
|
|
866
1332
|
}, async (args) => guard(async () => {
|
|
@@ -965,23 +1431,56 @@ export function createThingsMcpServer(options = {}) {
|
|
|
965
1431
|
return mutationResult(await c.write.editChecklist(args.uuid, edit, writeOptions(args)));
|
|
966
1432
|
}));
|
|
967
1433
|
// ------------------------------------------------- to-dos AND projects
|
|
968
|
-
server.registerTool("
|
|
969
|
-
description: "
|
|
970
|
-
"
|
|
971
|
-
"
|
|
972
|
-
|
|
1434
|
+
server.registerTool("delete", {
|
|
1435
|
+
description: "Delete a to-do/project (kind item), an area, or a tag — kind selects which. " +
|
|
1436
|
+
"kind item: moves a to-do or project to the Trash (recoverable via restore_item until " +
|
|
1437
|
+
"the Trash is emptied; a deleted project takes its to-dos with it; not available for " +
|
|
1438
|
+
"repeating to-dos). kind area: PERMANENT — areas do not go to the Trash, so this cannot " +
|
|
1439
|
+
"be undone and requires dangerously_permanent; deleting an area moves its to-dos and " +
|
|
1440
|
+
"projects to the Trash, so a non-empty area is refused unless you pass " +
|
|
1441
|
+
"allow_non_empty_area (empty it first to keep its contents). kind tag: PERMANENT — requires " +
|
|
1442
|
+
"dangerously_permanent; the tag is removed from every item, and if it has nested child " +
|
|
1443
|
+
"tags they are ALL permanently deleted with it — pass acknowledge_tag_subtree to confirm.",
|
|
1444
|
+
inputSchema: {
|
|
1445
|
+
kind: z.enum(["item", "area", "tag"]),
|
|
1446
|
+
uuid: z
|
|
1447
|
+
.string()
|
|
1448
|
+
.describe("The target — an item by uuid; an area or tag also accepts a unique name"),
|
|
1449
|
+
dangerously_permanent: z
|
|
1450
|
+
.boolean()
|
|
1451
|
+
.optional()
|
|
1452
|
+
.describe("kind area/tag: confirm permanent, unrecoverable deletion"),
|
|
1453
|
+
allow_non_empty_area: z
|
|
1454
|
+
.boolean()
|
|
1455
|
+
.optional()
|
|
1456
|
+
.describe("kind area: delete a non-empty area together with its contents (its to-dos and projects move to the Trash)"),
|
|
1457
|
+
acknowledge_tag_subtree: z
|
|
1458
|
+
.boolean()
|
|
1459
|
+
.optional()
|
|
1460
|
+
.describe("kind tag: confirm permanent deletion of ALL nested child tags too"),
|
|
1461
|
+
...dryRunShape,
|
|
1462
|
+
...opIdShape,
|
|
1463
|
+
},
|
|
973
1464
|
annotations: DESTRUCTIVE,
|
|
974
1465
|
}, async (args) => guard(async () => {
|
|
975
1466
|
const c = getClient();
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
:
|
|
1467
|
+
const opts = writeOptions(args);
|
|
1468
|
+
switch (args.kind) {
|
|
1469
|
+
case "item":
|
|
1470
|
+
return mutationResult(itemType(args.uuid) === "to-do"
|
|
1471
|
+
? await c.write.deleteTodo(args.uuid, opts)
|
|
1472
|
+
: await c.write.deleteProject(args.uuid, opts));
|
|
1473
|
+
case "area":
|
|
1474
|
+
return mutationResult(await c.write.deleteArea(args.uuid, opts));
|
|
1475
|
+
case "tag":
|
|
1476
|
+
return mutationResult(await c.write.deleteTag(args.uuid, opts));
|
|
1477
|
+
}
|
|
979
1478
|
}));
|
|
980
1479
|
server.registerTool("restore_item", {
|
|
981
1480
|
description: "Restore a trashed to-do or project. A to-do returns to the Inbox without its " +
|
|
982
1481
|
"previous schedule or project/area. A project is restored in place: its schedule, " +
|
|
983
1482
|
"area, and children come back exactly as they were.",
|
|
984
|
-
inputSchema: { uuid: z.string(), ...dryRunShape },
|
|
1483
|
+
inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
|
|
985
1484
|
annotations: NON_DESTRUCTIVE,
|
|
986
1485
|
}, async (args) => guard(async () => {
|
|
987
1486
|
const c = getClient();
|
|
@@ -998,6 +1497,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
998
1497
|
completion_date: z.string().optional().describe(DATE_FORMAT),
|
|
999
1498
|
creation_date: z.string().optional().describe(DATE_FORMAT),
|
|
1000
1499
|
...dryRunShape,
|
|
1500
|
+
...opIdShape,
|
|
1001
1501
|
},
|
|
1002
1502
|
annotations: NON_DESTRUCTIVE,
|
|
1003
1503
|
}, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
|
|
@@ -1014,6 +1514,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1014
1514
|
creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
|
|
1015
1515
|
notes: z.string().optional(),
|
|
1016
1516
|
...dryRunShape,
|
|
1517
|
+
...opIdShape,
|
|
1017
1518
|
},
|
|
1018
1519
|
annotations: NON_DESTRUCTIVE,
|
|
1019
1520
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
|
|
@@ -1022,78 +1523,192 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1022
1523
|
...(args.creation_date !== undefined && { creationDate: args.creation_date }),
|
|
1023
1524
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
1024
1525
|
}, writeOptions(args)))));
|
|
1025
|
-
server.registerTool("
|
|
1026
|
-
description: "
|
|
1027
|
-
"
|
|
1028
|
-
"
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
"
|
|
1039
|
-
"
|
|
1040
|
-
"
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
annotations: NON_DESTRUCTIVE,
|
|
1048
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.renameHeading(args.uuid, args.title, writeOptions(args)))));
|
|
1049
|
-
server.registerTool("archive_heading", {
|
|
1050
|
-
description: "Archive a heading — it leaves the active project view (reversible with " +
|
|
1051
|
-
"unarchive_heading). The preferred way to retire a heading: row deletion only " +
|
|
1052
|
-
"exists in the app's UI / Shortcuts behind a per-run consent dialog. With open " +
|
|
1053
|
-
"children the children policy is required: complete or cancel resolve them with " +
|
|
1054
|
-
"the heading in one cascade; reparent moves them to the project root first, " +
|
|
1055
|
-
"keeping them open (a compound sequence that undo reverses as one unit).",
|
|
1526
|
+
server.registerTool("heading", {
|
|
1527
|
+
description: "Manage a project's headings — action selects which; project is always required, and a " +
|
|
1528
|
+
"heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
|
|
1529
|
+
"new heading in the project (project + title; returns its uuid; uses the Things proxy " +
|
|
1530
|
+
"shortcuts, set up once with `things setup shortcuts`); a placement flag positions it, " +
|
|
1531
|
+
"else it appends. rename_heading: rename in place (project + heading + title; works on " +
|
|
1532
|
+
"archived headings). archive_heading: retire a heading so it leaves the active project " +
|
|
1533
|
+
"view (reversible with unarchive_heading); with open children pass children — complete " +
|
|
1534
|
+
"or cancel resolve them with the heading in one cascade, reparent moves them to the " +
|
|
1535
|
+
"project root keeping them open. unarchive_heading: bring an archived heading back; " +
|
|
1536
|
+
"restore_children also reopens the children the archive resolved with it. " +
|
|
1537
|
+
"promote_heading: promote a heading into a new project — this REPLACES the heading and " +
|
|
1538
|
+
"cannot be undone (its to-dos move under the new project), and requires " +
|
|
1539
|
+
"dangerously_drive_gui. move_heading: reposition headings as an ordered block (children " +
|
|
1540
|
+
"follow); pass exactly one placement flag. Reordering headings needs allow-experimental. " +
|
|
1541
|
+
"move_heading_to_project: relocate ONE heading (with its to-dos) to a DIFFERENT project " +
|
|
1542
|
+
"(project + heading + to_project) — the cross-project move, distinct from move_heading's " +
|
|
1543
|
+
"within-project reorder; GUI-only (requires dangerously_drive_gui), fails closed on a " +
|
|
1544
|
+
"source-heading or destination-project title collision, and has no undo (move it back). " +
|
|
1545
|
+
"dissolve_heading: remove a heading but KEEP its to-dos as direct project children (NOT " +
|
|
1546
|
+
"trashed — the opposite of a delete cascade); GUI-only (requires dangerously_drive_gui), " +
|
|
1547
|
+
"fails closed on a title collision, no undo.",
|
|
1056
1548
|
inputSchema: {
|
|
1057
|
-
|
|
1549
|
+
action: z.enum([
|
|
1550
|
+
"add_heading",
|
|
1551
|
+
"rename_heading",
|
|
1552
|
+
"archive_heading",
|
|
1553
|
+
"unarchive_heading",
|
|
1554
|
+
"promote_heading",
|
|
1555
|
+
"move_heading",
|
|
1556
|
+
"move_heading_to_project",
|
|
1557
|
+
"dissolve_heading",
|
|
1558
|
+
]),
|
|
1559
|
+
project: z.string().describe(`the heading's project (${REF_FORMAT})`),
|
|
1560
|
+
heading: z
|
|
1561
|
+
.string()
|
|
1562
|
+
.optional()
|
|
1563
|
+
.describe("rename/archive/unarchive/promote: the heading selector (exact title or uuid)"),
|
|
1564
|
+
headings: z
|
|
1565
|
+
.array(z.string())
|
|
1566
|
+
.optional()
|
|
1567
|
+
.describe("move_heading: heading selectors in the order they should land"),
|
|
1568
|
+
to_project: z
|
|
1569
|
+
.string()
|
|
1570
|
+
.optional()
|
|
1571
|
+
.describe(`move_heading_to_project: the destination project (${REF_FORMAT})`),
|
|
1572
|
+
title: z
|
|
1573
|
+
.string()
|
|
1574
|
+
.optional()
|
|
1575
|
+
.describe("add_heading: the new heading; rename_heading: the new title"),
|
|
1058
1576
|
children: z
|
|
1059
1577
|
.enum(["complete", "cancel", "reparent"])
|
|
1060
1578
|
.optional()
|
|
1061
|
-
.describe("
|
|
1579
|
+
.describe("archive_heading: required when the heading has open children"),
|
|
1580
|
+
restore_children: z
|
|
1581
|
+
.boolean()
|
|
1582
|
+
.optional()
|
|
1583
|
+
.describe("unarchive_heading: also reopen the children archived with the heading"),
|
|
1584
|
+
first: z
|
|
1585
|
+
.boolean()
|
|
1586
|
+
.optional()
|
|
1587
|
+
.describe("add/move: place first among the project's headings"),
|
|
1588
|
+
last: z.boolean().optional().describe("add/move: place last among the project's headings"),
|
|
1589
|
+
before_heading: z
|
|
1590
|
+
.string()
|
|
1591
|
+
.optional()
|
|
1592
|
+
.describe("add/move: place immediately before this heading (title or uuid)"),
|
|
1593
|
+
after_heading: z
|
|
1594
|
+
.string()
|
|
1595
|
+
.optional()
|
|
1596
|
+
.describe("add/move: place immediately after this heading (title or uuid)"),
|
|
1597
|
+
...driveGuiShape,
|
|
1062
1598
|
...dryRunShape,
|
|
1063
1599
|
},
|
|
1064
|
-
annotations:
|
|
1600
|
+
annotations: DESTRUCTIVE,
|
|
1065
1601
|
}, async (args) => guard(async () => {
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1602
|
+
const c = getClient();
|
|
1603
|
+
const opts = writeOptions(args);
|
|
1604
|
+
const proj = c.resolve.project(args.project);
|
|
1605
|
+
const placementCount = [
|
|
1606
|
+
args.first === true,
|
|
1607
|
+
args.last === true,
|
|
1608
|
+
args.before_heading !== undefined,
|
|
1609
|
+
args.after_heading !== undefined,
|
|
1610
|
+
].filter(Boolean).length;
|
|
1611
|
+
const placement = () => {
|
|
1612
|
+
if (args.first === true)
|
|
1613
|
+
return { position: "first" };
|
|
1614
|
+
if (args.last === true)
|
|
1615
|
+
return { position: "last" };
|
|
1616
|
+
if (args.before_heading !== undefined) {
|
|
1617
|
+
return { before: c.resolve.heading(proj.uuid, args.before_heading).uuid };
|
|
1618
|
+
}
|
|
1619
|
+
if (args.after_heading !== undefined) {
|
|
1620
|
+
return { after: c.resolve.heading(proj.uuid, args.after_heading).uuid };
|
|
1621
|
+
}
|
|
1622
|
+
return undefined;
|
|
1623
|
+
};
|
|
1624
|
+
switch (args.action) {
|
|
1625
|
+
case "add_heading": {
|
|
1626
|
+
if (args.title === undefined)
|
|
1627
|
+
return usage('action "add_heading" requires title');
|
|
1628
|
+
if (placementCount > 1) {
|
|
1629
|
+
return usage("pass at most one of first/last/before_heading/after_heading");
|
|
1630
|
+
}
|
|
1631
|
+
return mutationResult(await c.write.addHeading({ uuid: proj.uuid }, args.title, placement(), opts));
|
|
1632
|
+
}
|
|
1633
|
+
case "rename_heading": {
|
|
1634
|
+
if (args.heading === undefined || args.title === undefined) {
|
|
1635
|
+
return usage('action "rename_heading" requires heading and title');
|
|
1636
|
+
}
|
|
1637
|
+
const h = c.resolve.heading(proj.uuid, args.heading);
|
|
1638
|
+
return mutationResult(await c.write.renameHeading(h.uuid, args.title, opts));
|
|
1639
|
+
}
|
|
1640
|
+
case "archive_heading": {
|
|
1641
|
+
if (args.heading === undefined)
|
|
1642
|
+
return usage('action "archive_heading" requires heading');
|
|
1643
|
+
const h = c.resolve.heading(proj.uuid, args.heading);
|
|
1644
|
+
const r = await c.write.archiveHeading(h.uuid, args.children !== undefined ? { children: args.children } : {}, opts);
|
|
1645
|
+
return r.heading.kind === "ok" || r.heading.kind === "dry-run"
|
|
1646
|
+
? jsonResult(r)
|
|
1647
|
+
: mutationResult(r.heading);
|
|
1648
|
+
}
|
|
1649
|
+
case "unarchive_heading": {
|
|
1650
|
+
if (args.heading === undefined) {
|
|
1651
|
+
return usage('action "unarchive_heading" requires heading');
|
|
1652
|
+
}
|
|
1653
|
+
const h = c.resolve.heading(proj.uuid, args.heading);
|
|
1654
|
+
const r = await c.write.unarchiveHeading(h.uuid, args.restore_children === true ? { restoreChildren: true } : {}, opts);
|
|
1655
|
+
return r.heading.kind === "ok" || r.heading.kind === "dry-run"
|
|
1656
|
+
? jsonResult(r)
|
|
1657
|
+
: mutationResult(r.heading);
|
|
1658
|
+
}
|
|
1659
|
+
case "promote_heading": {
|
|
1660
|
+
if (args.heading === undefined)
|
|
1661
|
+
return usage('action "promote_heading" requires heading');
|
|
1662
|
+
const h = c.resolve.heading(proj.uuid, args.heading);
|
|
1663
|
+
return mutationResult(await c.write.run("project.promote-heading", { uuid: h.uuid }, opts));
|
|
1664
|
+
}
|
|
1665
|
+
case "move_heading": {
|
|
1666
|
+
if (args.headings === undefined || args.headings.length === 0) {
|
|
1667
|
+
return usage('action "move_heading" requires headings');
|
|
1668
|
+
}
|
|
1669
|
+
if (placementCount !== 1) {
|
|
1670
|
+
return usage("move_heading requires exactly one of first/last/before_heading/after_heading");
|
|
1671
|
+
}
|
|
1672
|
+
const headings = args.headings.map((s) => c.resolve.heading(proj.uuid, s).uuid);
|
|
1673
|
+
return mutationResult(await c.write.moveHeading({ uuid: proj.uuid }, headings, placement(), opts));
|
|
1674
|
+
}
|
|
1675
|
+
case "move_heading_to_project": {
|
|
1676
|
+
if (args.heading === undefined || args.to_project === undefined) {
|
|
1677
|
+
return usage('action "move_heading_to_project" requires heading and to_project');
|
|
1678
|
+
}
|
|
1679
|
+
const dest = c.resolve.project(args.to_project);
|
|
1680
|
+
return mutationResult(await c.write.moveHeadingToProject({ uuid: proj.uuid }, args.heading, { uuid: dest.uuid }, opts));
|
|
1681
|
+
}
|
|
1682
|
+
case "dissolve_heading": {
|
|
1683
|
+
if (args.heading === undefined)
|
|
1684
|
+
return usage('action "dissolve_heading" requires heading');
|
|
1685
|
+
const h = c.resolve.heading(proj.uuid, args.heading);
|
|
1686
|
+
return mutationResult(await c.write.dissolveHeading(h.uuid, opts));
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1070
1689
|
}));
|
|
1071
|
-
server.registerTool("
|
|
1072
|
-
description: "
|
|
1073
|
-
"
|
|
1074
|
-
"
|
|
1690
|
+
server.registerTool("convert_to_project", {
|
|
1691
|
+
description: "Promote a to-do into a project. This REPLACES the to-do with a new project (its notes " +
|
|
1692
|
+
"are kept); the to-do's identity is gone and it cannot be undone. Requires " +
|
|
1693
|
+
"dangerously_drive_gui. The new project's uuid is on the result. (To promote a HEADING, " +
|
|
1694
|
+
"use the heading tool's promote_heading action.)",
|
|
1075
1695
|
inputSchema: {
|
|
1076
|
-
uuid: z.string(),
|
|
1077
|
-
|
|
1696
|
+
uuid: z.string().describe("the to-do's uuid"),
|
|
1697
|
+
...driveGuiShape,
|
|
1078
1698
|
...dryRunShape,
|
|
1699
|
+
...opIdShape,
|
|
1079
1700
|
},
|
|
1701
|
+
annotations: DESTRUCTIVE,
|
|
1702
|
+
}, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.convert-to-project", { uuid: args.uuid }, writeOptions(args)))));
|
|
1703
|
+
server.registerTool("clear_reminder", {
|
|
1704
|
+
description: "Clear a to-do's time-of-day reminder while keeping its scheduled date. Uses the " +
|
|
1705
|
+
"Things proxy shortcuts when installed (in place, and the only path for a repeating " +
|
|
1706
|
+
"to-do); otherwise a non-repeating dated to-do falls back to a URL re-schedule that " +
|
|
1707
|
+
"briefly moves it to Today and back. Reversible with the undo tool.",
|
|
1708
|
+
inputSchema: { uuid: z.string(), ...dryRunShape },
|
|
1080
1709
|
annotations: NON_DESTRUCTIVE,
|
|
1081
|
-
}, async (args) => guard(async () =>
|
|
1082
|
-
const r = await getClient().write.unarchiveHeading(args.uuid, args.restore_children === true ? { restoreChildren: true } : {}, writeOptions(args));
|
|
1083
|
-
return r.heading.kind === "ok" || r.heading.kind === "dry-run"
|
|
1084
|
-
? jsonResult(r)
|
|
1085
|
-
: mutationResult(r.heading);
|
|
1086
|
-
}));
|
|
1710
|
+
}, async (args) => guard(async () => mutationResult(await getClient().write.clearReminder(args.uuid, writeOptions(args)))));
|
|
1087
1711
|
// -------------------------------------------- GUI-driven (Accessibility)
|
|
1088
|
-
const driveGuiShape = {
|
|
1089
|
-
dangerously_drive_gui: z
|
|
1090
|
-
.boolean()
|
|
1091
|
-
.optional()
|
|
1092
|
-
.describe("Required: this drives the local Things app through its accessibility interface to " +
|
|
1093
|
-
"make a change the app offers nowhere else. It briefly interacts with the app's UI " +
|
|
1094
|
-
"on the machine running this server, and must be turned on first with `things config " +
|
|
1095
|
-
"set ui-enabled true`. Intended for a dedicated always-on Mac."),
|
|
1096
|
-
};
|
|
1097
1712
|
const WEEKDAY_ENUM = z.enum([
|
|
1098
1713
|
"sunday",
|
|
1099
1714
|
"monday",
|
|
@@ -1103,7 +1718,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1103
1718
|
"friday",
|
|
1104
1719
|
"saturday",
|
|
1105
1720
|
]);
|
|
1106
|
-
// The base rule (also used by
|
|
1721
|
+
// The base rule (also used by the repeat tool's create action, which stays minimal —
|
|
1107
1722
|
// its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
|
|
1108
1723
|
const baseRepeatShape = {
|
|
1109
1724
|
frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
|
|
@@ -1171,182 +1786,114 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1171
1786
|
fields.startDaysEarlier = a.start_days_earlier;
|
|
1172
1787
|
return fields;
|
|
1173
1788
|
};
|
|
1174
|
-
server.registerTool("
|
|
1175
|
-
description: "
|
|
1176
|
-
"
|
|
1177
|
-
"
|
|
1178
|
-
"monthly/yearly day, end bound,
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
uuid: args.uuid,
|
|
1188
|
-
frequency: args.frequency,
|
|
1189
|
-
interval: args.interval,
|
|
1190
|
-
...repeatExtras(args, args.frequency),
|
|
1191
|
-
}, writeOptions(args)))));
|
|
1192
|
-
server.registerTool("reschedule_repeat", {
|
|
1193
|
-
description: "Change a repeating to-do's rule in place, keeping the same item. Set the frequency and " +
|
|
1194
|
-
"interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, or " +
|
|
1195
|
-
"deadline. This can be undone — it restores the previous rule.",
|
|
1789
|
+
server.registerTool("repeat", {
|
|
1790
|
+
description: "Manage recurrence on a to-do or project (scope) by driving the local Things app's " +
|
|
1791
|
+
"interface — every action needs dangerously_drive_gui. action start: turn a plain item " +
|
|
1792
|
+
"into a repeating one — this REPLACES it with a new series and cannot be undone (give " +
|
|
1793
|
+
"frequency + interval and optionally the weekday set, monthly/yearly day, end bound, " +
|
|
1794
|
+
"reminder, or per-occurrence deadline); returns a repeating block with instanceUuid (the " +
|
|
1795
|
+
"visible occurrence), templateUuid (the rule), and replacedUuid. action reschedule: " +
|
|
1796
|
+
"change a repeating item's rule in place, keeping the same item (undoable — it restores " +
|
|
1797
|
+
"the previous rule). action pause/resume: stop or restart its new occurrences, keeping " +
|
|
1798
|
+
"the rule. action add (scope project only): create a project and make it repeating in " +
|
|
1799
|
+
"one call — the project is created first and PERSISTS even if the make-repeating step " +
|
|
1800
|
+
"refuses; give an area to place it or omit it to create in Someday (only frequency and " +
|
|
1801
|
+
"interval are supported); returns the new project's uuid.",
|
|
1196
1802
|
inputSchema: {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
...dryRunShape,
|
|
1201
|
-
},
|
|
1202
|
-
annotations: NON_DESTRUCTIVE,
|
|
1203
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.reschedule-repeat", {
|
|
1204
|
-
uuid: args.uuid,
|
|
1205
|
-
frequency: args.frequency,
|
|
1206
|
-
interval: args.interval,
|
|
1207
|
-
...repeatExtras(args, args.frequency),
|
|
1208
|
-
}, writeOptions(args)))));
|
|
1209
|
-
server.registerTool("set_repeat_state", {
|
|
1210
|
-
description: "Pause or resume a repeating to-do. 'pause' stops it spawning new occurrences but keeps " +
|
|
1211
|
-
"its rule; 'resume' starts it again. The two are inverses of each other.",
|
|
1212
|
-
inputSchema: {
|
|
1213
|
-
uuid: z.string().describe("The repeating to-do"),
|
|
1214
|
-
state: z.enum(["pause", "resume"]),
|
|
1215
|
-
...driveGuiShape,
|
|
1216
|
-
...dryRunShape,
|
|
1217
|
-
},
|
|
1218
|
-
annotations: NON_DESTRUCTIVE,
|
|
1219
|
-
}, async (args) => guard(async () => {
|
|
1220
|
-
const op = args.state === "pause" ? "todo.pause-repeat" : "todo.resume-repeat";
|
|
1221
|
-
return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
|
|
1222
|
-
}));
|
|
1223
|
-
server.registerTool("reschedule_project_repeat", {
|
|
1224
|
-
description: "Change a repeating project's rule in place, keeping the same project. Set the frequency " +
|
|
1225
|
-
"and interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, " +
|
|
1226
|
-
"or deadline. This can be undone — it restores the previous rule.",
|
|
1227
|
-
inputSchema: {
|
|
1228
|
-
uuid: z.string().describe(`The repeating project to reschedule (${REF_FORMAT})`),
|
|
1229
|
-
...repeatRuleShape,
|
|
1230
|
-
...driveGuiShape,
|
|
1231
|
-
...dryRunShape,
|
|
1232
|
-
},
|
|
1233
|
-
annotations: NON_DESTRUCTIVE,
|
|
1234
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.run("project.reschedule-repeat", {
|
|
1235
|
-
uuid: args.uuid,
|
|
1236
|
-
frequency: args.frequency,
|
|
1237
|
-
interval: args.interval,
|
|
1238
|
-
...repeatExtras(args, args.frequency),
|
|
1239
|
-
}, writeOptions(args)))));
|
|
1240
|
-
server.registerTool("set_project_repeat_state", {
|
|
1241
|
-
description: "Pause or resume a repeating project. 'pause' stops it spawning new occurrences but keeps " +
|
|
1242
|
-
"its rule; 'resume' starts it again. The two are inverses of each other.",
|
|
1243
|
-
inputSchema: {
|
|
1244
|
-
uuid: z.string().describe(`The repeating project (${REF_FORMAT})`),
|
|
1245
|
-
state: z.enum(["pause", "resume"]),
|
|
1246
|
-
...driveGuiShape,
|
|
1247
|
-
...dryRunShape,
|
|
1248
|
-
},
|
|
1249
|
-
annotations: NON_DESTRUCTIVE,
|
|
1250
|
-
}, async (args) => guard(async () => {
|
|
1251
|
-
const op = args.state === "pause" ? "project.pause-repeat" : "project.resume-repeat";
|
|
1252
|
-
return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
|
|
1253
|
-
}));
|
|
1254
|
-
server.registerTool("reorder_area", {
|
|
1255
|
-
description: "Move an area to a new position in the area order. Give the area plus exactly one " +
|
|
1256
|
-
"destination: before/after another area, or position first/last. The move is made by " +
|
|
1257
|
-
"driving the Things window with the pointer — the app comes to the front and the " +
|
|
1258
|
-
"sidebar may scroll while the area is dragged; the area's projects and to-dos are " +
|
|
1259
|
-
"untouched. Area references are a uuid or a unique name.",
|
|
1260
|
-
inputSchema: {
|
|
1261
|
-
target: z.string().describe("The area to move (uuid or unique name)"),
|
|
1262
|
-
before: z
|
|
1803
|
+
scope: z.enum(["todo", "project"]),
|
|
1804
|
+
action: z.enum(["start", "reschedule", "pause", "resume", "add"]),
|
|
1805
|
+
uuid: z
|
|
1263
1806
|
.string()
|
|
1264
1807
|
.optional()
|
|
1265
|
-
.describe("
|
|
1266
|
-
|
|
1808
|
+
.describe("start/reschedule/pause/resume: the item (a project also accepts a unique name)"),
|
|
1809
|
+
title: z.string().optional().describe("add (project): the new project's title"),
|
|
1810
|
+
notes: z.string().optional().describe("add (project): notes"),
|
|
1811
|
+
area: z.string().optional().describe(`add (project): destination area (${REF_FORMAT})`),
|
|
1812
|
+
project_deadline: z
|
|
1267
1813
|
.string()
|
|
1268
1814
|
.optional()
|
|
1269
|
-
.describe(
|
|
1270
|
-
|
|
1271
|
-
|
|
1815
|
+
.describe(`add (project): the project's due date — ${DATE_FORMAT}`),
|
|
1816
|
+
todos: z.array(z.string()).optional().describe("add (project): initial child to-do titles"),
|
|
1817
|
+
frequency: z
|
|
1818
|
+
.enum(["daily", "weekly", "monthly", "yearly"])
|
|
1272
1819
|
.optional()
|
|
1273
|
-
.describe("
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
uuid: z.string().describe(`The project to make repeating (${REF_FORMAT})`),
|
|
1293
|
-
...repeatRuleShape,
|
|
1294
|
-
...driveGuiShape,
|
|
1295
|
-
...dryRunShape,
|
|
1296
|
-
},
|
|
1297
|
-
annotations: DESTRUCTIVE,
|
|
1298
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.makeRepeatingProject(args.uuid, {
|
|
1299
|
-
frequency: args.frequency,
|
|
1300
|
-
interval: args.interval,
|
|
1301
|
-
...repeatExtras(args, args.frequency),
|
|
1302
|
-
}, writeOptions(args)))));
|
|
1303
|
-
server.registerTool("create_repeating_project", {
|
|
1304
|
-
description: "Create a project and make it repeating in one call. TWO operations: the project is " +
|
|
1305
|
-
"created first and PERSISTS even if the make-repeating step refuses; then it is promoted " +
|
|
1306
|
-
"(which drives the GUI). Give an area to place it, or omit it to create in Someday. Only a " +
|
|
1307
|
-
"frequency and an interval are supported. Returns the new repeating project's uuid.",
|
|
1308
|
-
inputSchema: {
|
|
1309
|
-
title: z.string(),
|
|
1310
|
-
notes: z.string().optional(),
|
|
1311
|
-
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
1312
|
-
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
1313
|
-
todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
|
|
1314
|
-
...baseRepeatShape,
|
|
1315
|
-
...driveGuiShape,
|
|
1316
|
-
...dryRunShape,
|
|
1317
|
-
},
|
|
1318
|
-
annotations: DESTRUCTIVE,
|
|
1319
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.createRepeatingProject({
|
|
1320
|
-
title: args.title,
|
|
1321
|
-
...(args.notes !== undefined && { notes: args.notes }),
|
|
1322
|
-
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
1323
|
-
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
1324
|
-
...(args.todos !== undefined && { todos: args.todos }),
|
|
1325
|
-
frequency: args.frequency,
|
|
1326
|
-
interval: args.interval,
|
|
1327
|
-
}, writeOptions(args)))));
|
|
1328
|
-
server.registerTool("convert_to_project", {
|
|
1329
|
-
description: "Convert a to-do or a heading into a project. This REPLACES the original with a new " +
|
|
1330
|
-
"project (a converted to-do keeps its notes; a converted heading is promoted alongside " +
|
|
1331
|
-
"its project and its to-dos move under the new project). The original is gone and this " +
|
|
1332
|
-
"cannot be undone. Returns the new project's uuid.",
|
|
1333
|
-
inputSchema: {
|
|
1334
|
-
uuid: z.string().describe("The to-do or heading to convert"),
|
|
1820
|
+
.describe("start/reschedule/add: how often it repeats"),
|
|
1821
|
+
interval: z
|
|
1822
|
+
.number()
|
|
1823
|
+
.int()
|
|
1824
|
+
.min(1)
|
|
1825
|
+
.max(99)
|
|
1826
|
+
.optional()
|
|
1827
|
+
.describe("start/reschedule/add: every N units (1–99)"),
|
|
1828
|
+
after_completion: repeatRuleShape.after_completion,
|
|
1829
|
+
weekdays: repeatRuleShape.weekdays,
|
|
1830
|
+
monthly_day: repeatRuleShape.monthly_day,
|
|
1831
|
+
monthly_weekday: repeatRuleShape.monthly_weekday,
|
|
1832
|
+
monthly_ordinal: repeatRuleShape.monthly_ordinal,
|
|
1833
|
+
yearly_month: repeatRuleShape.yearly_month,
|
|
1834
|
+
ends_after: repeatRuleShape.ends_after,
|
|
1835
|
+
ends_on: repeatRuleShape.ends_on,
|
|
1836
|
+
reminder: repeatRuleShape.reminder,
|
|
1837
|
+
deadline: repeatRuleShape.deadline,
|
|
1838
|
+
start_days_earlier: repeatRuleShape.start_days_earlier,
|
|
1335
1839
|
...driveGuiShape,
|
|
1336
1840
|
...dryRunShape,
|
|
1337
1841
|
},
|
|
1338
1842
|
annotations: DESTRUCTIVE,
|
|
1339
1843
|
}, async (args) => guard(async () => {
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1844
|
+
const c = getClient();
|
|
1845
|
+
const opts = writeOptions(args);
|
|
1846
|
+
const { frequency, interval } = args;
|
|
1847
|
+
if (args.action === "add") {
|
|
1848
|
+
if (args.scope !== "project")
|
|
1849
|
+
return usage('action "add" requires scope "project"');
|
|
1850
|
+
if (args.title === undefined)
|
|
1851
|
+
return usage('action "add" requires title');
|
|
1852
|
+
if (frequency === undefined || interval === undefined) {
|
|
1853
|
+
return usage('action "add" requires frequency and interval');
|
|
1854
|
+
}
|
|
1855
|
+
return mutationResult(await c.write.addRepeatingProject({
|
|
1856
|
+
title: args.title,
|
|
1857
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
1858
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
1859
|
+
...(args.project_deadline !== undefined && { deadline: args.project_deadline }),
|
|
1860
|
+
...(args.todos !== undefined && { todos: args.todos }),
|
|
1861
|
+
frequency,
|
|
1862
|
+
interval,
|
|
1863
|
+
}, opts));
|
|
1864
|
+
}
|
|
1865
|
+
if (args.uuid === undefined)
|
|
1866
|
+
return usage(`action "${args.action}" requires uuid`);
|
|
1867
|
+
if (args.action === "pause" || args.action === "resume") {
|
|
1868
|
+
const op = args.scope === "todo"
|
|
1869
|
+
? args.action === "pause"
|
|
1870
|
+
? "todo.pause-repeat"
|
|
1871
|
+
: "todo.resume-repeat"
|
|
1872
|
+
: args.action === "pause"
|
|
1873
|
+
? "project.pause-repeat"
|
|
1874
|
+
: "project.resume-repeat";
|
|
1875
|
+
return mutationResult(await c.write.run(op, { uuid: args.uuid }, opts));
|
|
1876
|
+
}
|
|
1877
|
+
// start | reschedule
|
|
1878
|
+
if (frequency === undefined || interval === undefined) {
|
|
1879
|
+
return usage(`action "${args.action}" requires frequency and interval`);
|
|
1880
|
+
}
|
|
1881
|
+
const extras = repeatExtras(args, frequency);
|
|
1882
|
+
if (args.scope === "todo") {
|
|
1883
|
+
const op = args.action === "start" ? "todo.make-repeating" : "todo.reschedule-repeat";
|
|
1884
|
+
return mutationResult(await c.write.run(op, { uuid: args.uuid, frequency, interval, ...extras }, opts));
|
|
1885
|
+
}
|
|
1886
|
+
// scope project
|
|
1887
|
+
if (args.action === "reschedule") {
|
|
1888
|
+
return mutationResult(await c.write.run("project.reschedule-repeat", { uuid: args.uuid, frequency, interval, ...extras }, opts));
|
|
1889
|
+
}
|
|
1890
|
+
// scope project, action start → the dedicated make-repeating method
|
|
1891
|
+
return mutationResult(await c.write.makeRepeatingProject(args.uuid, { frequency, interval, ...extras }, opts));
|
|
1345
1892
|
}));
|
|
1346
1893
|
server.registerTool("duplicate_item", {
|
|
1347
1894
|
description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
|
|
1348
1895
|
"includes its children. Not available for repeating items.",
|
|
1349
|
-
inputSchema: { uuid: z.string(), ...dryRunShape },
|
|
1896
|
+
inputSchema: { uuid: z.string(), ...dryRunShape, ...opIdShape },
|
|
1350
1897
|
annotations: NON_DESTRUCTIVE,
|
|
1351
1898
|
}, async (args) => guard(async () => {
|
|
1352
1899
|
const c = getClient();
|
|
@@ -1365,127 +1912,56 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1365
1912
|
when: whenSchema,
|
|
1366
1913
|
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
1367
1914
|
todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
|
|
1915
|
+
...tzShape,
|
|
1368
1916
|
...dryRunShape,
|
|
1369
|
-
|
|
1370
|
-
annotations: NON_DESTRUCTIVE,
|
|
1371
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.addProject({
|
|
1372
|
-
title: args.title,
|
|
1373
|
-
...(args.notes !== undefined && { notes: args.notes }),
|
|
1374
|
-
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
1375
|
-
...(args.when !== undefined && { when: args.when }),
|
|
1376
|
-
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
1377
|
-
...(args.todos !== undefined && { todos: args.todos }),
|
|
1378
|
-
}, writeOptions(args)))));
|
|
1379
|
-
server.registerTool("update_project", {
|
|
1380
|
-
description: "Update a project's title, notes, schedule, reminder, or deadline. " +
|
|
1381
|
-
"append_notes/prepend_notes add a line to the existing notes (exclusive with notes). " +
|
|
1382
|
-
"Changing the schedule keeps an existing reminder unless the call sets a new one. " +
|
|
1383
|
-
"clear_reminder works while the project is scheduled for today or this evening; a " +
|
|
1384
|
-
"reminder on a future date can only be changed, not cleared.",
|
|
1385
|
-
inputSchema: {
|
|
1386
|
-
uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
|
|
1387
|
-
title: z.string().optional(),
|
|
1388
|
-
notes: z.string().optional().describe("Replaces the whole notes body"),
|
|
1389
|
-
append_notes: z.string().optional(),
|
|
1390
|
-
prepend_notes: z.string().optional(),
|
|
1391
|
-
when: whenSchema,
|
|
1392
|
-
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
1393
|
-
clear_reminder: z.boolean().optional(),
|
|
1394
|
-
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
1395
|
-
clear_deadline: z.boolean().optional(),
|
|
1396
|
-
...dryRunShape,
|
|
1917
|
+
...opIdShape,
|
|
1397
1918
|
},
|
|
1398
1919
|
annotations: NON_DESTRUCTIVE,
|
|
1399
1920
|
}, async (args) => guard(async () => {
|
|
1400
|
-
const
|
|
1401
|
-
if (
|
|
1402
|
-
return
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
return usage("pass at most one of reminder / clear_reminder");
|
|
1406
|
-
}
|
|
1407
|
-
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
1408
|
-
return usage("pass at most one of deadline / clear_deadline");
|
|
1409
|
-
}
|
|
1410
|
-
return mutationResult(await getClient().write.updateProject(args.uuid, {
|
|
1411
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1921
|
+
const badZone = badTz(args.tz);
|
|
1922
|
+
if (badZone !== null)
|
|
1923
|
+
return badZone;
|
|
1924
|
+
return mutationResult(await getClient().write.addProject({
|
|
1925
|
+
title: args.title,
|
|
1412
1926
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
1413
|
-
...(args.
|
|
1414
|
-
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
1927
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
1415
1928
|
...(args.when !== undefined && { when: args.when }),
|
|
1416
|
-
...(args.reminder !== undefined && { reminder: args.reminder }),
|
|
1417
|
-
...(args.clear_reminder === true && { reminder: null }),
|
|
1418
1929
|
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
1419
|
-
...(args.
|
|
1930
|
+
...(args.todos !== undefined && { todos: args.todos }),
|
|
1420
1931
|
}, writeOptions(args)));
|
|
1421
1932
|
}));
|
|
1422
|
-
server.registerTool("set_project_status", {
|
|
1423
|
-
description: "Complete, cancel, or reopen a project. Completing or canceling requires a " +
|
|
1424
|
-
"children policy: 'require-resolved' errors if open to-dos remain; " +
|
|
1425
|
-
"'auto-complete'/'auto-cancel' resolves them together with the project (canceling " +
|
|
1426
|
-
"never alters already-completed children). status 'open' reopens a completed or " +
|
|
1427
|
-
"canceled project; its children stay completed/canceled unless restore_children " +
|
|
1428
|
-
"also reopens the ones that were resolved together with the project.",
|
|
1429
|
-
inputSchema: {
|
|
1430
|
-
uuid: z.string().describe(`The project (${REF_FORMAT})`),
|
|
1431
|
-
status: z.enum(["completed", "canceled", "open"]),
|
|
1432
|
-
children: z
|
|
1433
|
-
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
1434
|
-
.optional()
|
|
1435
|
-
.describe("Required for completed/canceled: what to do with the project's open to-dos"),
|
|
1436
|
-
restore_children: z
|
|
1437
|
-
.boolean()
|
|
1438
|
-
.optional()
|
|
1439
|
-
.describe("open only: also reopen the to-dos that were resolved with the project"),
|
|
1440
|
-
...dryRunShape,
|
|
1441
|
-
},
|
|
1442
|
-
annotations: NON_DESTRUCTIVE,
|
|
1443
|
-
}, async (args) => guard(async () => {
|
|
1444
|
-
const c = getClient();
|
|
1445
|
-
if (args.status !== "open" && args.restore_children !== undefined) {
|
|
1446
|
-
return usage("restore_children applies only to status 'open'");
|
|
1447
|
-
}
|
|
1448
|
-
if (args.status === "completed") {
|
|
1449
|
-
if (args.children !== "require-resolved" && args.children !== "auto-complete") {
|
|
1450
|
-
return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
|
|
1451
|
-
}
|
|
1452
|
-
return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, writeOptions(args)));
|
|
1453
|
-
}
|
|
1454
|
-
if (args.status === "canceled") {
|
|
1455
|
-
if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
|
|
1456
|
-
return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
|
|
1457
|
-
}
|
|
1458
|
-
return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, writeOptions(args)));
|
|
1459
|
-
}
|
|
1460
|
-
if (args.children !== undefined) {
|
|
1461
|
-
return usage("children applies only to status 'completed' or 'canceled'");
|
|
1462
|
-
}
|
|
1463
|
-
const outcome = await c.write.reopenProject(args.uuid, {
|
|
1464
|
-
...writeOptions(args),
|
|
1465
|
-
...(args.restore_children === true && { restoreChildren: true }),
|
|
1466
|
-
});
|
|
1467
|
-
return outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
|
|
1468
|
-
? jsonResult(outcome)
|
|
1469
|
-
: mutationResult(outcome.project);
|
|
1470
|
-
}));
|
|
1471
1933
|
server.registerTool("move_project", {
|
|
1472
|
-
description: "Move
|
|
1473
|
-
"
|
|
1934
|
+
description: "Move one or more projects as an ordered block (spec §4/§5). Pass at most one " +
|
|
1935
|
+
"destination: to_area, or no_area (leave the area — a project's complete detach). To " +
|
|
1936
|
+
"REORDER projects among their siblings without changing area, pass a position " +
|
|
1937
|
+
"(first/last/before/after) and NO destination. An anchor positions but never migrates. " +
|
|
1938
|
+
"The project's status and schedule are unaffected; the result's placementClass states " +
|
|
1939
|
+
"whether top-of-bucket placement was guaranteed.",
|
|
1474
1940
|
inputSchema: {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1941
|
+
uuids: z.array(z.string()).describe(`The projects to move (${REF_FORMAT}), in order`),
|
|
1942
|
+
to_area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
1943
|
+
no_area: z.boolean().optional().describe("Leave the current area (a project's detach)"),
|
|
1944
|
+
...positionShape,
|
|
1478
1945
|
...dryRunShape,
|
|
1479
1946
|
},
|
|
1480
1947
|
annotations: NON_DESTRUCTIVE,
|
|
1481
1948
|
}, async (args) => guard(async () => {
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1949
|
+
const dests = [];
|
|
1950
|
+
if (args.to_area !== undefined)
|
|
1951
|
+
dests.push({ kind: "area", ref: containerRef(args.to_area) });
|
|
1952
|
+
if (args.no_area === true)
|
|
1953
|
+
dests.push({ kind: "no-area" });
|
|
1954
|
+
if (dests.length > 1)
|
|
1955
|
+
return usage("pass at most one of to_area / no_area");
|
|
1956
|
+
const position = movePositionArgs(args);
|
|
1957
|
+
if (position === "conflict")
|
|
1958
|
+
return usage("pass at most one of first/last/before/after");
|
|
1959
|
+
const request = {
|
|
1960
|
+
uuids: args.uuids,
|
|
1961
|
+
...(dests[0] !== undefined && { destination: dests[0] }),
|
|
1962
|
+
...(position !== undefined && { position }),
|
|
1963
|
+
};
|
|
1964
|
+
return moveResult(await getClient().write.moveProjects(request, writeOptions(args)));
|
|
1489
1965
|
}));
|
|
1490
1966
|
// ----------------------------------------------------------------- areas
|
|
1491
1967
|
server.registerTool("add_area", {
|
|
@@ -1495,46 +1971,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1495
1971
|
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
1496
1972
|
...createTagsShape,
|
|
1497
1973
|
...dryRunShape,
|
|
1974
|
+
...opIdShape,
|
|
1498
1975
|
},
|
|
1499
1976
|
annotations: NON_DESTRUCTIVE,
|
|
1500
1977
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
|
|
1501
|
-
server.registerTool("update_area", {
|
|
1502
|
-
description: "Rename an area and/or replace its tags (the full set). Tags must exist unless " +
|
|
1503
|
-
"create_tags is set.",
|
|
1504
|
-
inputSchema: {
|
|
1505
|
-
target: z.string().describe(`Area to update (${REF_FORMAT})`),
|
|
1506
|
-
title: z.string().optional().describe("New name"),
|
|
1507
|
-
tags: z
|
|
1508
|
-
.array(z.string())
|
|
1509
|
-
.optional()
|
|
1510
|
-
.describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
|
|
1511
|
-
...createTagsShape,
|
|
1512
|
-
...dryRunShape,
|
|
1513
|
-
},
|
|
1514
|
-
annotations: NON_DESTRUCTIVE,
|
|
1515
|
-
}, async (args) => guard(async () => {
|
|
1516
|
-
if (args.title === undefined && args.tags === undefined) {
|
|
1517
|
-
return usage("pass title and/or tags");
|
|
1518
|
-
}
|
|
1519
|
-
return mutationResult(await getClient().write.updateArea(args.target, {
|
|
1520
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1521
|
-
...(args.tags !== undefined && { tags: args.tags }),
|
|
1522
|
-
}, writeOptions(args)));
|
|
1523
|
-
}));
|
|
1524
|
-
server.registerTool("delete_area", {
|
|
1525
|
-
description: "Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
|
|
1526
|
-
"undone; requires dangerously_permanent. The area's to-dos move to the Trash; its " +
|
|
1527
|
-
"projects remain, no longer assigned to any area.",
|
|
1528
|
-
inputSchema: {
|
|
1529
|
-
target: z.string().describe(`Area to delete (${REF_FORMAT})`),
|
|
1530
|
-
dangerously_permanent: z
|
|
1531
|
-
.boolean()
|
|
1532
|
-
.optional()
|
|
1533
|
-
.describe("Confirm permanent, unrecoverable deletion"),
|
|
1534
|
-
...dryRunShape,
|
|
1535
|
-
},
|
|
1536
|
-
annotations: DESTRUCTIVE,
|
|
1537
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.deleteArea(args.target, writeOptions(args)))));
|
|
1538
1978
|
// ------------------------------------------------------------------ tags
|
|
1539
1979
|
server.registerTool("add_tag", {
|
|
1540
1980
|
description: "Create a tag, optionally nested under an existing parent tag.",
|
|
@@ -1542,64 +1982,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1542
1982
|
title: z.string(),
|
|
1543
1983
|
parent: z.string().optional().describe("Existing parent tag name"),
|
|
1544
1984
|
...dryRunShape,
|
|
1985
|
+
...opIdShape,
|
|
1545
1986
|
},
|
|
1546
1987
|
annotations: NON_DESTRUCTIVE,
|
|
1547
1988
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
|
|
1548
|
-
server.registerTool("update_tag", {
|
|
1549
|
-
description: "Rename a tag (existing assignments follow the rename), nest it under another " +
|
|
1550
|
-
"existing tag, un-nest it to the top level, and set or clear its keyboard shortcut. " +
|
|
1551
|
-
"parent and unnest are exclusive; shortcut and clear_shortcut are exclusive.",
|
|
1552
|
-
inputSchema: {
|
|
1553
|
-
target: z.string().describe(`Tag to update (${REF_FORMAT})`),
|
|
1554
|
-
title: z.string().optional().describe("New name"),
|
|
1555
|
-
parent: z.string().optional().describe("Existing tag to nest under"),
|
|
1556
|
-
unnest: z.boolean().optional().describe("Move the tag to the top level"),
|
|
1557
|
-
shortcut: z.string().optional().describe("Keyboard shortcut character"),
|
|
1558
|
-
clear_shortcut: z.boolean().optional().describe("Remove the keyboard shortcut"),
|
|
1559
|
-
...dryRunShape,
|
|
1560
|
-
},
|
|
1561
|
-
annotations: NON_DESTRUCTIVE,
|
|
1562
|
-
}, async (args) => guard(async () => {
|
|
1563
|
-
if (args.title === undefined &&
|
|
1564
|
-
args.parent === undefined &&
|
|
1565
|
-
args.unnest === undefined &&
|
|
1566
|
-
args.shortcut === undefined &&
|
|
1567
|
-
args.clear_shortcut === undefined) {
|
|
1568
|
-
return usage("pass title, parent, unnest, shortcut, and/or clear_shortcut");
|
|
1569
|
-
}
|
|
1570
|
-
if (args.parent !== undefined && args.unnest === true) {
|
|
1571
|
-
return usage("parent and unnest are exclusive");
|
|
1572
|
-
}
|
|
1573
|
-
if (args.shortcut !== undefined && args.clear_shortcut === true) {
|
|
1574
|
-
return usage("shortcut and clear_shortcut are exclusive");
|
|
1575
|
-
}
|
|
1576
|
-
return mutationResult(await getClient().write.updateTag(args.target, {
|
|
1577
|
-
...(args.title !== undefined && { title: args.title }),
|
|
1578
|
-
...(args.parent !== undefined && { parent: args.parent }),
|
|
1579
|
-
...(args.unnest === true && { unnest: true }),
|
|
1580
|
-
...(args.shortcut !== undefined && { shortcut: args.shortcut }),
|
|
1581
|
-
...(args.clear_shortcut === true && { clearShortcut: true }),
|
|
1582
|
-
}, writeOptions(args)));
|
|
1583
|
-
}));
|
|
1584
|
-
server.registerTool("delete_tag", {
|
|
1585
|
-
description: "Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be undone; " +
|
|
1586
|
-
"requires dangerously_permanent. The tag is removed from every item. If the tag " +
|
|
1587
|
-
"has nested child tags they are ALL permanently deleted with it — pass " +
|
|
1588
|
-
"acknowledge_tag_subtree to confirm.",
|
|
1589
|
-
inputSchema: {
|
|
1590
|
-
target: z.string().describe(`Tag to delete (${REF_FORMAT})`),
|
|
1591
|
-
dangerously_permanent: z
|
|
1592
|
-
.boolean()
|
|
1593
|
-
.optional()
|
|
1594
|
-
.describe("Confirm permanent, unrecoverable deletion"),
|
|
1595
|
-
acknowledge_tag_subtree: z
|
|
1596
|
-
.boolean()
|
|
1597
|
-
.optional()
|
|
1598
|
-
.describe("Confirm permanent deletion of ALL nested child tags too"),
|
|
1599
|
-
...dryRunShape,
|
|
1600
|
-
},
|
|
1601
|
-
annotations: DESTRUCTIVE,
|
|
1602
|
-
}, async (args) => guard(async () => mutationResult(await getClient().write.deleteTag(args.target, writeOptions(args)))));
|
|
1603
1989
|
// -------------------------------------------------- generic + discovery
|
|
1604
1990
|
server.registerTool("run_operation", {
|
|
1605
1991
|
description: "Run any cataloged operation by kind — the generic entry for operations without a " +
|
|
@@ -1620,25 +2006,48 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1620
2006
|
.boolean()
|
|
1621
2007
|
.optional()
|
|
1622
2008
|
.describe("Required for area/tag delete and trash.empty (PERMANENT, no Trash)"),
|
|
2009
|
+
allow_non_empty_area: z
|
|
2010
|
+
.boolean()
|
|
2011
|
+
.optional()
|
|
2012
|
+
.describe("area.delete: delete a non-empty area together with its contents"),
|
|
1623
2013
|
...dryRunShape,
|
|
2014
|
+
...opIdShape,
|
|
1624
2015
|
},
|
|
1625
2016
|
annotations: DESTRUCTIVE,
|
|
1626
2017
|
}, async (args) => guard(async () => mutationResult(await getClient().write.run(args.op, args.params, writeOptions(args)))));
|
|
1627
2018
|
server.registerTool("batch", {
|
|
1628
2019
|
description: "Run several operations in order, each independently — there are no transactions, " +
|
|
1629
2020
|
"and a failure does not roll back earlier operations. Per-operation results return " +
|
|
1630
|
-
"in order; fail_fast skips the remainder after the first failure."
|
|
2021
|
+
"in order; fail_fast skips the remainder after the first failure. " +
|
|
2022
|
+
"CHAINING: an operation that creates something may carry temp_id (a handle); a LATER " +
|
|
2023
|
+
'operation references that new uuid as "$handle" in any id/container field (dotted ' +
|
|
2024
|
+
'"$handle.instance"/"$handle.replaced" reach a repeating op\'s spawned instance / ' +
|
|
2025
|
+
"replaced source). A temp_id is valid only on a creating operation (not tag.add — " +
|
|
2026
|
+
"reference a tag by title) and unique per batch. IDEMPOTENCY: op_id makes resubmission " +
|
|
2027
|
+
"safe — an operation matching an earlier success is reported already-applied, not " +
|
|
2028
|
+
"re-created. The result adds temp_id_mapping (handle → uuid) and undo_token, which " +
|
|
2029
|
+
"reverses the whole batch as one unit via the undo tool.",
|
|
1631
2030
|
inputSchema: {
|
|
1632
2031
|
ops: z
|
|
1633
2032
|
.array(z.object({
|
|
1634
2033
|
op: z.enum(OPERATION_KINDS),
|
|
1635
2034
|
params: z.record(z.string(), z.unknown()),
|
|
2035
|
+
temp_id: z
|
|
2036
|
+
.string()
|
|
2037
|
+
.optional()
|
|
2038
|
+
.describe('Handle for this op\'s new uuid, referenced later as "$handle"'),
|
|
2039
|
+
op_id: z
|
|
2040
|
+
.string()
|
|
2041
|
+
.optional()
|
|
2042
|
+
.describe("Idempotency id — a resubmitted op matching an earlier success is skipped"),
|
|
1636
2043
|
options: z
|
|
1637
2044
|
.object({
|
|
1638
2045
|
acknowledge_checklist_reset: z.boolean().optional(),
|
|
1639
2046
|
acknowledge_project_reopen: z.boolean().optional(),
|
|
1640
2047
|
dangerously_permanent: z.boolean().optional(),
|
|
1641
2048
|
acknowledge_tag_subtree: z.boolean().optional(),
|
|
2049
|
+
allow_non_empty_area: z.boolean().optional(),
|
|
2050
|
+
dangerously_drive_gui: z.boolean().optional(),
|
|
1642
2051
|
})
|
|
1643
2052
|
.optional(),
|
|
1644
2053
|
}))
|
|
@@ -1659,58 +2068,122 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1659
2068
|
...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
1660
2069
|
...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1661
2070
|
...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
2071
|
+
...(o?.allow_non_empty_area === true && { allowNonEmptyArea: true }),
|
|
2072
|
+
...(o?.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
|
|
1662
2073
|
...(ceiling !== undefined && { maxDisruption: ceiling }),
|
|
1663
2074
|
};
|
|
1664
2075
|
return {
|
|
1665
2076
|
op: op.op,
|
|
1666
2077
|
params: op.params,
|
|
2078
|
+
...(op.temp_id !== undefined && { tempId: op.temp_id }),
|
|
2079
|
+
...(op.op_id !== undefined && { opId: op.op_id }),
|
|
1667
2080
|
...(Object.keys(opts).length > 0 && { options: opts }),
|
|
1668
2081
|
};
|
|
1669
2082
|
});
|
|
1670
|
-
const
|
|
2083
|
+
const batchResult = await getClient().write.batch(ops, {
|
|
1671
2084
|
...(args.dry_run === true && { dryRun: true }),
|
|
1672
2085
|
...(args.fail_fast === true && { failFast: true }),
|
|
1673
2086
|
actor: mcpActor(),
|
|
1674
2087
|
});
|
|
1675
|
-
|
|
2088
|
+
// First block: the per-op results, each FLATTENED to the wire shape (a
|
|
2089
|
+
// string `outcome` with the variant fields hoisted — parity with the CLI
|
|
2090
|
+
// JSONL stream, docs/contract.md). Second block (additive): the batch
|
|
2091
|
+
// chaining/undo summary, present only when the batch minted a token or
|
|
2092
|
+
// bound temp ids.
|
|
2093
|
+
const lines = batchResult.results.map(flattenBatchLine);
|
|
2094
|
+
const hasSummary = batchResult.undoToken !== undefined || Object.keys(batchResult.tempIdMapping).length > 0;
|
|
2095
|
+
if (!hasSummary)
|
|
2096
|
+
return jsonResult(lines);
|
|
2097
|
+
return {
|
|
2098
|
+
content: [
|
|
2099
|
+
{ type: "text", text: JSON.stringify(lines) },
|
|
2100
|
+
{
|
|
2101
|
+
type: "text",
|
|
2102
|
+
text: JSON.stringify({
|
|
2103
|
+
tempIdMapping: batchResult.tempIdMapping,
|
|
2104
|
+
...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
|
|
2105
|
+
}),
|
|
2106
|
+
},
|
|
2107
|
+
],
|
|
2108
|
+
};
|
|
1676
2109
|
}));
|
|
1677
2110
|
server.registerTool("reorder", {
|
|
1678
|
-
description: "
|
|
1679
|
-
"
|
|
1680
|
-
"
|
|
1681
|
-
"
|
|
1682
|
-
"
|
|
1683
|
-
"
|
|
1684
|
-
"Today
|
|
1685
|
-
"
|
|
1686
|
-
|
|
1687
|
-
"
|
|
2111
|
+
description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
|
|
2112
|
+
"share — this REARRANGES, never changes what an item belongs to (to change membership " +
|
|
2113
|
+
"use move_todo / move_project). The refs order is the resulting order; unmentioned " +
|
|
2114
|
+
"siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
|
|
2115
|
+
"EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
|
|
2116
|
+
"position the block instead. Refs that span different containers or buckets are refused. " +
|
|
2117
|
+
"A Today or This Evening member also holds a slot in its own container, so a set that is " +
|
|
2118
|
+
"coherent on BOTH axes is ambiguous — pass `in` to name the axis (the refusal names both " +
|
|
2119
|
+
"choices). The project rows the Today/Evening/day lists intermix with to-dos may be " +
|
|
2120
|
+
"reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
|
|
2121
|
+
"to-dos, or an area must first be enabled once via `things config set allow-experimental " +
|
|
2122
|
+
"true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
|
|
2123
|
+
"move_heading action; to reorder sidebar AREAS use reorder_areas.",
|
|
1688
2124
|
inputSchema: {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
"
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
"project",
|
|
1695
|
-
"headings",
|
|
1696
|
-
"area",
|
|
1697
|
-
"projects",
|
|
1698
|
-
]),
|
|
1699
|
-
container: z
|
|
2125
|
+
refs: z
|
|
2126
|
+
.array(z.string())
|
|
2127
|
+
.describe("The items to rearrange, in the order they should land (may be a subset)"),
|
|
2128
|
+
...positionShape,
|
|
2129
|
+
in: z
|
|
1700
2130
|
.string()
|
|
1701
2131
|
.optional()
|
|
1702
|
-
.describe(
|
|
1703
|
-
|
|
1704
|
-
strategy: z.enum(["native", "bounce"]).optional(),
|
|
2132
|
+
.describe("Disambiguate the axis of a Today/This Evening set: today | evening | anytime | " +
|
|
2133
|
+
"someday | inbox, or a project/area/heading ref (uuid or unique title)"),
|
|
1705
2134
|
...dryRunShape,
|
|
1706
2135
|
},
|
|
1707
2136
|
annotations: NON_DESTRUCTIVE,
|
|
1708
|
-
}, async (args) => guard(async () =>
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2137
|
+
}, async (args) => guard(async () => {
|
|
2138
|
+
const position = movePositionArgs(args);
|
|
2139
|
+
if (position === "conflict")
|
|
2140
|
+
return usage("pass at most one of first/last/before/after");
|
|
2141
|
+
const request = {
|
|
2142
|
+
uuids: args.refs,
|
|
2143
|
+
...(position !== undefined && { position }),
|
|
2144
|
+
...(args.in !== undefined && { in: args.in }),
|
|
2145
|
+
};
|
|
2146
|
+
return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
|
|
2147
|
+
}));
|
|
2148
|
+
server.registerTool("reorder_areas", {
|
|
2149
|
+
description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
|
|
2150
|
+
"name). Pass exactly one destination: before/after another area, or first/last. This " +
|
|
2151
|
+
"visibly drives the local Things app (the window comes forward and the sidebar may " +
|
|
2152
|
+
"scroll) and must be turned on first with `things config set ui-enabled true`; the " +
|
|
2153
|
+
"area's projects and to-dos are untouched.",
|
|
2154
|
+
inputSchema: {
|
|
2155
|
+
target: z.string().describe(`the area to move (${REF_FORMAT})`),
|
|
2156
|
+
before: z
|
|
2157
|
+
.string()
|
|
2158
|
+
.optional()
|
|
2159
|
+
.describe(`place it immediately above this area (${REF_FORMAT})`),
|
|
2160
|
+
after: z
|
|
2161
|
+
.string()
|
|
2162
|
+
.optional()
|
|
2163
|
+
.describe(`place it immediately below this area (${REF_FORMAT})`),
|
|
2164
|
+
first: z.boolean().optional().describe("move it to the top of the area list"),
|
|
2165
|
+
last: z.boolean().optional().describe("move it to the bottom of the area list"),
|
|
2166
|
+
...driveGuiShape,
|
|
2167
|
+
...dryRunShape,
|
|
2168
|
+
},
|
|
2169
|
+
annotations: NON_DESTRUCTIVE,
|
|
2170
|
+
}, async (args) => guard(async () => {
|
|
2171
|
+
const chosen = [
|
|
2172
|
+
args.before !== undefined,
|
|
2173
|
+
args.after !== undefined,
|
|
2174
|
+
args.first === true,
|
|
2175
|
+
args.last === true,
|
|
2176
|
+
].filter(Boolean).length;
|
|
2177
|
+
if (chosen !== 1)
|
|
2178
|
+
return usage("pass exactly one of before / after / first / last");
|
|
2179
|
+
return mutationResult(await getClient().write.run("area.reorder", {
|
|
2180
|
+
target: args.target,
|
|
2181
|
+
...(args.before !== undefined && { before: args.before }),
|
|
2182
|
+
...(args.after !== undefined && { after: args.after }),
|
|
2183
|
+
...(args.first === true && { position: "first" }),
|
|
2184
|
+
...(args.last === true && { position: "last" }),
|
|
2185
|
+
}, writeOptions(args)));
|
|
2186
|
+
}));
|
|
1714
2187
|
server.registerTool("undo", {
|
|
1715
2188
|
description: "Undo the last N changes, newest first (changes made directly in the Things app " +
|
|
1716
2189
|
"cannot be undone here). By default this undoes only changes made through THIS " +
|