things-api 0.8.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 +31 -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 +34 -2
- package/dist/cli/commands/area.js +140 -28
- 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 +23 -1
- package/dist/cli/commands/project.js +82 -15
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +6 -125
- package/dist/cli/commands/reads.js +649 -657
- 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.d.ts +1 -1
- package/dist/cli/commands/show.js +169 -18
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/snapshot.js +1 -1
- package/dist/cli/commands/snapshot.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +21 -9
- 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 +29 -0
- package/dist/cli/did-you-mean.js +53 -0
- package/dist/cli/did-you-mean.js.map +1 -0
- 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 +67 -7
- package/dist/cli/glyphs.js +202 -19
- 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 +36 -21
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +39 -0
- package/dist/cli/period.js +141 -0
- package/dist/cli/period.js.map +1 -0
- package/dist/cli/read-driver.d.ts +83 -0
- package/dist/cli/read-driver.js +215 -0
- package/dist/cli/read-driver.js.map +1 -0
- package/dist/cli/render.d.ts +246 -0
- package/dist/cli/render.js +806 -0
- package/dist/cli/render.js.map +1 -0
- package/dist/cli/resolve-invocation.d.ts +116 -0
- package/dist/cli/resolve-invocation.js +261 -0
- package/dist/cli/resolve-invocation.js.map +1 -0
- package/dist/cli/shell-quote.d.ts +9 -0
- package/dist/cli/shell-quote.js +12 -0
- package/dist/cli/shell-quote.js.map +1 -0
- 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 +146 -24
- package/dist/client.js +106 -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 +136 -1
- 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 +850 -128
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +40 -7
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +7 -0
- package/dist/model/mappers.js +20 -3
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/occurrences.d.ts +6 -1
- package/dist/model/occurrences.js +9 -5
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +17 -8
- package/dist/model/recurrence.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/caps.d.ts +14 -0
- package/dist/read/caps.js +21 -0
- package/dist/read/caps.js.map +1 -0
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/log-boundary.d.ts +9 -4
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +61 -0
- package/dist/read/predicates.js +78 -0
- package/dist/read/predicates.js.map +1 -0
- 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 +156 -8
- package/dist/read/queries.js +261 -38
- package/dist/read/queries.js.map +1 -1
- package/dist/read/search-rank.d.ts +36 -0
- package/dist/read/search-rank.js +31 -0
- package/dist/read/search-rank.js.map +1 -0
- 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/show-target.d.ts +13 -4
- package/dist/read/show-target.js +11 -2
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/sidebar-order.d.ts +19 -0
- package/dist/read/sidebar-order.js +93 -0
- package/dist/read/sidebar-order.js.map +1 -0
- 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/truncation.js +217 -0
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +177 -19
- package/dist/read/views.js +390 -213
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +42 -0
- package/dist/surface-copy.js +55 -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 -5
- 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 +494 -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/mcp/server.js
CHANGED
|
@@ -13,16 +13,74 @@
|
|
|
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 { DATE_FORMAT, REF_FORMAT, REMINDER_FORMAT, WHEN_VALUES } from "../surface-copy.js";
|
|
20
|
-
import { capabilitiesTable } from "../write/capabilities.js";
|
|
21
|
-
import { OPERATION_KINDS } from "../write/operations.js";
|
|
22
|
-
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";
|
|
23
17
|
function jsonResult(data) {
|
|
24
18
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
25
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
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A read result carrying truncation metadata: the data (already limited) in
|
|
31
|
+
* the first content block, and a second block with the {@link Truncation}
|
|
32
|
+
* numbers plus a one-line note the agent can read when rows were dropped.
|
|
33
|
+
*/
|
|
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`
|
|
37
|
+
: undefined;
|
|
38
|
+
return {
|
|
39
|
+
content: [
|
|
40
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
41
|
+
{ type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Grouped read result (anytime/someday): the per-block-truncated sections plus
|
|
47
|
+
* a second block carrying the {@link GroupedTruncation} counts and, when
|
|
48
|
+
* anything was hidden, a one-line note the agent can read.
|
|
49
|
+
*/
|
|
50
|
+
function groupedResult(data, grouped) {
|
|
51
|
+
const note = grouped.truncated
|
|
52
|
+
? "some blocks are previews — raise area_limit/project_limit for more per block, or all: true for every item"
|
|
53
|
+
: undefined;
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{ type: "text", text: JSON.stringify(omitEmpty(data)) },
|
|
57
|
+
{ type: "text", text: JSON.stringify({ grouped, ...(note !== undefined && { note }) }) },
|
|
58
|
+
],
|
|
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
|
+
}
|
|
75
|
+
/** Resolve MCP limit/all (flat read tools) into a row cap (null = every row). */
|
|
76
|
+
function resolveLimit(args) {
|
|
77
|
+
return resolveCap(args.limit, args.all, DEFAULT_LIST_LIMIT);
|
|
78
|
+
}
|
|
79
|
+
/** Shared limit/all input schema fragment for the flat read tools. */
|
|
80
|
+
const limitShape = {
|
|
81
|
+
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
82
|
+
all: z.boolean().optional().describe(ALL_DESC),
|
|
83
|
+
};
|
|
26
84
|
function errorResult(error) {
|
|
27
85
|
return { content: [{ type: "text", text: JSON.stringify(error) }], isError: true };
|
|
28
86
|
}
|
|
@@ -37,14 +95,14 @@ function mutationResult(result) {
|
|
|
37
95
|
return jsonResult(result);
|
|
38
96
|
case "blocked":
|
|
39
97
|
return errorResult({
|
|
40
|
-
code:
|
|
98
|
+
code: blockedCode(result),
|
|
41
99
|
message: result.detail,
|
|
42
100
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
43
101
|
remediation: result.remediation,
|
|
44
102
|
});
|
|
45
103
|
case "verify-failed":
|
|
46
104
|
return errorResult({
|
|
47
|
-
code:
|
|
105
|
+
code: verifyFailedCode(result),
|
|
48
106
|
message: result.detail,
|
|
49
107
|
...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
|
|
50
108
|
...(result.hint !== undefined && { remediation: result.hint }),
|
|
@@ -66,19 +124,73 @@ function mutationResult(result) {
|
|
|
66
124
|
const READ_ONLY = { readOnlyHint: true };
|
|
67
125
|
const NON_DESTRUCTIVE = { destructiveHint: false };
|
|
68
126
|
const DESTRUCTIVE = { destructiveHint: true };
|
|
69
|
-
|
|
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 = {
|
|
70
137
|
tag: z
|
|
71
|
-
.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()
|
|
149
|
+
.optional()
|
|
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)"),
|
|
152
|
+
};
|
|
153
|
+
const tagFilterShape = {
|
|
154
|
+
...tagOnlyShape,
|
|
155
|
+
overdue: z
|
|
156
|
+
.boolean()
|
|
72
157
|
.optional()
|
|
73
|
-
.describe(
|
|
74
|
-
exact_tag: z.boolean().optional().describe("Match only the named tag, not its nested children"),
|
|
158
|
+
.describe("Only open items past their deadline (due today is not overdue)"),
|
|
75
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";
|
|
76
176
|
const dryRunShape = {
|
|
77
177
|
dry_run: z.boolean().optional().describe("Preview the planned change without applying anything"),
|
|
78
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
|
+
};
|
|
79
189
|
const containerRef = (ref) => ({ uuid: ref, title: ref });
|
|
80
190
|
/** Cap on project titles inlined into the server instructions. */
|
|
81
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}`;
|
|
82
194
|
/**
|
|
83
195
|
* Live-inventory preamble: conventions plus the user's actual areas, tags,
|
|
84
196
|
* and open projects, read once at server start so models can reference real
|
|
@@ -103,13 +215,13 @@ function buildInstructions(getClient) {
|
|
|
103
215
|
"- Every write tool accepts dry_run: true to preview the change without applying it. " +
|
|
104
216
|
"Operations with cascading or permanent effects require the explicit confirmation " +
|
|
105
217
|
"parameter named in their description; refused calls return an error saying what to pass.",
|
|
218
|
+
`- Read results are compact: ${OMIT_EMPTY_NOTE}`,
|
|
106
219
|
];
|
|
107
220
|
try {
|
|
108
221
|
const c = getClient();
|
|
109
222
|
const areas = c.read.areas();
|
|
110
223
|
const tags = c.read.tags();
|
|
111
224
|
const projects = c.read.projects();
|
|
112
|
-
const tagLabel = (t) => t.parent === null ? t.title : `${t.parent.title} > ${t.title}`;
|
|
113
225
|
const shown = projects.slice(0, INSTRUCTIONS_MAX_PROJECTS);
|
|
114
226
|
const overflow = projects.length - shown.length;
|
|
115
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"}` +
|
|
@@ -126,6 +238,24 @@ function buildInstructions(getClient) {
|
|
|
126
238
|
function checklistTarget(args) {
|
|
127
239
|
return args.index !== undefined ? { index: args.index } : { item: args.item ?? "" };
|
|
128
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
|
+
}
|
|
129
259
|
export function createThingsMcpServer(options = {}) {
|
|
130
260
|
// One lazily-opened client for the server's lifetime; SQLite read
|
|
131
261
|
// snapshots are per-statement, so fresh reads see external commits.
|
|
@@ -138,26 +268,70 @@ export function createThingsMcpServer(options = {}) {
|
|
|
138
268
|
return client;
|
|
139
269
|
};
|
|
140
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
|
+
});
|
|
141
289
|
/** Run a handler, mapping environment/usage throws to tool errors. */
|
|
142
290
|
const guard = async (fn) => {
|
|
143
291
|
try {
|
|
144
292
|
return await fn();
|
|
145
293
|
}
|
|
146
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
|
+
}
|
|
147
305
|
const message = err instanceof Error ? err.message : String(err);
|
|
148
306
|
const code = err instanceof RangeError ? "usage" : "environment";
|
|
149
307
|
return errorResult({ code, message });
|
|
150
308
|
}
|
|
151
309
|
};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
};
|
|
161
335
|
/** Resolve a uuid to to-do/project for the type-generic item tools. */
|
|
162
336
|
const itemType = (uuid) => {
|
|
163
337
|
const item = getClient().read.byUuid(uuid);
|
|
@@ -173,16 +347,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
173
347
|
description: "Read a Things list as the app presents it: today (split into Today and This " +
|
|
174
348
|
"Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
|
|
175
349
|
"horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
|
|
176
|
-
"anytime/someday return
|
|
350
|
+
"anytime/someday return sections in canonical order (area + items; null area = the " +
|
|
177
351
|
"top-level block); children of someday/future-scheduled projects are excluded " +
|
|
178
|
-
"from anytime — the project row represents them
|
|
352
|
+
"from anytime — the project row represents them; someday lists each group's " +
|
|
353
|
+
"project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
|
|
354
|
+
`return at most ${DEFAULT_LIST_LIMIT} items by default (raise with limit); ` +
|
|
355
|
+
"anytime/someday always return every group and cap per block instead — " +
|
|
356
|
+
`area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
|
|
357
|
+
`project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
|
|
358
|
+
"all: true lifts every cap; the result's second block reports the counts. " +
|
|
359
|
+
OMIT_EMPTY_NOTE,
|
|
179
360
|
inputSchema: {
|
|
180
361
|
view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
|
|
181
362
|
...tagFilterShape,
|
|
363
|
+
evening: z.boolean().optional().describe("today only: show only the This Evening section"),
|
|
364
|
+
show_active_project_items: z
|
|
365
|
+
.union([z.boolean(), z.number().int().min(1)])
|
|
366
|
+
.optional()
|
|
367
|
+
.describe("someday only: include someday to-dos inside active projects, clustered per " +
|
|
368
|
+
"project after each group; a number caps each project's list (true: every item)"),
|
|
182
369
|
active_project_items: z
|
|
183
|
-
.boolean()
|
|
370
|
+
.union([z.boolean(), z.number().int().min(1)])
|
|
184
371
|
.optional()
|
|
185
|
-
.describe("
|
|
372
|
+
.describe("compatibility alias for show_active_project_items"),
|
|
186
373
|
horizon: z
|
|
187
374
|
.number()
|
|
188
375
|
.int()
|
|
@@ -190,42 +377,128 @@ export function createThingsMcpServer(options = {}) {
|
|
|
190
377
|
.max(10)
|
|
191
378
|
.optional()
|
|
192
379
|
.describe("upcoming only: occurrences shown per repeating item (default 1)"),
|
|
193
|
-
limit: z
|
|
380
|
+
limit: z
|
|
381
|
+
.number()
|
|
382
|
+
.int()
|
|
383
|
+
.min(1)
|
|
384
|
+
.optional()
|
|
385
|
+
.describe(`flat views only (not anytime/someday): ${LIMIT_DESC}`),
|
|
386
|
+
area_limit: z
|
|
387
|
+
.number()
|
|
388
|
+
.int()
|
|
389
|
+
.min(1)
|
|
390
|
+
.optional()
|
|
391
|
+
.describe(`anytime/someday only: ${AREA_LIMIT_DESC}`),
|
|
392
|
+
project_limit: z
|
|
393
|
+
.number()
|
|
394
|
+
.int()
|
|
395
|
+
.min(1)
|
|
396
|
+
.optional()
|
|
397
|
+
.describe(`anytime only: ${PROJECT_LIMIT_DESC}`),
|
|
398
|
+
all: z
|
|
399
|
+
.boolean()
|
|
400
|
+
.optional()
|
|
401
|
+
.describe("show everything (flat views: no row limit; anytime/someday: no per-block caps)"),
|
|
194
402
|
},
|
|
195
403
|
annotations: READ_ONLY,
|
|
196
|
-
}, 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);
|
|
415
|
+
// show_active_project_items is the preferred name; active_project_items
|
|
416
|
+
// stays accepted as a compatibility alias.
|
|
417
|
+
const showActiveProjectItems = args.show_active_project_items ?? args.active_project_items;
|
|
418
|
+
const isGrouped = args.view === "anytime" || args.view === "someday";
|
|
419
|
+
if (isGrouped && args.limit !== undefined) {
|
|
420
|
+
return usage(`limit does not apply to ${args.view} — cap blocks with area_limit` +
|
|
421
|
+
`${args.view === "anytime" ? "/project_limit" : ""}, or pass all: true`);
|
|
422
|
+
}
|
|
423
|
+
if (!isGrouped && (args.area_limit !== undefined || args.project_limit !== undefined)) {
|
|
424
|
+
return usage(`area_limit/project_limit apply only to anytime/someday, not ${args.view}`);
|
|
425
|
+
}
|
|
426
|
+
if (args.view !== "someday" && showActiveProjectItems !== undefined) {
|
|
427
|
+
return usage("show_active_project_items applies only to someday");
|
|
428
|
+
}
|
|
429
|
+
if (args.view !== "today" && args.evening === true) {
|
|
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
|
+
}
|
|
197
444
|
const c = getClient();
|
|
198
|
-
const filter =
|
|
199
|
-
...(args.tag !== undefined && { tag: args.tag }),
|
|
200
|
-
...(args.exact_tag === true && { exactTag: true }),
|
|
201
|
-
};
|
|
445
|
+
const filter = validated.filter;
|
|
202
446
|
switch (args.view) {
|
|
203
|
-
case "today":
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
454
|
+
}
|
|
455
|
+
case "inbox": {
|
|
456
|
+
const { items, truncation } = c.read.inbox({ ...filter, limit });
|
|
457
|
+
return truncatedResult(items, truncation);
|
|
458
|
+
}
|
|
459
|
+
case "anytime": {
|
|
460
|
+
const { view, grouped } = c.read.anytime({ ...filter, areaLimit, projectLimit });
|
|
461
|
+
return groupedResult(view, grouped);
|
|
462
|
+
}
|
|
463
|
+
case "upcoming": {
|
|
464
|
+
const { items, truncation } = c.read.upcoming({
|
|
211
465
|
...filter,
|
|
212
466
|
...(args.horizon !== undefined && { horizon: args.horizon }),
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return
|
|
467
|
+
limit,
|
|
468
|
+
});
|
|
469
|
+
return truncatedResult(items, truncation);
|
|
470
|
+
}
|
|
471
|
+
case "someday": {
|
|
472
|
+
const active = showActiveProjectItems;
|
|
473
|
+
if (typeof active === "number" && args.all === true) {
|
|
474
|
+
return usage("pass at most one of a numeric show_active_project_items / all");
|
|
475
|
+
}
|
|
476
|
+
const { view, grouped } = c.read.someday({
|
|
216
477
|
...filter,
|
|
217
|
-
...(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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);
|
|
486
|
+
}
|
|
487
|
+
case "logbook": {
|
|
488
|
+
const { items, truncation } = c.read.logbook({ ...filter, limit });
|
|
489
|
+
return truncatedResult(items, truncation);
|
|
490
|
+
}
|
|
491
|
+
case "trash": {
|
|
492
|
+
const { items, truncation } = c.read.trash({ limit });
|
|
493
|
+
return truncatedResult(items, truncation);
|
|
494
|
+
}
|
|
223
495
|
}
|
|
224
496
|
}));
|
|
225
497
|
server.registerTool("search", {
|
|
226
498
|
description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
|
|
227
499
|
"include more with logged/trashed/all. Scope with project/area/tag — scope " +
|
|
228
|
-
"references must name existing items."
|
|
500
|
+
"references must name existing items. " +
|
|
501
|
+
OMIT_EMPTY_NOTE,
|
|
229
502
|
inputSchema: {
|
|
230
503
|
query: z.string(),
|
|
231
504
|
...tagFilterShape,
|
|
@@ -240,72 +513,186 @@ export function createThingsMcpServer(options = {}) {
|
|
|
240
513
|
type: z.enum(["to-do", "project"]).optional(),
|
|
241
514
|
logged: z.boolean().optional().describe("Also include completed/canceled items"),
|
|
242
515
|
trashed: z.boolean().optional().describe("Also include trashed items"),
|
|
243
|
-
all: z
|
|
244
|
-
|
|
516
|
+
all: z
|
|
517
|
+
.boolean()
|
|
518
|
+
.optional()
|
|
519
|
+
.describe("Everything, unbounded: open + logged + trashed, no row limit"),
|
|
520
|
+
limit: z.number().int().min(1).optional().describe(LIMIT_DESC),
|
|
245
521
|
},
|
|
246
522
|
annotations: READ_ONLY,
|
|
247
|
-
}, async (args) =>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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);
|
|
539
|
+
const limit = resolveLimit(args);
|
|
540
|
+
if (limit === "conflict")
|
|
541
|
+
return usage("pass at most one of limit / all");
|
|
542
|
+
const { items, truncation } = getClient().read.search(args.query, {
|
|
543
|
+
limit,
|
|
544
|
+
...validated.filter,
|
|
545
|
+
...(args.project !== undefined && { project: args.project }),
|
|
546
|
+
...(args.area !== undefined && { area: args.area }),
|
|
547
|
+
...(args.type !== undefined && { type: args.type }),
|
|
548
|
+
...(args.logged === true && { logged: true }),
|
|
549
|
+
...(args.trashed === true && { trashed: true }),
|
|
550
|
+
...(args.all === true && { all: true }),
|
|
551
|
+
});
|
|
552
|
+
return truncatedResult(items, truncation);
|
|
553
|
+
}));
|
|
258
554
|
server.registerTool("changes_since", {
|
|
259
555
|
description: "List items created or modified since a moment — including trashed, logged, and " +
|
|
260
556
|
"repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
|
|
261
|
-
"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,
|
|
262
559
|
inputSchema: {
|
|
263
560
|
since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
|
|
264
|
-
|
|
561
|
+
...limitShape,
|
|
265
562
|
},
|
|
266
563
|
annotations: READ_ONLY,
|
|
267
|
-
}, async (args) =>
|
|
564
|
+
}, async (args) => readGuard(() => {
|
|
565
|
+
const limit = resolveLimit(args);
|
|
566
|
+
if (limit === "conflict")
|
|
567
|
+
return usage("pass at most one of limit / all");
|
|
268
568
|
const since = new Date(args.since);
|
|
269
569
|
if (Number.isNaN(since.getTime())) {
|
|
270
570
|
return usage(`since is not a parseable date: ${args.since}`);
|
|
271
571
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(args.limit !== undefined && { limit: args.limit }),
|
|
275
|
-
}));
|
|
572
|
+
const { items, truncation } = getClient().read.changes({ since, limit });
|
|
573
|
+
return truncatedResult(items, truncation);
|
|
276
574
|
}));
|
|
277
575
|
server.registerTool("get_item", {
|
|
278
576
|
description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
|
|
279
577
|
"(direct and inherited), checklist with per-item state, repeat schedule, and its " +
|
|
280
|
-
"project/area/heading."
|
|
578
|
+
"project/area/heading. " +
|
|
579
|
+
OMIT_EMPTY_NOTE,
|
|
281
580
|
inputSchema: { uuid: z.string() },
|
|
282
581
|
annotations: READ_ONLY,
|
|
283
|
-
}, async (args) =>
|
|
582
|
+
}, async (args) => readGuard(() => {
|
|
284
583
|
const item = getClient().read.byUuid(args.uuid);
|
|
285
584
|
return item === null
|
|
286
|
-
? errorResult({ code: "not-found", message:
|
|
287
|
-
:
|
|
585
|
+
? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
|
|
586
|
+
: readResult(item);
|
|
288
587
|
}));
|
|
289
588
|
server.registerTool("get_project", {
|
|
290
|
-
description: "One project's full contents: metadata plus its to-dos grouped under their headings."
|
|
291
|
-
|
|
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
|
+
},
|
|
292
601
|
annotations: READ_ONLY,
|
|
293
|
-
}, 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
|
+
}));
|
|
294
610
|
server.registerTool("get_area", {
|
|
295
|
-
description: "One area's
|
|
296
|
-
"projects in
|
|
297
|
-
|
|
611
|
+
description: "One area's contents: metadata plus its direct to-dos (active first), its " +
|
|
612
|
+
"projects in canonical order, later (scheduled/repeating/someday), and logged items. " +
|
|
613
|
+
`The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
|
|
614
|
+
"by default (project_limit / area_limit adjust them; all: true lifts both); the " +
|
|
615
|
+
"second result block reports the counts. " +
|
|
616
|
+
OMIT_EMPTY_NOTE,
|
|
617
|
+
inputSchema: {
|
|
618
|
+
ref: z.string().describe("Area uuid or unique name"),
|
|
619
|
+
...tagOnlyShape,
|
|
620
|
+
area_limit: z
|
|
621
|
+
.number()
|
|
622
|
+
.int()
|
|
623
|
+
.min(1)
|
|
624
|
+
.optional()
|
|
625
|
+
.describe(`maximum direct to-dos to return (default ${AREA_PREVIEW_LIMIT})`),
|
|
626
|
+
project_limit: z
|
|
627
|
+
.number()
|
|
628
|
+
.int()
|
|
629
|
+
.min(1)
|
|
630
|
+
.optional()
|
|
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"),
|
|
636
|
+
all: z.boolean().optional().describe("return both sections in full (no caps)"),
|
|
637
|
+
},
|
|
298
638
|
annotations: READ_ONLY,
|
|
299
|
-
}, async (args) =>
|
|
639
|
+
}, async (args) => readGuard(() => {
|
|
640
|
+
if (tagFlagConflict(tagPresence(args)))
|
|
641
|
+
return usage(MCP_UNTAGGED_CONFLICT);
|
|
642
|
+
const areaLimit = resolveCap(args.area_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
643
|
+
const projectLimit = resolveCap(args.project_limit, args.all, AREA_PREVIEW_LIMIT);
|
|
644
|
+
if (areaLimit === "conflict" || projectLimit === "conflict") {
|
|
645
|
+
return usage("pass at most one of area_limit/project_limit / all");
|
|
646
|
+
}
|
|
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);
|
|
654
|
+
}));
|
|
300
655
|
server.registerTool("list_collections", {
|
|
301
656
|
description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
|
|
302
|
-
"refresh the inventory summarized in the server instructions."
|
|
303
|
-
|
|
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
|
+
},
|
|
304
668
|
annotations: READ_ONLY,
|
|
305
|
-
}, async (args) =>
|
|
669
|
+
}, async (args) => readGuard(() => {
|
|
306
670
|
const c = getClient();
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
})
|
|
309
696
|
: args.kind === "areas"
|
|
310
697
|
? c.read.areas()
|
|
311
698
|
: c.read.tags());
|
|
@@ -315,7 +702,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
315
702
|
server.registerTool("add_todo", {
|
|
316
703
|
description: "Create a to-do and return its uuid. Optionally schedule it, set a reminder or " +
|
|
317
704
|
"deadline, tag it, give it a checklist, and place it in a project or area " +
|
|
318
|
-
"(optionally under an existing heading).
|
|
705
|
+
"(optionally under an existing heading). A reminder " +
|
|
319
706
|
"requires when = today, evening, or a date. Adding into a completed or canceled " +
|
|
320
707
|
"project reopens that project — pass acknowledge_project_reopen to confirm.",
|
|
321
708
|
inputSchema: {
|
|
@@ -324,7 +711,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
324
711
|
when: whenSchema,
|
|
325
712
|
reminder: z.string().optional().describe(REMINDER_FORMAT),
|
|
326
713
|
deadline: z.string().optional().describe(DATE_FORMAT),
|
|
327
|
-
tags: z.array(z.string()).optional().describe(
|
|
714
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
328
715
|
checklist_items: z.array(z.string()).optional(),
|
|
329
716
|
project: z.string().optional().describe(`Destination project (${REF_FORMAT})`),
|
|
330
717
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
@@ -333,21 +720,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
333
720
|
.boolean()
|
|
334
721
|
.optional()
|
|
335
722
|
.describe("Confirm adding into a completed/canceled project (this reopens it)"),
|
|
723
|
+
...createTagsShape,
|
|
336
724
|
...dryRunShape,
|
|
337
725
|
},
|
|
338
726
|
annotations: NON_DESTRUCTIVE,
|
|
339
|
-
}, async (args) => guard(async () =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}));
|
|
351
746
|
server.registerTool("update_todo", {
|
|
352
747
|
description: "Update a to-do's title, notes, schedule, reminder, or deadline. " +
|
|
353
748
|
"append_notes/prepend_notes add a line to the existing notes (exclusive with " +
|
|
@@ -381,13 +776,18 @@ export function createThingsMcpServer(options = {}) {
|
|
|
381
776
|
if (args.deadline !== undefined && args.clear_deadline === true) {
|
|
382
777
|
return usage("pass at most one of deadline / clear_deadline");
|
|
383
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;
|
|
384
784
|
return mutationResult(await getClient().write.updateTodo(args.uuid, {
|
|
385
785
|
...(args.title !== undefined && { title: args.title }),
|
|
386
786
|
...(args.notes !== undefined && { notes: args.notes }),
|
|
387
787
|
...(args.append_notes !== undefined && { appendNotes: args.append_notes }),
|
|
388
788
|
...(args.prepend_notes !== undefined && { prependNotes: args.prepend_notes }),
|
|
389
|
-
...(
|
|
390
|
-
...(
|
|
789
|
+
...(when !== undefined && { when: when }),
|
|
790
|
+
...(reminder !== undefined && { reminder }),
|
|
391
791
|
...(args.clear_reminder === true && { reminder: null }),
|
|
392
792
|
...(args.deadline !== undefined && { deadline: args.deadline }),
|
|
393
793
|
...(args.clear_deadline === true && { deadline: null }),
|
|
@@ -454,11 +854,12 @@ export function createThingsMcpServer(options = {}) {
|
|
|
454
854
|
server.registerTool("set_tags", {
|
|
455
855
|
description: "Replace or extend a to-do's or project's tags. mode 'replace' (default) sets exactly " +
|
|
456
856
|
"the given list — an empty list removes all tags; mode 'add' merges with the current " +
|
|
457
|
-
"tags. Tags must
|
|
857
|
+
"tags. Tags must exist unless create_tags is set (or create them first with add_tag).",
|
|
458
858
|
inputSchema: {
|
|
459
859
|
uuid: z.string(),
|
|
460
|
-
tags: z.array(z.string()).describe(
|
|
860
|
+
tags: z.array(z.string()).describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
461
861
|
mode: z.enum(["replace", "add"]).optional().describe("Default: replace"),
|
|
862
|
+
...createTagsShape,
|
|
462
863
|
...dryRunShape,
|
|
463
864
|
},
|
|
464
865
|
annotations: NON_DESTRUCTIVE,
|
|
@@ -683,6 +1084,265 @@ export function createThingsMcpServer(options = {}) {
|
|
|
683
1084
|
? jsonResult(r)
|
|
684
1085
|
: mutationResult(r.heading);
|
|
685
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
|
+
}));
|
|
686
1346
|
server.registerTool("duplicate_item", {
|
|
687
1347
|
description: "Duplicate a to-do or project and return the copy's uuid; a duplicated project " +
|
|
688
1348
|
"includes its children. Not available for repeating items.",
|
|
@@ -723,7 +1383,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
723
1383
|
"clear_reminder works while the project is scheduled for today or this evening; a " +
|
|
724
1384
|
"reminder on a future date can only be changed, not cleared.",
|
|
725
1385
|
inputSchema: {
|
|
726
|
-
uuid: z.string(),
|
|
1386
|
+
uuid: z.string().describe(`The project to update (${REF_FORMAT})`),
|
|
727
1387
|
title: z.string().optional(),
|
|
728
1388
|
notes: z.string().optional().describe("Replaces the whole notes body"),
|
|
729
1389
|
append_notes: z.string().optional(),
|
|
@@ -767,7 +1427,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
767
1427
|
"canceled project; its children stay completed/canceled unless restore_children " +
|
|
768
1428
|
"also reopens the ones that were resolved together with the project.",
|
|
769
1429
|
inputSchema: {
|
|
770
|
-
uuid: z.string().describe(
|
|
1430
|
+
uuid: z.string().describe(`The project (${REF_FORMAT})`),
|
|
771
1431
|
status: z.enum(["completed", "canceled", "open"]),
|
|
772
1432
|
children: z
|
|
773
1433
|
.enum(["require-resolved", "auto-complete", "auto-cancel"])
|
|
@@ -812,7 +1472,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
812
1472
|
description: "Move a project into an area, or detach it from its current area. Pass exactly " +
|
|
813
1473
|
"one of area / detach. The project's status and schedule are unaffected.",
|
|
814
1474
|
inputSchema: {
|
|
815
|
-
uuid: z.string().describe(
|
|
1475
|
+
uuid: z.string().describe(`The project to move (${REF_FORMAT})`),
|
|
816
1476
|
area: z.string().optional().describe(`Destination area (${REF_FORMAT})`),
|
|
817
1477
|
detach: z.boolean().optional().describe("Remove the current area assignment"),
|
|
818
1478
|
...dryRunShape,
|
|
@@ -829,20 +1489,26 @@ export function createThingsMcpServer(options = {}) {
|
|
|
829
1489
|
}));
|
|
830
1490
|
// ----------------------------------------------------------------- areas
|
|
831
1491
|
server.registerTool("add_area", {
|
|
832
|
-
description: "Create an area. Tags
|
|
1492
|
+
description: "Create an area, optionally tagged. Tags must exist unless create_tags is set.",
|
|
833
1493
|
inputSchema: {
|
|
834
1494
|
title: z.string(),
|
|
835
|
-
tags: z.array(z.string()).optional().describe(
|
|
1495
|
+
tags: z.array(z.string()).optional().describe(`Tags — ${TAG_REF_FORMAT}`),
|
|
1496
|
+
...createTagsShape,
|
|
836
1497
|
...dryRunShape,
|
|
837
1498
|
},
|
|
838
1499
|
annotations: NON_DESTRUCTIVE,
|
|
839
1500
|
}, async (args) => guard(async () => mutationResult(await getClient().write.addArea({ title: args.title, ...(args.tags !== undefined && { tags: args.tags }) }, writeOptions(args)))));
|
|
840
1501
|
server.registerTool("update_area", {
|
|
841
|
-
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.",
|
|
842
1504
|
inputSchema: {
|
|
843
1505
|
target: z.string().describe(`Area to update (${REF_FORMAT})`),
|
|
844
1506
|
title: z.string().optional().describe("New name"),
|
|
845
|
-
tags: z
|
|
1507
|
+
tags: z
|
|
1508
|
+
.array(z.string())
|
|
1509
|
+
.optional()
|
|
1510
|
+
.describe(`Tags (full replacement) — ${TAG_REF_FORMAT}`),
|
|
1511
|
+
...createTagsShape,
|
|
846
1512
|
...dryRunShape,
|
|
847
1513
|
},
|
|
848
1514
|
annotations: NON_DESTRUCTIVE,
|
|
@@ -969,10 +1635,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
969
1635
|
params: z.record(z.string(), z.unknown()),
|
|
970
1636
|
options: z
|
|
971
1637
|
.object({
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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(),
|
|
976
1642
|
})
|
|
977
1643
|
.optional(),
|
|
978
1644
|
}))
|
|
@@ -982,10 +1648,29 @@ export function createThingsMcpServer(options = {}) {
|
|
|
982
1648
|
},
|
|
983
1649
|
annotations: DESTRUCTIVE,
|
|
984
1650
|
}, async (args) => guard(async () => {
|
|
985
|
-
|
|
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, {
|
|
986
1671
|
...(args.dry_run === true && { dryRun: true }),
|
|
987
1672
|
...(args.fail_fast === true && { failFast: true }),
|
|
988
|
-
actor:
|
|
1673
|
+
actor: mcpActor(),
|
|
989
1674
|
});
|
|
990
1675
|
return jsonResult(results);
|
|
991
1676
|
}));
|
|
@@ -993,7 +1678,7 @@ export function createThingsMcpServer(options = {}) {
|
|
|
993
1678
|
description: "Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
|
|
994
1679
|
"area-less someday projects — one kind per call), a " +
|
|
995
1680
|
"project's to-dos, a project's headings (scope=headings — children move with " +
|
|
996
|
-
"their heading), an area, or the top-level
|
|
1681
|
+
"their heading), an area, or the top-level projects (scope=projects — " +
|
|
997
1682
|
"each project takes a brief someday/anytime round-trip) — the given uuids move " +
|
|
998
1683
|
"to the TOP in the given order; unlisted items keep their relative order below. " +
|
|
999
1684
|
"Today/inbox/someday/project/headings/area ordering must first be enabled once " +
|
|
@@ -1027,24 +1712,59 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1027
1712
|
...(args.strategy !== undefined && { strategy: args.strategy }),
|
|
1028
1713
|
}, writeOptions(args)))));
|
|
1029
1714
|
server.registerTool("undo", {
|
|
1030
|
-
description: "Undo the last N changes
|
|
1031
|
-
"
|
|
1032
|
-
"
|
|
1033
|
-
|
|
1034
|
-
"
|
|
1035
|
-
"
|
|
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.",
|
|
1036
1727
|
inputSchema: {
|
|
1037
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."),
|
|
1038
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."),
|
|
1039
1748
|
...dryRunShape,
|
|
1040
1749
|
},
|
|
1041
1750
|
annotations: DESTRUCTIVE,
|
|
1042
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
|
+
}
|
|
1043
1755
|
const items = await getClient().write.undo({
|
|
1044
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() }),
|
|
1045
1762
|
...(args.dry_run === true && { dryRun: true }),
|
|
1046
1763
|
...(args.dangerously_permanent === true && { dangerouslyPermanent: true }),
|
|
1047
|
-
|
|
1764
|
+
...(args.acknowledge_out_of_band_changes === true && {
|
|
1765
|
+
acknowledgeOutOfBandChanges: true,
|
|
1766
|
+
}),
|
|
1767
|
+
actor: mcpActor(),
|
|
1048
1768
|
});
|
|
1049
1769
|
return jsonResult(items);
|
|
1050
1770
|
}));
|
|
@@ -1062,8 +1782,10 @@ export function createThingsMcpServer(options = {}) {
|
|
|
1062
1782
|
server.registerTool("doctor", {
|
|
1063
1783
|
description: "Check the environment: whether the Things app and its database are reachable, " +
|
|
1064
1784
|
"whether changes can be made, any one-time setup still needed (macOS permissions, " +
|
|
1065
|
-
"the app's 'Enable Things URLs' setting),
|
|
1066
|
-
"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.",
|
|
1067
1789
|
inputSchema: {
|
|
1068
1790
|
probe_automation: z
|
|
1069
1791
|
.boolean()
|