things-api 0.9.0 → 0.10.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 +30 -11
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +125 -70
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +69 -31
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.js +423 -157
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +2 -3
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +47 -24
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +7 -6
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +378 -148
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +39 -2
- package/dist/cli/glyphs.js +91 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +355 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +32 -25
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +7 -0
- package/dist/cli/period.js +12 -0
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +67 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +318 -97
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/tag-filters.d.ts +59 -0
- package/dist/cli/tag-filters.js +57 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +138 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +137 -24
- package/dist/client.js +105 -16
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +87 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +30 -4
- package/dist/index.js +35 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +691 -133
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +31 -7
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +7 -0
- package/dist/model/mappers.js +7 -2
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +49 -13
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +39 -8
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +149 -6
- package/dist/read/queries.js +250 -31
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +140 -16
- package/dist/read/views.js +264 -67
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +63 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +7 -0
- package/dist/write/automation-probe.js +8 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +4 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +2 -0
- package/dist/write/commands.js +562 -48
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +64 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +19 -0
- package/dist/write/pipeline.js +109 -26
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +68 -3
- package/dist/write/pre-state.js +102 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +17 -1
- package/dist/write/vectors/registry.js +18 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/types.d.ts +119 -3
- package/dist/write/vectors/ui-certification.d.ts +48 -0
- package/dist/write/vectors/ui-certification.js +46 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +70 -0
- package/dist/write/vectors/ui-recipes.js +558 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +137 -0
- package/dist/write/vectors/ui.js +656 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +31 -3
- package/dist/write/verify/delta.js +22 -6
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.js +1 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
package/dist/mcp/server.js
CHANGED
|
@@ -13,37 +13,38 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
-
import { openThings } from "../
|
|
17
|
-
import { PKG_VERSION } from "../contracts.js";
|
|
18
|
-
import { diagnose } from "../diagnose.js";
|
|
19
|
-
import { AREA_PREVIEW_LIMIT, DEFAULT_LIST_LIMIT, PROJECT_PREVIEW_LIMIT, capAreaSections, paginateList, paginateToday, previewSections, previewSomedaySections, } from "../read/pagination.js";
|
|
20
|
-
import { resolveCap } from "../read/caps.js";
|
|
21
|
-
import { ALL_DESC, AREA_LIMIT_DESC, DATE_FORMAT, LIMIT_DESC, PROJECT_LIMIT_DESC, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES, } from "../surface-copy.js";
|
|
22
|
-
import { capabilitiesTable } from "../write/capabilities.js";
|
|
23
|
-
import { OPERATION_KINDS } from "../write/operations.js";
|
|
24
|
-
import { BOUNCE_MAX_ITEMS } from "../write/reorder.js";
|
|
16
|
+
import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, hasTagPresence, LIMIT_DESC, MCP_WHEN_LABELS, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
|
|
25
17
|
function jsonResult(data) {
|
|
26
18
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
27
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A read result: like {@link jsonResult}, but the entity payload passes through
|
|
22
|
+
* the omit-empty transform (docs/design/contracts.md) so an empty optional
|
|
23
|
+
* field is absent, not null/[]. Mutation results keep their own shape and use
|
|
24
|
+
* jsonResult directly.
|
|
25
|
+
*/
|
|
26
|
+
function readResult(data) {
|
|
27
|
+
return jsonResult(omitEmpty(data));
|
|
28
|
+
}
|
|
28
29
|
/**
|
|
29
30
|
* A read result carrying truncation metadata: the data (already limited) in
|
|
30
|
-
* the first content block, and a second block with the {@link
|
|
31
|
+
* the first content block, and a second block with the {@link Truncation}
|
|
31
32
|
* numbers plus a one-line note the agent can read when rows were dropped.
|
|
32
33
|
*/
|
|
33
|
-
function
|
|
34
|
-
const note =
|
|
35
|
-
? `showing ${
|
|
34
|
+
function truncatedResult(data, truncation) {
|
|
35
|
+
const note = truncation.truncated
|
|
36
|
+
? `showing ${truncation.shown} of ${truncation.total} items — pass limit (or all: true) to see more`
|
|
36
37
|
: undefined;
|
|
37
38
|
return {
|
|
38
39
|
content: [
|
|
39
|
-
{ type: "text", text: JSON.stringify(data) },
|
|
40
|
-
{ type: "text", text: JSON.stringify({
|
|
40
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
41
|
+
{ type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
|
|
41
42
|
],
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
46
|
* Grouped read result (anytime/someday): the per-block-truncated sections plus
|
|
46
|
-
* a second block carrying the {@link
|
|
47
|
+
* a second block carrying the {@link GroupedTruncation} counts and, when
|
|
47
48
|
* anything was hidden, a one-line note the agent can read.
|
|
48
49
|
*/
|
|
49
50
|
function groupedResult(data, grouped) {
|
|
@@ -52,11 +53,25 @@ function groupedResult(data, grouped) {
|
|
|
52
53
|
: undefined;
|
|
53
54
|
return {
|
|
54
55
|
content: [
|
|
55
|
-
{ type: "text", text: JSON.stringify(data) },
|
|
56
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
56
57
|
{ type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
|
|
57
58
|
],
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve one MCP cap (limit / area_limit / project_limit) against `all` into a
|
|
63
|
+
* row cap for the client: `null` = every row, `"conflict"` when an explicit
|
|
64
|
+
* value is combined with `all: true` (the tool returns a usage error), else the
|
|
65
|
+
* value or the default. The client re-applies the same semantics on what it
|
|
66
|
+
* receives; this copy owns the conflict detection the MCP surface reports.
|
|
67
|
+
*/
|
|
68
|
+
function resolveCap(value, all, defaultLimit) {
|
|
69
|
+
if (all === true && value !== undefined)
|
|
70
|
+
return "conflict";
|
|
71
|
+
if (all === true)
|
|
72
|
+
return null;
|
|
73
|
+
return value ?? defaultLimit;
|
|
74
|
+
}
|
|
60
75
|
/** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
|
|
61
76
|
function resolveLimit(args) {
|
|
62
77
|
return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
|
|
@@ -80,14 +95,14 @@ function mutationResult(result) {
|
|
|
80
95
|
return jsonResult(result);
|
|
81
96
|
case "blocked":
|
|
82
97
|
return errorResult({
|
|
83
|
-
code:
|
|
98
|
+
code: blockedCode(result),
|
|
84
99
|
message: result.detail,
|
|
85
100
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
86
101
|
remediation: result.remediation,
|
|
87
102
|
});
|
|
88
103
|
case "verify-failed":
|
|
89
104
|
return errorResult({
|
|
90
|
-
code:
|
|
105
|
+
code: verifyFailedCode(result),
|
|
91
106
|
message: result.detail,
|
|
92
107
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
93
108
|
...(result.hint !== undefined && { remediation: result.hint }),
|
|
@@ -109,19 +124,73 @@ function mutationResult(result) {
|
|
|
109
124
|
const READ_ONLY = { readOnlyHint: true };
|
|
110
125
|
const NON_DESTRUCTIVE = { destructiveHint: false };
|
|
111
126
|
const DESTRUCTIVE = { destructiveHint: true };
|
|
112
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The three tag-filter inputs shared by every tag-accepting tool. `tag` is an
|
|
129
|
+
* ARRAY — repeat a tag to AND several together (both keep hierarchy-descendant
|
|
130
|
+
* expansion, which `exact_tag` disables). In FLAT views (read_view, search, and
|
|
131
|
+
* the projects list of list_collections) `tag` honors container inheritance; in
|
|
132
|
+
* the SINGLE-CONTAINER tools (get_project, get_area) it matches a tag carried
|
|
133
|
+
* DIRECTLY on the item — that container's own inherited tags are ignored (every
|
|
134
|
+
* child inherits them, so an inheritance-inclusive match would be vacuous).
|
|
135
|
+
*/
|
|
136
|
+
const tagOnlyShape = {
|
|
113
137
|
tag: z
|
|
114
|
-
.string()
|
|
138
|
+
.array(z.string())
|
|
139
|
+
.optional()
|
|
140
|
+
.describe(`Filter by tag (${REF_FORMAT}); repeat to AND several tags. Matches a direct, ` +
|
|
141
|
+
"container-inherited, or descendant tag (in a container tool the container's own " +
|
|
142
|
+
"inherited tags are ignored — the tag must be on the item itself)"),
|
|
143
|
+
exact_tag: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Match only the named tag(s), not their nested children"),
|
|
147
|
+
untagged: z
|
|
148
|
+
.boolean()
|
|
115
149
|
.optional()
|
|
116
|
-
.describe(
|
|
117
|
-
|
|
150
|
+
.describe("Only items with no tag (direct or inherited); not combinable with tag/exact_tag " +
|
|
151
|
+
"(in a container tool: no tag on the item itself, ignoring inherited tags)"),
|
|
118
152
|
};
|
|
153
|
+
const tagFilterShape = {
|
|
154
|
+
...tagOnlyShape,
|
|
155
|
+
overdue: z
|
|
156
|
+
.boolean()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Only open items past their deadline (due today is not overdue)"),
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Map the MCP tool's snake_case tag inputs onto the shared {@link TagPresence}
|
|
162
|
+
* shape (canonical CLI-flag spelling) so the one set of contract predicates —
|
|
163
|
+
* {@link hasTagPresence}, {@link tagFlagConflict}, {@link tagFilterFields},
|
|
164
|
+
* {@link validateViewArgs} — serves both surfaces. The only difference is
|
|
165
|
+
* `exact_tag` → `exactTag`.
|
|
166
|
+
*/
|
|
167
|
+
function tagPresence(args) {
|
|
168
|
+
return {
|
|
169
|
+
...(args.tag !== undefined && { tag: args.tag }),
|
|
170
|
+
...(args.exact_tag !== undefined && { exactTag: args.exact_tag }),
|
|
171
|
+
...(args.untagged !== undefined && { untagged: args.untagged }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The MCP-voiced usage copy for the tag-filter mutual-exclusivity conflict. */
|
|
175
|
+
const MCP_UNTAGGED_CONFLICT = "untagged does not combine with tag/exact_tag";
|
|
119
176
|
const dryRunShape = {
|
|
120
177
|
dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
|
|
121
178
|
};
|
|
179
|
+
/** How a tag value may be expressed on any tag-accepting tool. */
|
|
180
|
+
const TAG_REF_FORMAT = "each a tag name or a parent/child path; must exist unless create_tags is set";
|
|
181
|
+
/** create_tags param, shared by every tag-accepting write tool. */
|
|
182
|
+
const createTagsShape = {
|
|
183
|
+
create_tags: z
|
|
184
|
+
.boolean()
|
|
185
|
+
.optional()
|
|
186
|
+
.describe("Create any named tag that does not exist yet (nesting parent/child) before applying, " +
|
|
187
|
+
"instead of stopping on an unknown tag"),
|
|
188
|
+
};
|
|
122
189
|
const containerRef = (ref) => ({ uuid: ref, title: ref });
|
|
123
190
|
/** Cap on project titles inlined into the server instructions. */
|
|
124
191
|
const INSTRUCTIONS_MAX_PROJECTS = 100;
|
|
192
|
+
/** A tag's display label: nested tags show `parent > child`. */
|
|
193
|
+
const tagLabel = (t) => t.parent === null ? t.title : `${t.parent} > ${t.title}`;
|
|
125
194
|
/**
|
|
126
195
|
* Live-inventory preamble: conventions plus the user's actual areas, tags,
|
|
127
196
|
* and open projects, read once at server start so models can reference real
|
|
@@ -146,13 +215,13 @@ function buildInstructions(getClient) {
|
|
|
146
215
|
"- Every write tool accepts dry_run: true to preview the change without applying it. " +
|
|
147
216
|
"Operations with cascading or permanent effects require the explicit confirmation " +
|
|
148
217
|
"parameter named in their description; refused calls return an error saying what to pass.",
|
|
218
|
+
`- Read results are compact: ${OMIT_EMPTY_NOTE}`,
|
|
149
219
|
];
|
|
150
220
|
try {
|
|
151
221
|
const c = getClient();
|
|
152
222
|
const areas = c.read.areas();
|
|
153
223
|
const tags = c.read.tags();
|
|
154
224
|
const projects = c.read.projects();
|
|
155
|
-
const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
|
|
156
225
|
const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
|
|
157
226
|
const overflow = projects.length - shown.length;
|
|
158
227
|
lines.push("", "Current inventory (read at server start — refresh with list_collections):", `- Areas (${areas.length}): ${areas.map((a) => a.title).join(", ") || "none"}`, `- Tags (${tags.length}): ${tags.map(tagLabel).join(", ") || "none"}`, `- Open projects (${projects.length}): ${shown.map((p) => p.title).join("; ") || "none"}` +
|
|
@@ -169,6 +238,24 @@ function buildInstructions(getClient) {
|
|
|
169
238
|
function checklistTarget(args) {
|
|
170
239
|
return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
|
|
171
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Derive the audit author for a client's writes from the MCP initialize
|
|
243
|
+
* handshake's clientInfo.name: lowercased, every run of non-alphanumerics
|
|
244
|
+
* collapsed to a single "-", leading/trailing dashes trimmed, capped at 32
|
|
245
|
+
* characters. An absent (or empty-after-sanitizing) client name falls back to
|
|
246
|
+
* the bare "mcp". The result is never caller-settable — it is the connecting
|
|
247
|
+
* client's own identity, so each client's writes are attributed to it.
|
|
248
|
+
*/
|
|
249
|
+
const MCP_ACTOR_PREFIX = "mcp";
|
|
250
|
+
function deriveMcpActor(clientName) {
|
|
251
|
+
const slug = (clientName ?? "")
|
|
252
|
+
.toLowerCase()
|
|
253
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
254
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
255
|
+
.slice(0, 32)
|
|
256
|
+
.replaceAll(/-+$/g, "");
|
|
257
|
+
return slug === "" ? MCP_ACTOR_PREFIX : `${MCP_ACTOR_PREFIX}:${slug}`;
|
|
258
|
+
}
|
|
172
259
|
export function createThingsMcpServer(options = {}) {
|
|
173
260
|
// One lazily-opened client for the server's lifetime; SQLite read
|
|
174
261
|
// snapshots are per-statement, so fresh reads see external commits.
|
|
@@ -181,26 +268,70 @@ export function createThingsMcpServer(options = {}) {
|
|
|
181
268
|
return client;
|
|
182
269
|
};
|
|
183
270
|
const server = new McpServer({ name: "things-api", version: PKG_VERSION }, { instructions: buildInstructions(getClient) });
|
|
271
|
+
// The audit author for every write on this connection, derived once from the
|
|
272
|
+
// client's handshake identity (clientInfo.name). Read per call: clientInfo is
|
|
273
|
+
// populated when the initialize handshake completes, before any tool can run.
|
|
274
|
+
// Not caller-settable — no tool argument overrides it.
|
|
275
|
+
const mcpActor = () => deriveMcpActor(server.server.getClientVersion()?.name);
|
|
276
|
+
/** Translate the shared MCP write-tool args into pipeline WriteOptions. */
|
|
277
|
+
const writeOptions = (args) => ({
|
|
278
|
+
actor: mcpActor(),
|
|
279
|
+
...(options.maxDisruption !== undefined && { maxDisruption: options.maxDisruption }),
|
|
280
|
+
...(args.dry_run === true && { dryRun: true }),
|
|
281
|
+
...(args.verify_timeout_ms !== undefined && { verifyTimeoutMs: args.verify_timeout_ms }),
|
|
282
|
+
...(args.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
|
|
283
|
+
...(args.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
284
|
+
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
285
|
+
...(args.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
286
|
+
...(args.dangerously_drive_gui === true && { dangerouslyDriveGui: true }),
|
|
287
|
+
...(args.create_tags === true && { createTags: true }),
|
|
288
|
+
});
|
|
184
289
|
/** Run a handler, mapping environment/usage throws to tool errors. */
|
|
185
290
|
const guard = async (fn) => {
|
|
186
291
|
try {
|
|
187
292
|
return await fn();
|
|
188
293
|
}
|
|
189
294
|
catch (err) {
|
|
295
|
+
// An unresolved reference (ambiguous or not-found uuid/partial-uuid/name)
|
|
296
|
+
// carries machine-readable candidates so a consumer can disambiguate as
|
|
297
|
+
// data, not by re-parsing the prose message.
|
|
298
|
+
if (err instanceof ReferenceResolutionError) {
|
|
299
|
+
return errorResult({
|
|
300
|
+
code: err.code,
|
|
301
|
+
message: err.message,
|
|
302
|
+
details: { candidates: err.candidates },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
190
305
|
const message = err instanceof Error ? err.message : String(err);
|
|
191
306
|
const code = err instanceof RangeError ? "usage" : "environment";
|
|
192
307
|
return errorResult({ code, message });
|
|
193
308
|
}
|
|
194
309
|
};
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
310
|
+
/**
|
|
311
|
+
* A read handler wrapper: run through {@link guard}, then — on a successful
|
|
312
|
+
* result — append a meta block carrying any non-blocking schema warning (the
|
|
313
|
+
* same note the CLI prints), so a consumer sees when the Things database no
|
|
314
|
+
* longer matches the validated schema and its data may be incomplete. No
|
|
315
|
+
* block is added when the schema checks out or the read itself errored.
|
|
316
|
+
*/
|
|
317
|
+
const readGuard = async (fn) => {
|
|
318
|
+
const result = await guard(fn);
|
|
319
|
+
if (result.isError === true)
|
|
320
|
+
return result;
|
|
321
|
+
let warnings = [];
|
|
322
|
+
try {
|
|
323
|
+
warnings = schemaWarnings(getClient().schemaStatus());
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
warnings = [];
|
|
327
|
+
}
|
|
328
|
+
if (warnings.length === 0)
|
|
329
|
+
return result;
|
|
330
|
+
return {
|
|
331
|
+
...result,
|
|
332
|
+
content: [...result.content, { type: "text", text: JSON.stringify({ meta: { warnings } }) }],
|
|
333
|
+
};
|
|
334
|
+
};
|
|
204
335
|
/** Resolve a uuid to to-do/project for the type-generic item tools. */
|
|
205
336
|
const itemType = (uuid) => {
|
|
206
337
|
const item = getClient().read.byUuid(uuid);
|
|
@@ -216,7 +347,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
216
347
|
description: "Read a Things list as the app presents it: today (split into Today and This " +
|
|
217
348
|
"Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
|
|
218
349
|
"horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
|
|
219
|
-
"anytime/someday return
|
|
350
|
+
"anytime/someday return sections in canonical order (area + items; null area = the " +
|
|
220
351
|
"top-level block); children of someday/future-scheduled projects are excluded " +
|
|
221
352
|
"from anytime — the project row represents them; someday lists each group's " +
|
|
222
353
|
"project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
|
|
@@ -224,10 +355,12 @@ export function createThingsMcpServer(options = {}) {
|
|
|
224
355
|
"anytime/someday always return every group and cap per block instead — " +
|
|
225
356
|
`area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
|
|
226
357
|
`project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
|
|
227
|
-
"all: true lifts every cap; the result's second block reports the counts."
|
|
358
|
+
"all: true lifts every cap; the result's second block reports the counts. " +
|
|
359
|
+
OMIT_EMPTY_NOTE,
|
|
228
360
|
inputSchema: {
|
|
229
361
|
view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
|
|
230
362
|
...tagFilterShape,
|
|
363
|
+
evening: z.boolean().optional().describe("today only: show only the This Evening section"),
|
|
231
364
|
show_active_project_items: z
|
|
232
365
|
.union([z.boolean(), z.number().int().min(1)])
|
|
233
366
|
.optional()
|
|
@@ -268,7 +401,17 @@ export function createThingsMcpServer(options = {}) {
|
|
|
268
401
|
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
|
|
269
402
|
},
|
|
270
403
|
annotations: READ_ONLY,
|
|
271
|
-
}, async (args) =>
|
|
404
|
+
}, async (args) => readGuard(() => {
|
|
405
|
+
// Tag-conflict AND overdue-applicability both derive from the shared
|
|
406
|
+
// contract: read_view honors overdue only on today/inbox/anytime/someday
|
|
407
|
+
// (the current-work views), matching FILTER_CONTRACT.
|
|
408
|
+
const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
|
|
409
|
+
untaggedConflict: MCP_UNTAGGED_CONFLICT,
|
|
410
|
+
overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
|
|
411
|
+
overdueStatusWiden: "",
|
|
412
|
+
});
|
|
413
|
+
if (!validated.ok)
|
|
414
|
+
return usage(validated.message);
|
|
272
415
|
// show_active_project_items is the preferred name; active_project_items
|
|
273
416
|
// stays accepted as a compatibility alias.
|
|
274
417
|
const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
|
|
@@ -283,6 +426,9 @@ export function createThingsMcpServer(options = {}) {
|
|
|
283
426
|
if (args.view !== "someday" && showActiveProjectItems !== undefined) {
|
|
284
427
|
return usage("show_active_project_items applies only to someday");
|
|
285
428
|
}
|
|
429
|
+
if (args.view !== "today" && args.evening === true) {
|
|
430
|
+
return usage(`evening applies only to today, not ${args.view}`);
|
|
431
|
+
}
|
|
286
432
|
if (args.view === "someday" && args.project_limit !== undefined) {
|
|
287
433
|
return usage("project_limit does not apply to someday — pass a number as show_active_project_items " +
|
|
288
434
|
"to cap that section's project lists");
|
|
@@ -296,63 +442,63 @@ export function createThingsMcpServer(options = {}) {
|
|
|
296
442
|
return usage("pass at most one of area_limit/project_limit / all");
|
|
297
443
|
}
|
|
298
444
|
const c = getClient();
|
|
299
|
-
const filter =
|
|
300
|
-
...(args.tag !== undefined && { tag: args.tag }),
|
|
301
|
-
...(args.exact_tag === true && { exactTag: true }),
|
|
302
|
-
};
|
|
445
|
+
const filter = validated.filter;
|
|
303
446
|
switch (args.view) {
|
|
304
447
|
case "today": {
|
|
305
|
-
const {
|
|
306
|
-
|
|
448
|
+
const { view, truncation } = c.read.today({
|
|
449
|
+
...filter,
|
|
450
|
+
...(args.evening === true && { eveningOnly: true }),
|
|
451
|
+
limit,
|
|
452
|
+
});
|
|
453
|
+
return truncatedResult(view, truncation);
|
|
307
454
|
}
|
|
308
455
|
case "inbox": {
|
|
309
|
-
const {
|
|
310
|
-
return
|
|
456
|
+
const { items, truncation } = c.read.inbox({ ...filter, limit });
|
|
457
|
+
return truncatedResult(items, truncation);
|
|
311
458
|
}
|
|
312
459
|
case "anytime": {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
return groupedResult(data, grouped);
|
|
460
|
+
const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
|
|
461
|
+
return groupedResult(view, grouped);
|
|
316
462
|
}
|
|
317
463
|
case "upcoming": {
|
|
318
|
-
const {
|
|
464
|
+
const { items, truncation } = c.read.upcoming({
|
|
319
465
|
...filter,
|
|
320
466
|
...(args.horizon !== undefined && { horizon: args.horizon }),
|
|
321
|
-
|
|
322
|
-
|
|
467
|
+
limit,
|
|
468
|
+
});
|
|
469
|
+
return truncatedResult(items, truncation);
|
|
323
470
|
}
|
|
324
471
|
case "someday": {
|
|
325
472
|
const active = showActiveProjectItems;
|
|
326
473
|
if (typeof active === "number" && args.all === true) {
|
|
327
474
|
return usage("pass at most one of a numeric show_active_project_items / all");
|
|
328
475
|
}
|
|
329
|
-
const
|
|
330
|
-
area: areaLimit,
|
|
331
|
-
// true = every item per project; a number caps each list.
|
|
332
|
-
project: typeof active === "number" ? active : null,
|
|
333
|
-
};
|
|
334
|
-
const { data, grouped } = previewSomedaySections(c.read.someday({
|
|
476
|
+
const { view, grouped } = c.read.someday({
|
|
335
477
|
...filter,
|
|
336
478
|
...((active === true || typeof active === "number") && {
|
|
337
479
|
activeProjectItems: true,
|
|
338
480
|
}),
|
|
339
|
-
|
|
340
|
-
|
|
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);
|
|
341
486
|
}
|
|
342
487
|
case "logbook": {
|
|
343
|
-
const {
|
|
344
|
-
return
|
|
488
|
+
const { items, truncation } = c.read.logbook({ ...filter, limit });
|
|
489
|
+
return truncatedResult(items, truncation);
|
|
345
490
|
}
|
|
346
491
|
case "trash": {
|
|
347
|
-
const {
|
|
348
|
-
return
|
|
492
|
+
const { items, truncation } = c.read.trash({ limit });
|
|
493
|
+
return truncatedResult(items, truncation);
|
|
349
494
|
}
|
|
350
495
|
}
|
|
351
496
|
}));
|
|
352
497
|
server.registerTool("search", {
|
|
353
498
|
description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
|
|
354
499
|
"include more with logged/trashed/all. Scope with project/area/tag — scope " +
|
|
355
|
-
"references must name existing items."
|
|
500
|
+
"references must name existing items. " +
|
|
501
|
+
OMIT_EMPTY_NOTE,
|
|
356
502
|
inputSchema: {
|
|
357
503
|
query: z.string(),
|
|
358
504
|
...tagFilterShape,
|
|
@@ -374,33 +520,48 @@ export function createThingsMcpServer(options = {}) {
|
|
|
374
520
|
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
375
521
|
},
|
|
376
522
|
annotations: READ_ONLY,
|
|
377
|
-
}, async (args) =>
|
|
523
|
+
}, async (args) => readGuard(() => {
|
|
524
|
+
// Tag-conflict AND the overdue/status-widening incompatibility both
|
|
525
|
+
// derive from the shared contract (search: statusWidening = true).
|
|
526
|
+
const validated = validateViewArgs("search", {
|
|
527
|
+
...tagPresence(args),
|
|
528
|
+
overdue: args.overdue,
|
|
529
|
+
logged: args.logged,
|
|
530
|
+
trashed: args.trashed,
|
|
531
|
+
all: args.all,
|
|
532
|
+
}, {
|
|
533
|
+
untaggedConflict: MCP_UNTAGGED_CONFLICT,
|
|
534
|
+
overdueRejected: "overdue does not apply to search",
|
|
535
|
+
overdueStatusWiden: "overdue lists open items; it does not combine with logged/trashed/all",
|
|
536
|
+
});
|
|
537
|
+
if (!validated.ok)
|
|
538
|
+
return usage(validated.message);
|
|
378
539
|
const limit = resolveLimit(args);
|
|
379
540
|
if (limit === "conflict")
|
|
380
541
|
return usage("pass at most one of limit / all");
|
|
381
|
-
const {
|
|
382
|
-
limit
|
|
383
|
-
...
|
|
384
|
-
...(args.exact_tag === true && { exactTag: true }),
|
|
542
|
+
const { items, truncation } = getClient().read.search(args.query, {
|
|
543
|
+
limit,
|
|
544
|
+
...validated.filter,
|
|
385
545
|
...(args.project !== undefined && { project: args.project }),
|
|
386
546
|
...(args.area !== undefined && { area: args.area }),
|
|
387
547
|
...(args.type !== undefined && { type: args.type }),
|
|
388
548
|
...(args.logged === true && { logged: true }),
|
|
389
549
|
...(args.trashed === true && { trashed: true }),
|
|
390
550
|
...(args.all === true && { all: true }),
|
|
391
|
-
})
|
|
392
|
-
return
|
|
551
|
+
});
|
|
552
|
+
return truncatedResult(items, truncation);
|
|
393
553
|
}));
|
|
394
554
|
server.registerTool("changes_since", {
|
|
395
555
|
description: "List items created or modified since a moment — including trashed, logged, and " +
|
|
396
556
|
"repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
|
|
397
|
-
"areas, and checklist items do not mark the containing item as modified."
|
|
557
|
+
"areas, and checklist items do not mark the containing item as modified. " +
|
|
558
|
+
OMIT_EMPTY_NOTE,
|
|
398
559
|
inputSchema: {
|
|
399
560
|
since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
|
|
400
561
|
...limitShape,
|
|
401
562
|
},
|
|
402
563
|
annotations: READ_ONLY,
|
|
403
|
-
}, async (args) =>
|
|
564
|
+
}, async (args) => readGuard(() => {
|
|
404
565
|
const limit = resolveLimit(args);
|
|
405
566
|
if (limit === "conflict")
|
|
406
567
|
return usage("pass at most one of limit / all");
|
|
@@ -408,34 +569,54 @@ export function createThingsMcpServer(options = {}) {
|
|
|
408
569
|
if (Number.isNaN(since.getTime())) {
|
|
409
570
|
return usage(`since is not a parseable date: ${args.since}`);
|
|
410
571
|
}
|
|
411
|
-
const {
|
|
412
|
-
return
|
|
572
|
+
const { items, truncation } = getClient().read.changes({ since, limit });
|
|
573
|
+
return truncatedResult(items, truncation);
|
|
413
574
|
}));
|
|
414
575
|
server.registerTool("get_item", {
|
|
415
576
|
description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
|
|
416
577
|
"(direct and inherited), checklist with per-item state, repeat schedule, and its " +
|
|
417
|
-
"project/area/heading."
|
|
578
|
+
"project/area/heading. " +
|
|
579
|
+
OMIT_EMPTY_NOTE,
|
|
418
580
|
inputSchema: { uuid: z.string() },
|
|
419
581
|
annotations: READ_ONLY,
|
|
420
|
-
}, async (args) =>
|
|
582
|
+
}, async (args) => readGuard(() => {
|
|
421
583
|
const item = getClient().read.byUuid(args.uuid);
|
|
422
584
|
return item === null
|
|
423
|
-
? errorResult({ code: "not-found", message:
|
|
424
|
-
:
|
|
585
|
+
? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
|
|
586
|
+
: readResult(item);
|
|
425
587
|
}));
|
|
426
588
|
server.registerTool("get_project", {
|
|
427
|
-
description: "One project's full contents: metadata plus its to-dos grouped under their headings."
|
|
428
|
-
|
|
589
|
+
description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
|
|
590
|
+
"The tag filters keep only the child to-dos matching by their own tags (a heading left " +
|
|
591
|
+
"with none is dropped). " +
|
|
592
|
+
OMIT_EMPTY_NOTE,
|
|
593
|
+
inputSchema: {
|
|
594
|
+
uuid: z.string().describe("Project uuid or unique name"),
|
|
595
|
+
...tagOnlyShape,
|
|
596
|
+
overdue: z
|
|
597
|
+
.boolean()
|
|
598
|
+
.optional()
|
|
599
|
+
.describe("Keep only child to-dos past their deadline (due today is not overdue); headings left empty are dropped"),
|
|
600
|
+
},
|
|
429
601
|
annotations: READ_ONLY,
|
|
430
|
-
}, async (args) =>
|
|
602
|
+
}, async (args) => readGuard(() => {
|
|
603
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
604
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
605
|
+
return readResult(getClient().read.projectView(args.uuid, {
|
|
606
|
+
overdue: args.overdue === true,
|
|
607
|
+
...tagFilterFields(tagPresence(args)),
|
|
608
|
+
}));
|
|
609
|
+
}));
|
|
431
610
|
server.registerTool("get_area", {
|
|
432
611
|
description: "One area's contents: metadata plus its direct to-dos (active first), its " +
|
|
433
|
-
"projects in
|
|
612
|
+
"projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
|
|
434
613
|
`The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
|
|
435
614
|
"by default (project_limit / area_limit adjust them; all: true lifts both); the " +
|
|
436
|
-
"second result block reports the counts."
|
|
615
|
+
"second result block reports the counts. " +
|
|
616
|
+
OMIT_EMPTY_NOTE,
|
|
437
617
|
inputSchema: {
|
|
438
618
|
ref: z.string().describe("Area uuid or unique name"),
|
|
619
|
+
...tagOnlyShape,
|
|
439
620
|
area_limit: z
|
|
440
621
|
.number()
|
|
441
622
|
.int()
|
|
@@ -448,28 +629,70 @@ export function createThingsMcpServer(options = {}) {
|
|
|
448
629
|
.min(1)
|
|
449
630
|
.optional()
|
|
450
631
|
.describe(`maximum project rows to return (default ${AREA_PREVIEW_LIMIT})`),
|
|
632
|
+
overdue: z
|
|
633
|
+
.boolean()
|
|
634
|
+
.optional()
|
|
635
|
+
.describe("Keep only rows (loose to-dos AND child projects) whose own deadline is past (due today is not overdue); no descent into project contents"),
|
|
451
636
|
all: z.boolean().optional().describe("return both sections in full (no caps)"),
|
|
452
637
|
},
|
|
453
638
|
annotations: READ_ONLY,
|
|
454
|
-
}, async (args) =>
|
|
639
|
+
}, async (args) => readGuard(() => {
|
|
640
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
641
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
455
642
|
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
456
643
|
const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
457
644
|
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
458
645
|
return usage("pass at most one of area_limit/project_limit / all");
|
|
459
646
|
}
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
647
|
+
const { view, grouped } = getClient().read.areaView(args.ref, {
|
|
648
|
+
overdue: args.overdue === true,
|
|
649
|
+
...tagFilterFields(tagPresence(args)),
|
|
650
|
+
areaLimit,
|
|
651
|
+
projectLimit,
|
|
652
|
+
});
|
|
653
|
+
return groupedResult(view, grouped);
|
|
463
654
|
}));
|
|
464
655
|
server.registerTool("list_collections", {
|
|
465
656
|
description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
|
|
466
|
-
"refresh the inventory summarized in the server instructions."
|
|
467
|
-
|
|
657
|
+
"refresh the inventory summarized in the server instructions. The tag filters scope " +
|
|
658
|
+
"the projects list by each project's own tags (areas/tags reject them). " +
|
|
659
|
+
OMIT_EMPTY_NOTE,
|
|
660
|
+
inputSchema: {
|
|
661
|
+
kind: z.enum(["projects", "areas", "tags"]),
|
|
662
|
+
...tagOnlyShape,
|
|
663
|
+
overdue: z
|
|
664
|
+
.boolean()
|
|
665
|
+
.optional()
|
|
666
|
+
.describe("projects only: keep only projects past their deadline (due today is not overdue); areas/tags carry no deadline and reject it"),
|
|
667
|
+
},
|
|
468
668
|
annotations: READ_ONLY,
|
|
469
|
-
}, async (args) =>
|
|
669
|
+
}, async (args) => readGuard(() => {
|
|
470
670
|
const c = getClient();
|
|
471
|
-
|
|
472
|
-
|
|
671
|
+
// areas/tags are not dated entities and have no per-row tag list to
|
|
672
|
+
// filter — overdue and the tag filters are vacuous there, rejected
|
|
673
|
+
// fail-closed (the same style read_view uses for the wrong views). The
|
|
674
|
+
// decision derives from the contract: only the `projects` list carries
|
|
675
|
+
// a deadline (overdue) and per-row (inheritance-inclusive) tags; the
|
|
676
|
+
// `areas` list rejects both, and `tags` has no contract row.
|
|
677
|
+
const kindSpec = args.kind === "projects"
|
|
678
|
+
? FILTER_CONTRACT.projects
|
|
679
|
+
: args.kind === "areas"
|
|
680
|
+
? FILTER_CONTRACT.areas
|
|
681
|
+
: null;
|
|
682
|
+
if (args.overdue === true && (kindSpec === null || !kindSpec.overdue)) {
|
|
683
|
+
return usage(`overdue applies only to projects, not ${args.kind}`);
|
|
684
|
+
}
|
|
685
|
+
if ((kindSpec === null || kindSpec.tag === "rejected") &&
|
|
686
|
+
(hasTagPresence(tagPresence(args)) || args.untagged === true)) {
|
|
687
|
+
return usage(`the tag filters apply only to projects, not ${args.kind}`);
|
|
688
|
+
}
|
|
689
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
690
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
691
|
+
return readResult(args.kind === "projects"
|
|
692
|
+
? c.read.projects({
|
|
693
|
+
overdue: args.overdue === true,
|
|
694
|
+
...tagFilterFields(tagPresence(args)),
|
|
695
|
+
})
|
|
473
696
|
: args.kind === "areas"
|
|
474
697
|
? c.read.areas()
|
|
475
698
|
: c.read.tags());
|
|
@@ -479,7 +702,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
479
702
|
server.registerTool("add_todo", {
|
|
480
703
|
description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
|
|
481
704
|
"deadline, tag it, give it a checklist, and place it in a project or area " +
|
|
482
|
-
"(optionally under an existing heading).
|
|
705
|
+
"(optionally under an existing heading). A reminder " +
|
|
483
706
|
"requires when = today, evening, or a date. Adding into a completed or canceled " +
|
|
484
707
|
"project reopens that project — pass acknowledge_project_reopen to confirm.",
|
|
485
708
|
inputSchema: {
|
|
@@ -488,7 +711,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
488
711
|
when: whenSchema,
|
|
489
712
|
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
490
713
|
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
491
|
-
tags: z.array(z.string()).optional().describe(
|
|
714
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
492
715
|
checklist_items: z.array(z.string()).optional(),
|
|
493
716
|
project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
|
|
494
717
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
@@ -497,21 +720,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
497
720
|
.boolean()
|
|
498
721
|
.optional()
|
|
499
722
|
.describe("Confirm adding into a completed/canceled project (this reopens it)"),
|
|
723
|
+
...createTagsShape,
|
|
500
724
|
...dryRunShape,
|
|
501
725
|
},
|
|
502
726
|
annotations: NON_DESTRUCTIVE,
|
|
503
|
-
}, async (args) => guard(async () =>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
727
|
+
}, async (args) => guard(async () => {
|
|
728
|
+
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
729
|
+
if (sugar.kind === "error")
|
|
730
|
+
return usage(sugar.message);
|
|
731
|
+
const when = sugar.kind === "split" ? sugar.when : args.when;
|
|
732
|
+
const reminder = sugar.kind === "split" ? sugar.reminder : args.reminder;
|
|
733
|
+
return mutationResult(await getClient().write.addTodo({
|
|
734
|
+
title: args.title,
|
|
735
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
736
|
+
...(when !== undefined && { when: when }),
|
|
737
|
+
...(reminder !== undefined && { reminder }),
|
|
738
|
+
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
739
|
+
...(args.tags !== undefined && { tags: args.tags }),
|
|
740
|
+
...(args.checklist_items !== undefined && { checklistItems: args.checklist_items }),
|
|
741
|
+
...(args.project !== undefined && { project: containerRef(args.project) }),
|
|
742
|
+
...(args.area !== undefined && { area: containerRef(args.area) }),
|
|
743
|
+
...(args.heading !== undefined && { heading: args.heading }),
|
|
744
|
+
}, writeOptions(args)));
|
|
745
|
+
}));
|
|
515
746
|
server.registerTool("update_todo", {
|
|
516
747
|
description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
|
|
517
748
|
"append_notes/prepend_notes add a line to the existing notes (exclusive with " +
|
|
@@ -545,13 +776,18 @@ export function createThingsMcpServer(options = {}) {
|
|
|
545
776
|
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
546
777
|
return usage("pass at most one of deadline / clear_deadline");
|
|
547
778
|
}
|
|
779
|
+
const sugar = splitWhenSugar(args.when, args.reminder !== undefined, MCP_WHEN_LABELS);
|
|
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;
|
|
548
784
|
return mutationResult(await getClient().write.updateTodo(args.uuid, {
|
|
549
785
|
...(args.title !== undefined && { title: args.title }),
|
|
550
786
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
551
787
|
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
552
788
|
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
553
|
-
...(
|
|
554
|
-
...(
|
|
789
|
+
...(when !== undefined && { when: when }),
|
|
790
|
+
...(reminder !== undefined && { reminder }),
|
|
555
791
|
...(args.clear_reminder === true && { reminder: null }),
|
|
556
792
|
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
557
793
|
...(args.clear_deadline === true && { deadline: null }),
|
|
@@ -618,11 +854,12 @@ export function createThingsMcpServer(options = {}) {
|
|
|
618
854
|
server.registerTool("set_tags", {
|
|
619
855
|
description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
|
|
620
856
|
"the given list — an empty list removes all tags; mode 'add' merges with the current " +
|
|
621
|
-
"tags. Tags must
|
|
857
|
+
"tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
|
|
622
858
|
inputSchema: {
|
|
623
859
|
uuid: z.string(),
|
|
624
|
-
tags: z.array(z.string()).describe(
|
|
860
|
+
tags: z.array(z.string()).describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
625
861
|
mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
|
|
862
|
+
...createTagsShape,
|
|
626
863
|
...dryRunShape,
|
|
627
864
|
},
|
|
628
865
|
annotations: NON_DESTRUCTIVE,
|
|
@@ -847,6 +1084,265 @@ export function createThingsMcpServer(options = {}) {
|
|
|
847
1084
|
? jsonResult(r)
|
|
848
1085
|
: mutationResult(r.heading);
|
|
849
1086
|
}));
|
|
1087
|
+
// -------------------------------------------- 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
|
+
const WEEKDAY_ENUM = z.enum([
|
|
1098
|
+
"sunday",
|
|
1099
|
+
"monday",
|
|
1100
|
+
"tuesday",
|
|
1101
|
+
"wednesday",
|
|
1102
|
+
"thursday",
|
|
1103
|
+
"friday",
|
|
1104
|
+
"saturday",
|
|
1105
|
+
]);
|
|
1106
|
+
// The base rule (also used by create_repeating_project, which stays minimal —
|
|
1107
|
+
// its own `deadline` is the project's due DATE, not the repeat's Add-deadlines).
|
|
1108
|
+
const baseRepeatShape = {
|
|
1109
|
+
frequency: z.enum(["daily", "weekly", "monthly", "yearly"]).describe("How often it repeats"),
|
|
1110
|
+
interval: z.number().int().min(1).max(99).describe("Every N units (1–99)"),
|
|
1111
|
+
};
|
|
1112
|
+
const repeatRuleShape = {
|
|
1113
|
+
...baseRepeatShape,
|
|
1114
|
+
after_completion: z
|
|
1115
|
+
.boolean()
|
|
1116
|
+
.optional()
|
|
1117
|
+
.describe("Repeat N units AFTER each occurrence is completed, instead of on a fixed schedule"),
|
|
1118
|
+
weekdays: z.array(WEEKDAY_ENUM).optional().describe("Weekly only: the weekdays it repeats on"),
|
|
1119
|
+
monthly_day: z
|
|
1120
|
+
.union([z.number().int(), z.literal("last")])
|
|
1121
|
+
.optional()
|
|
1122
|
+
.describe('Monthly/yearly only: a day of the month (1–31, or "last")'),
|
|
1123
|
+
monthly_weekday: WEEKDAY_ENUM.optional().describe("Monthly/yearly only: a weekday for an nth-weekday rule (with monthly_ordinal)"),
|
|
1124
|
+
monthly_ordinal: z
|
|
1125
|
+
.union([z.number().int(), z.literal("last")])
|
|
1126
|
+
.optional()
|
|
1127
|
+
.describe('Monthly/yearly only: which weekday (1–5, or "last") with monthly_weekday'),
|
|
1128
|
+
yearly_month: z
|
|
1129
|
+
.number()
|
|
1130
|
+
.int()
|
|
1131
|
+
.min(1)
|
|
1132
|
+
.max(12)
|
|
1133
|
+
.optional()
|
|
1134
|
+
.describe("Yearly only: the month (1–12)"),
|
|
1135
|
+
ends_after: z.number().int().optional().describe("Stop after N occurrences"),
|
|
1136
|
+
ends_on: z.string().optional().describe("YYYY-MM-DD — stop after this date"),
|
|
1137
|
+
reminder: z.string().optional().describe("HH:mm — a reminder time on each occurrence"),
|
|
1138
|
+
deadline: z.boolean().optional().describe("Give each occurrence a deadline"),
|
|
1139
|
+
start_days_earlier: z
|
|
1140
|
+
.number()
|
|
1141
|
+
.int()
|
|
1142
|
+
.optional()
|
|
1143
|
+
.describe("With deadline: start each occurrence N days before its deadline"),
|
|
1144
|
+
};
|
|
1145
|
+
// oxlint-disable-next-line consistent-function-scoping -- kept beside repeatRuleShape it mirrors
|
|
1146
|
+
const repeatExtras = (a, frequency) => {
|
|
1147
|
+
const fields = {};
|
|
1148
|
+
if (a.after_completion === true)
|
|
1149
|
+
fields.afterCompletion = true;
|
|
1150
|
+
if (a.weekdays !== undefined)
|
|
1151
|
+
fields.weekdays = a.weekdays;
|
|
1152
|
+
const anchor = a.monthly_day !== undefined
|
|
1153
|
+
? { day: a.monthly_day }
|
|
1154
|
+
: a.monthly_weekday !== undefined || a.monthly_ordinal !== undefined
|
|
1155
|
+
? { weekday: a.monthly_weekday, ordinal: a.monthly_ordinal }
|
|
1156
|
+
: undefined;
|
|
1157
|
+
if (frequency === "monthly" && anchor !== undefined)
|
|
1158
|
+
fields.monthly = anchor;
|
|
1159
|
+
if (frequency === "yearly" && (a.yearly_month !== undefined || anchor !== undefined)) {
|
|
1160
|
+
fields.yearly = { month: a.yearly_month, ...anchor };
|
|
1161
|
+
}
|
|
1162
|
+
if (a.ends_after !== undefined)
|
|
1163
|
+
fields.ends = { kind: "after", count: a.ends_after };
|
|
1164
|
+
else if (a.ends_on !== undefined)
|
|
1165
|
+
fields.ends = { kind: "on-date", date: a.ends_on };
|
|
1166
|
+
if (a.reminder !== undefined)
|
|
1167
|
+
fields.reminder = a.reminder;
|
|
1168
|
+
if (a.deadline === true)
|
|
1169
|
+
fields.deadline = true;
|
|
1170
|
+
if (a.start_days_earlier !== undefined)
|
|
1171
|
+
fields.startDaysEarlier = a.start_days_earlier;
|
|
1172
|
+
return fields;
|
|
1173
|
+
};
|
|
1174
|
+
server.registerTool("make_repeating", {
|
|
1175
|
+
description: "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new recurring " +
|
|
1176
|
+
"series — the original disappears and a fresh repeating item takes its place, so it " +
|
|
1177
|
+
"cannot be undone. Set the frequency and interval, and optionally the weekday set, " +
|
|
1178
|
+
"monthly/yearly day, end bound, reminders, or deadline. Returns the new item's uuid.",
|
|
1179
|
+
inputSchema: {
|
|
1180
|
+
uuid: z.string().describe("The to-do to make repeating"),
|
|
1181
|
+
...repeatRuleShape,
|
|
1182
|
+
...driveGuiShape,
|
|
1183
|
+
...dryRunShape,
|
|
1184
|
+
},
|
|
1185
|
+
annotations: DESTRUCTIVE,
|
|
1186
|
+
}, async (args) => guard(async () => mutationResult(await getClient().write.run("todo.make-repeating", {
|
|
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.",
|
|
1196
|
+
inputSchema: {
|
|
1197
|
+
uuid: z.string().describe("The repeating to-do to reschedule"),
|
|
1198
|
+
...repeatRuleShape,
|
|
1199
|
+
...driveGuiShape,
|
|
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
|
|
1263
|
+
.string()
|
|
1264
|
+
.optional()
|
|
1265
|
+
.describe("Place it immediately above this area (uuid or unique name)"),
|
|
1266
|
+
after: z
|
|
1267
|
+
.string()
|
|
1268
|
+
.optional()
|
|
1269
|
+
.describe("Place it immediately below this area (uuid or unique name)"),
|
|
1270
|
+
position: z
|
|
1271
|
+
.enum(["first", "last"])
|
|
1272
|
+
.optional()
|
|
1273
|
+
.describe("Move it to the top or bottom of the area list"),
|
|
1274
|
+
...driveGuiShape,
|
|
1275
|
+
...dryRunShape,
|
|
1276
|
+
},
|
|
1277
|
+
annotations: NON_DESTRUCTIVE,
|
|
1278
|
+
}, async (args) => guard(async () => mutationResult(await getClient().write.run("area.reorder", {
|
|
1279
|
+
target: args.target,
|
|
1280
|
+
...(args.before !== undefined && { before: args.before }),
|
|
1281
|
+
...(args.after !== undefined && { after: args.after }),
|
|
1282
|
+
...(args.position !== undefined && { position: args.position }),
|
|
1283
|
+
}, writeOptions(args)))));
|
|
1284
|
+
server.registerTool("make_project_repeating", {
|
|
1285
|
+
description: "Turn an existing project into a repeating one. This REPLACES the project with a new " +
|
|
1286
|
+
"recurring series — the original disappears and a fresh repeating project takes its place " +
|
|
1287
|
+
"(its area is kept), so it cannot be undone. An area-less project scheduled for Anytime is " +
|
|
1288
|
+
"moved to Someday first (a cleanup-free intermediate step, shown by dry_run). Set the " +
|
|
1289
|
+
"frequency and interval, and optionally the weekday set, monthly/yearly day, end bound, " +
|
|
1290
|
+
"reminders, or deadline. Returns the new project's uuid.",
|
|
1291
|
+
inputSchema: {
|
|
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"),
|
|
1335
|
+
...driveGuiShape,
|
|
1336
|
+
...dryRunShape,
|
|
1337
|
+
},
|
|
1338
|
+
annotations: DESTRUCTIVE,
|
|
1339
|
+
}, async (args) => guard(async () => {
|
|
1340
|
+
const item = getClient().read.byUuid(args.uuid);
|
|
1341
|
+
if (item === null)
|
|
1342
|
+
throw new RangeError(`no item with uuid ${args.uuid}`);
|
|
1343
|
+
const op = item.type === "heading" ? "heading.convert-to-project" : "todo.convert-to-project";
|
|
1344
|
+
return mutationResult(await getClient().write.run(op, { uuid: args.uuid }, writeOptions(args)));
|
|
1345
|
+
}));
|
|
850
1346
|
server.registerTool("duplicate_item", {
|
|
851
1347
|
description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
|
|
852
1348
|
"includes its children. Not available for repeating items.",
|
|
@@ -887,7 +1383,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
887
1383
|
"clear_reminder works while the project is scheduled for today or this evening; a " +
|
|
888
1384
|
"reminder on a future date can only be changed, not cleared.",
|
|
889
1385
|
inputSchema: {
|
|
890
|
-
uuid: z.string(),
|
|
1386
|
+
uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
|
|
891
1387
|
title: z.string().optional(),
|
|
892
1388
|
notes: z.string().optional().describe("Replaces the whole notes body"),
|
|
893
1389
|
append_notes: z.string().optional(),
|
|
@@ -931,7 +1427,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
931
1427
|
"canceled project; its children stay completed/canceled unless restore_children " +
|
|
932
1428
|
"also reopens the ones that were resolved together with the project.",
|
|
933
1429
|
inputSchema: {
|
|
934
|
-
uuid: z.string().describe(
|
|
1430
|
+
uuid: z.string().describe(`The project (${REF_FORMAT})`),
|
|
935
1431
|
status: z.enum(["completed", "canceled", "open"]),
|
|
936
1432
|
children: z
|
|
937
1433
|
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
@@ -976,7 +1472,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
976
1472
|
description: "Move a project into an area, or detach it from its current area. Pass exactly " +
|
|
977
1473
|
"one of area / detach. The project's status and schedule are unaffected.",
|
|
978
1474
|
inputSchema: {
|
|
979
|
-
uuid: z.string().describe(
|
|
1475
|
+
uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
|
|
980
1476
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
981
1477
|
detach: z.boolean().optional().describe("Remove the current area assignment"),
|
|
982
1478
|
...dryRunShape,
|
|
@@ -993,20 +1489,26 @@ export function createThingsMcpServer(options = {}) {
|
|
|
993
1489
|
}));
|
|
994
1490
|
// ----------------------------------------------------------------- areas
|
|
995
1491
|
server.registerTool("add_area", {
|
|
996
|
-
description: "Create an area. Tags
|
|
1492
|
+
description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
|
|
997
1493
|
inputSchema: {
|
|
998
1494
|
title: z.string(),
|
|
999
|
-
tags: z.array(z.string()).optional().describe(
|
|
1495
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
1496
|
+
...createTagsShape,
|
|
1000
1497
|
...dryRunShape,
|
|
1001
1498
|
},
|
|
1002
1499
|
annotations: NON_DESTRUCTIVE,
|
|
1003
1500
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
|
|
1004
1501
|
server.registerTool("update_area", {
|
|
1005
|
-
description: "Rename an area and/or replace its tags (the full set
|
|
1502
|
+
description: "Rename an area and/or replace its tags (the full set). Tags must exist unless " +
|
|
1503
|
+
"create_tags is set.",
|
|
1006
1504
|
inputSchema: {
|
|
1007
1505
|
target: z.string().describe(`Area to update (${REF_FORMAT})`),
|
|
1008
1506
|
title: z.string().optional().describe("New name"),
|
|
1009
|
-
tags: z
|
|
1507
|
+
tags: z
|
|
1508
|
+
.array(z.string())
|
|
1509
|
+
.optional()
|
|
1510
|
+
.describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
|
|
1511
|
+
...createTagsShape,
|
|
1010
1512
|
...dryRunShape,
|
|
1011
1513
|
},
|
|
1012
1514
|
annotations: NON_DESTRUCTIVE,
|
|
@@ -1133,10 +1635,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1133
1635
|
params: z.record(z.string(), z.unknown()),
|
|
1134
1636
|
options: z
|
|
1135
1637
|
.object({
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1638
|
+
acknowledge_checklist_reset: z.boolean().optional(),
|
|
1639
|
+
acknowledge_project_reopen: z.boolean().optional(),
|
|
1640
|
+
dangerously_permanent: z.boolean().optional(),
|
|
1641
|
+
acknowledge_tag_subtree: z.boolean().optional(),
|
|
1140
1642
|
})
|
|
1141
1643
|
.optional(),
|
|
1142
1644
|
}))
|
|
@@ -1146,10 +1648,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1146
1648
|
},
|
|
1147
1649
|
annotations: DESTRUCTIVE,
|
|
1148
1650
|
}, async (args) => guard(async () => {
|
|
1149
|
-
|
|
1651
|
+
// Map each op's snake_case acknowledgements into the batch engine's
|
|
1652
|
+
// option names, and apply the process-wide disruption ceiling — batch
|
|
1653
|
+
// takes it per-op, and MCP exposes no per-op override, so it is uniform.
|
|
1654
|
+
const ceiling = options.maxDisruption;
|
|
1655
|
+
const ops = args.ops.map((op) => {
|
|
1656
|
+
const o = op.options;
|
|
1657
|
+
const opts = {
|
|
1658
|
+
...(o?.acknowledge_checklist_reset === true && { acknowledgeChecklistReset: true }),
|
|
1659
|
+
...(o?.acknowledge_project_reopen === true && { acknowledgeProjectReopen: true }),
|
|
1660
|
+
...(o?.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1661
|
+
...(o?.acknowledge_tag_subtree === true && { acknowledgeTagSubtree: true }),
|
|
1662
|
+
...(ceiling !== undefined && { maxDisruption: ceiling }),
|
|
1663
|
+
};
|
|
1664
|
+
return {
|
|
1665
|
+
op: op.op,
|
|
1666
|
+
params: op.params,
|
|
1667
|
+
...(Object.keys(opts).length > 0 && { options: opts }),
|
|
1668
|
+
};
|
|
1669
|
+
});
|
|
1670
|
+
const results = await getClient().write.batch(ops, {
|
|
1150
1671
|
...(args.dry_run === true && { dryRun: true }),
|
|
1151
1672
|
...(args.fail_fast === true && { failFast: true }),
|
|
1152
|
-
actor:
|
|
1673
|
+
actor: mcpActor(),
|
|
1153
1674
|
});
|
|
1154
1675
|
return jsonResult(results);
|
|
1155
1676
|
}));
|
|
@@ -1157,7 +1678,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1157
1678
|
description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
|
|
1158
1679
|
"area-less someday projects — one kind per call), a " +
|
|
1159
1680
|
"project's to-dos, a project's headings (scope=headings — children move with " +
|
|
1160
|
-
"their heading), an area, or the top-level
|
|
1681
|
+
"their heading), an area, or the top-level projects (scope=projects — " +
|
|
1161
1682
|
"each project takes a brief someday/anytime round-trip) — the given uuids move " +
|
|
1162
1683
|
"to the TOP in the given order; unlisted items keep their relative order below. " +
|
|
1163
1684
|
"Today/inbox/someday/project/headings/area ordering must first be enabled once " +
|
|
@@ -1191,24 +1712,59 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1191
1712
|
...(args.strategy !== undefined && { strategy: args.strategy }),
|
|
1192
1713
|
}, writeOptions(args)))));
|
|
1193
1714
|
server.registerTool("undo", {
|
|
1194
|
-
description: "Undo the last N changes
|
|
1195
|
-
"
|
|
1196
|
-
"
|
|
1197
|
-
|
|
1198
|
-
"
|
|
1199
|
-
"
|
|
1715
|
+
description: "Undo the last N changes, newest first (changes made directly in the Things app " +
|
|
1716
|
+
"cannot be undone here). By default this undoes only changes made through THIS " +
|
|
1717
|
+
"connection — this client's own writes; it will not touch the user's own edits, or " +
|
|
1718
|
+
'another client\'s, unless you pass by="*" (all authors) or a specific author name; ' +
|
|
1719
|
+
"pass a txn token to undo one exact change. Some changes cannot be reversed — " +
|
|
1720
|
+
"permanent deletions, or changes whose prior state is unknown — and are reported as " +
|
|
1721
|
+
"irreversible; a to-do brought back from an undone delete returns to the Inbox " +
|
|
1722
|
+
"without its schedule. Undoing the creation of an area or tag deletes it " +
|
|
1723
|
+
"permanently — requires dangerously_permanent. An undo is refused when the item " +
|
|
1724
|
+
"changed outside this interface since (its list or project, status, schedule, " +
|
|
1725
|
+
"trashed state, or a field like the title moved) — pass " +
|
|
1726
|
+
"acknowledge_out_of_band_changes to overwrite it anyway.",
|
|
1200
1727
|
inputSchema: {
|
|
1201
1728
|
last: z.number().int().min(1).optional().describe("How many to unwind (default 1)"),
|
|
1729
|
+
by: z
|
|
1730
|
+
.string()
|
|
1731
|
+
.optional()
|
|
1732
|
+
.describe('Whose changes to undo: an exact author name, or "*" for everyone. Defaults to ' +
|
|
1733
|
+
"this client's own writes (only changes made through this connection). Matches " +
|
|
1734
|
+
"exactly. Selects WHICH changes to undo, and never a change already undone. Not " +
|
|
1735
|
+
"combinable with txn."),
|
|
1736
|
+
txn: z
|
|
1737
|
+
.string()
|
|
1738
|
+
.optional()
|
|
1739
|
+
.describe("Undo exactly the one change with this undo token (the undoToken field returned " +
|
|
1740
|
+
"by the mutation); immune to interleaving. Not combinable with last/by."),
|
|
1202
1741
|
dangerously_permanent: z.boolean().optional(),
|
|
1742
|
+
acknowledge_out_of_band_changes: z
|
|
1743
|
+
.boolean()
|
|
1744
|
+
.optional()
|
|
1745
|
+
.describe("Proceed even when the item changed outside this interface since (in the Things app " +
|
|
1746
|
+
"or by another tool) — overwrites whatever the out-of-band change left, instead of " +
|
|
1747
|
+
"refusing."),
|
|
1203
1748
|
...dryRunShape,
|
|
1204
1749
|
},
|
|
1205
1750
|
annotations: DESTRUCTIVE,
|
|
1206
1751
|
}, async (args) => guard(async () => {
|
|
1752
|
+
if (args.txn !== undefined && (args.last !== undefined || args.by !== undefined)) {
|
|
1753
|
+
return usage("txn cannot be combined with last or by");
|
|
1754
|
+
}
|
|
1207
1755
|
const items = await getClient().write.undo({
|
|
1208
1756
|
...(args.last !== undefined && { last: args.last }),
|
|
1757
|
+
...(args.txn !== undefined && { txn: args.txn }),
|
|
1758
|
+
// Asymmetric default: agents must not clobber the user's own edits (or
|
|
1759
|
+
// another client's) without explicitly opting in via by:"*". Scoped to
|
|
1760
|
+
// this client's own handshake identity, so each session undoes its own.
|
|
1761
|
+
...(args.txn === undefined && { by: args.by ?? mcpActor() }),
|
|
1209
1762
|
...(args.dry_run === true && { dryRun: true }),
|
|
1210
1763
|
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1211
|
-
|
|
1764
|
+
...(args.acknowledge_out_of_band_changes === true && {
|
|
1765
|
+
acknowledgeOutOfBandChanges: true,
|
|
1766
|
+
}),
|
|
1767
|
+
actor: mcpActor(),
|
|
1212
1768
|
});
|
|
1213
1769
|
return jsonResult(items);
|
|
1214
1770
|
}));
|
|
@@ -1226,8 +1782,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1226
1782
|
server.registerTool("doctor", {
|
|
1227
1783
|
description: "Check the environment: whether the Things app and its database are reachable, " +
|
|
1228
1784
|
"whether changes can be made, any one-time setup still needed (macOS permissions, " +
|
|
1229
|
-
"the app's 'Enable Things URLs' setting),
|
|
1230
|
-
"the last successful write,
|
|
1785
|
+
"the app's 'Enable Things URLs' setting), whether the environment changed since " +
|
|
1786
|
+
"the last successful write, and a sync-health summary (whether the app is running, how " +
|
|
1787
|
+
"recently the data changed, and — when a Things Cloud account is attached — the last " +
|
|
1788
|
+
"sync attempt), with steps to fix.",
|
|
1231
1789
|
inputSchema: {
|
|
1232
1790
|
probe_automation: z
|
|
1233
1791
|
.boolean()
|