botnote 0.1.30 → 0.1.33
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/dist/mcp/http-client.d.ts +57 -0
- package/dist/mcp/http-client.js +22 -0
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.js +474 -100
- package/dist/mcp/server.js.map +1 -1
- package/dist/rest/routes.js +57 -3
- package/dist/rest/routes.js.map +1 -1
- package/dist/service/entities.d.ts +25 -2
- package/dist/service/entities.js +81 -0
- package/dist/service/entities.js.map +1 -1
- package/dist/service/opening_brief.d.ts +1 -0
- package/dist/service/opening_brief.js +22 -4
- package/dist/service/opening_brief.js.map +1 -1
- package/dist/service/types.d.ts +57 -3
- package/dist/service/types.js +15 -1
- package/dist/service/types.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { serializeEntity } from "./http-client.js";
|
|
3
|
+
import { BotnoteHttpError, serializeEntity } from "./http-client.js";
|
|
4
4
|
import { VERSION } from "../version.js";
|
|
5
5
|
// Inline kind constants so the MCP package can be packaged without pulling
|
|
6
6
|
// drizzle / the db schema module at runtime.
|
|
@@ -18,12 +18,28 @@ const RECURRENCE_PRESETS = ["hourly", "daily", "weekly", "monthly", "yearly"];
|
|
|
18
18
|
const RECURRENCE_ANCHORS = ["scheduled", "completion"];
|
|
19
19
|
const WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
|
20
20
|
const PROJECT_STATUSES = ["planned", "active", "watching", "paused", "archived"];
|
|
21
|
-
|
|
21
|
+
/** Accepts a full UUID, a unique UUID prefix (≥8 hex chars), or a human-readable KEY-SEQ such as BOT-55. */
|
|
22
|
+
const EntityRef = z
|
|
22
23
|
.string()
|
|
23
|
-
.min(
|
|
24
|
-
.max(
|
|
25
|
-
.
|
|
26
|
-
|
|
24
|
+
.min(1)
|
|
25
|
+
.max(40)
|
|
26
|
+
.describe("UUID, unique UUID prefix, or human-readable KEY-SEQ such as BOT-55.");
|
|
27
|
+
/**
|
|
28
|
+
* Resolve an entity reference to a UUID (or pass through for the backend to handle).
|
|
29
|
+
* If `ref` matches the KEY-SEQ pattern (e.g. BOT-55), it calls getEntityByKey and
|
|
30
|
+
* returns the resolved UUID. Otherwise it passes `ref` through unchanged so the
|
|
31
|
+
* backend can resolve UUID / prefix as before.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveEntityRef(c, ref) {
|
|
34
|
+
const keySeqMatch = /^([A-Z][A-Z0-9_]*)-(\d+)$/.exec(ref);
|
|
35
|
+
if (keySeqMatch) {
|
|
36
|
+
const projectKey = keySeqMatch[1];
|
|
37
|
+
const sequenceId = parseInt(keySeqMatch[2], 10);
|
|
38
|
+
const entity = await c.getEntityByKey(projectKey, sequenceId);
|
|
39
|
+
return entity.id;
|
|
40
|
+
}
|
|
41
|
+
return ref;
|
|
42
|
+
}
|
|
27
43
|
function displayTitle(e) {
|
|
28
44
|
if (e.title && e.title.trim())
|
|
29
45
|
return e.title;
|
|
@@ -32,9 +48,57 @@ function displayTitle(e) {
|
|
|
32
48
|
return firstLine.length > 60 ? `${firstLine.slice(0, 60)}…` : firstLine;
|
|
33
49
|
return "(untitled)";
|
|
34
50
|
}
|
|
35
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Human-readable reference for an entity: KEY-SEQ (e.g. BOT-55) when the
|
|
53
|
+
* project key is known, otherwise kind/uuid-prefix. Both forms are accepted
|
|
54
|
+
* back as EntityRef inputs, so tool output never needs the full UUID.
|
|
55
|
+
*/
|
|
56
|
+
function entityRefLabel(e, projectKey) {
|
|
57
|
+
if (projectKey && e.sequenceId != null)
|
|
58
|
+
return `${projectKey}-${e.sequenceId}`;
|
|
59
|
+
return `${e.kind}/${e.id.slice(0, 8)}`;
|
|
60
|
+
}
|
|
61
|
+
function summarizeEntity(e, projectKey) {
|
|
36
62
|
const tagPart = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
|
|
37
|
-
|
|
63
|
+
const base = `${entityRefLabel(e, projectKey)} · ${displayTitle(e)}${tagPart}`;
|
|
64
|
+
if (e.kind !== "task")
|
|
65
|
+
return `${base} (note)`;
|
|
66
|
+
// Compact task suffix: status, optional priority, and due/completedAt date.
|
|
67
|
+
const parts = [e.status];
|
|
68
|
+
if (e.priority && e.priority !== "none")
|
|
69
|
+
parts.push(e.priority);
|
|
70
|
+
if (e.status === "done" && e.completedAt) {
|
|
71
|
+
parts.push(`done ${e.completedAt.slice(0, 10)}`);
|
|
72
|
+
}
|
|
73
|
+
else if (e.dueAt) {
|
|
74
|
+
parts.push(`due ${e.dueAt.slice(0, 10)}`);
|
|
75
|
+
}
|
|
76
|
+
return `${base} (${parts.join(", ")})`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Format a caught error from a write-tool handler into a structured isError
|
|
80
|
+
* response. For BotnoteHttpError produces a human-readable hint keyed to the
|
|
81
|
+
* status code; for everything else falls back to the error message.
|
|
82
|
+
*/
|
|
83
|
+
function formatToolError(err) {
|
|
84
|
+
let text;
|
|
85
|
+
if (err instanceof BotnoteHttpError) {
|
|
86
|
+
const body = err.body?.trim() || err.statusText;
|
|
87
|
+
if (err.status === 404) {
|
|
88
|
+
text = `not found: ${body}`;
|
|
89
|
+
}
|
|
90
|
+
else if (err.status === 400 || err.status === 422) {
|
|
91
|
+
text = `invalid request: ${body}`;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
text = `HTTP ${err.status} ${err.statusText}: ${body}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const e = err;
|
|
99
|
+
text = String(e?.message ?? err);
|
|
100
|
+
}
|
|
101
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
38
102
|
}
|
|
39
103
|
const ProjectKey = z
|
|
40
104
|
.string()
|
|
@@ -43,12 +107,50 @@ const ProjectKey = z
|
|
|
43
107
|
.regex(/^[A-Z][A-Z0-9_]*$/);
|
|
44
108
|
const HexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
45
109
|
const IconName = z.string().min(1).max(40).regex(/^[a-z0-9_-]+$/);
|
|
110
|
+
/**
|
|
111
|
+
* Server-level behavioral guidance, surfaced to MCP clients at initialize time
|
|
112
|
+
* (Claude Code injects it into the agent's context). Keep it short — this is
|
|
113
|
+
* always-on context for every session that connects to botnote.
|
|
114
|
+
*/
|
|
115
|
+
const SERVER_INSTRUCTIONS = `botnote is the user's task + memory system. Rules for agents:
|
|
116
|
+
|
|
117
|
+
Proactive task capture (propose, don't silently create):
|
|
118
|
+
- When a discussion turns into a decision to build/fix/do something, propose recording it as a task before starting the work.
|
|
119
|
+
- When new work emerges mid-task that is outside the current task's scope, propose capturing it as a new task (link it via parentId or a reference) instead of silently doing it or letting it drop.
|
|
120
|
+
- Confirm with the user before creating. Batch proposals at natural checkpoints rather than interrupting for every item.
|
|
121
|
+
|
|
122
|
+
Task scope:
|
|
123
|
+
- Keep tasks small: one focused session (roughly ≤1 hour). Propose splitting bigger work into a parent milestone task plus small subtasks via parentId.
|
|
124
|
+
|
|
125
|
+
References:
|
|
126
|
+
- Refer to tasks and notes by their KEY-SEQ identifier (e.g. BOT-55) when talking to the user, never by UUID.
|
|
127
|
+
|
|
128
|
+
Session start: call opening_brief first to load project context.`;
|
|
46
129
|
export function buildMcpServer(ctx) {
|
|
47
130
|
const server = new McpServer({
|
|
48
131
|
name: "botnote",
|
|
49
132
|
version: ctx.version ?? VERSION
|
|
50
|
-
});
|
|
133
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
51
134
|
const c = ctx.client;
|
|
135
|
+
// projectId → key cache used to render human-readable KEY-SEQ refs (BOT-55)
|
|
136
|
+
// in tool output. Loaded lazily; refreshed whenever an unknown projectId
|
|
137
|
+
// shows up (e.g. a project created after the cache was filled).
|
|
138
|
+
let projectKeyCache = null;
|
|
139
|
+
async function projectKeysFor(rows) {
|
|
140
|
+
const cache = projectKeyCache;
|
|
141
|
+
if (!cache || rows.some((e) => e.projectId && !cache.has(e.projectId))) {
|
|
142
|
+
const projects = await c.listProjects({ includeArchived: true });
|
|
143
|
+
projectKeyCache = new Map(projects.map((p) => [p.id, p.key]));
|
|
144
|
+
}
|
|
145
|
+
return projectKeyCache;
|
|
146
|
+
}
|
|
147
|
+
async function summarizeAll(rows) {
|
|
148
|
+
const keys = await projectKeysFor(rows);
|
|
149
|
+
return rows.map((e) => summarizeEntity(e, e.projectId ? (keys.get(e.projectId) ?? null) : null));
|
|
150
|
+
}
|
|
151
|
+
async function summarize(e) {
|
|
152
|
+
return (await summarizeAll([e]))[0];
|
|
153
|
+
}
|
|
52
154
|
// ----- 1. opening_brief -----
|
|
53
155
|
server.registerTool("opening_brief", {
|
|
54
156
|
title: "Opening Brief",
|
|
@@ -93,7 +195,8 @@ export function buildMcpServer(ctx) {
|
|
|
93
195
|
kind,
|
|
94
196
|
limit
|
|
95
197
|
});
|
|
96
|
-
const
|
|
198
|
+
const summaries = await summarizeAll(hits.map((h) => h.entity));
|
|
199
|
+
const lines = hits.map((h, i) => `${h.score.toFixed(4)} ${summaries[i]}\n ${h.entity.body.slice(0, 200).replace(/\n/g, " ")}`);
|
|
97
200
|
return {
|
|
98
201
|
content: [
|
|
99
202
|
{
|
|
@@ -128,13 +231,14 @@ export function buildMcpServer(ctx) {
|
|
|
128
231
|
kinds,
|
|
129
232
|
limit
|
|
130
233
|
});
|
|
234
|
+
const summaries = await summarizeAll(rows);
|
|
131
235
|
return {
|
|
132
236
|
content: [
|
|
133
237
|
{
|
|
134
238
|
type: "text",
|
|
135
239
|
text: rows.length
|
|
136
240
|
? rows
|
|
137
|
-
.map((r) => `${r.createdAt.slice(0, 16).replace("T", " ")} · ${
|
|
241
|
+
.map((r, i) => `${r.createdAt.slice(0, 16).replace("T", " ")} · ${summaries[i]}`)
|
|
138
242
|
.join("\n")
|
|
139
243
|
: "no recent entities"
|
|
140
244
|
}
|
|
@@ -262,17 +366,18 @@ export function buildMcpServer(ctx) {
|
|
|
262
366
|
// ----- 8. get_entity -----
|
|
263
367
|
server.registerTool("get_entity", {
|
|
264
368
|
title: "Get Entity",
|
|
265
|
-
description: "Fetch a single task or note by its UUID
|
|
369
|
+
description: "Fetch a single task or note by its UUID, unique UUID prefix, or human-readable KEY-SEQ (e.g. BOT-55).",
|
|
266
370
|
annotations: {
|
|
267
371
|
readOnlyHint: true,
|
|
268
372
|
destructiveHint: false,
|
|
269
373
|
idempotentHint: true,
|
|
270
374
|
openWorldHint: false
|
|
271
375
|
},
|
|
272
|
-
inputSchema: { id:
|
|
376
|
+
inputSchema: { id: EntityRef }
|
|
273
377
|
}, async ({ id }) => {
|
|
274
378
|
try {
|
|
275
|
-
const
|
|
379
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
380
|
+
const entity = await c.getEntity(resolvedId);
|
|
276
381
|
return { content: [{ type: "text", text: JSON.stringify(serializeEntity(entity), null, 2) }] };
|
|
277
382
|
}
|
|
278
383
|
catch {
|
|
@@ -317,7 +422,14 @@ export function buildMcpServer(ctx) {
|
|
|
317
422
|
"- `status`: defaults to 'open'. Use 'in_progress' only if work has actually started in this turn.\n" +
|
|
318
423
|
"- `dueAt`: include when the user mentions a date. Use an ISO datetime in UTC.\n" +
|
|
319
424
|
"- `tags`: 2-4 short lowercase kebab-case tokens. Re-use existing tags when possible — search first if unsure.\n" +
|
|
320
|
-
"- `parentId`: include when this task is a follow-up under another task or note
|
|
425
|
+
"- `parentId`: include when this task is a follow-up under another task or note.\n" +
|
|
426
|
+
"\n" +
|
|
427
|
+
"Scope — keep tasks small:\n" +
|
|
428
|
+
"- One task = one focused work session (roughly ≤1 hour). If the work spans multiple hours, days, or deliverables, propose a split into smaller executable tasks and get the user's agreement before creating anything.\n" +
|
|
429
|
+
"- For a large goal, create a parent milestone task (no dueAt needed), then attach small executable subtasks via `parentId`.\n" +
|
|
430
|
+
"- Heuristic: if the title needs 'and' to chain multiple verbs, it should be several tasks.\n" +
|
|
431
|
+
"\n" +
|
|
432
|
+
"When reporting a created task to the user, refer to it by its KEY-SEQ identifier (e.g. BOT-55), never the UUID.",
|
|
321
433
|
annotations: {
|
|
322
434
|
readOnlyHint: false,
|
|
323
435
|
destructiveHint: false,
|
|
@@ -330,26 +442,34 @@ export function buildMcpServer(ctx) {
|
|
|
330
442
|
body: z.string().default(""),
|
|
331
443
|
tags: z.array(z.string()).default([]),
|
|
332
444
|
status: z.enum(TASK_STATUSES).default("open"),
|
|
333
|
-
parentId:
|
|
445
|
+
parentId: EntityRef.optional().describe("Link this task under another entity. Accepts UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
334
446
|
actorKind: z.enum(ACTOR_KINDS).default("agent"),
|
|
335
447
|
dueAt: z.string().datetime().optional().describe("ISO datetime."),
|
|
336
448
|
priority: z.enum(PRIORITIES).default("none"),
|
|
337
449
|
idempotencyKey: z.string().min(1).max(200).optional()
|
|
338
450
|
}
|
|
339
451
|
}, async (input) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
452
|
+
try {
|
|
453
|
+
const resolvedParentId = input.parentId
|
|
454
|
+
? await resolveEntityRef(c, input.parentId)
|
|
455
|
+
: null;
|
|
456
|
+
const entity = await c.createTask({
|
|
457
|
+
projectId: input.projectId ?? null,
|
|
458
|
+
title: input.title,
|
|
459
|
+
body: input.body,
|
|
460
|
+
tags: input.tags,
|
|
461
|
+
status: input.status,
|
|
462
|
+
parentId: resolvedParentId,
|
|
463
|
+
actorKind: input.actorKind,
|
|
464
|
+
dueAt: input.dueAt ?? null,
|
|
465
|
+
priority: input.priority,
|
|
466
|
+
idempotencyKey: input.idempotencyKey
|
|
467
|
+
});
|
|
468
|
+
return { content: [{ type: "text", text: `created task ${await summarize(entity)}\nid: ${entity.id}` }] };
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
return formatToolError(err);
|
|
472
|
+
}
|
|
353
473
|
});
|
|
354
474
|
// ----- 11. remember (= create_note) -----
|
|
355
475
|
server.registerTool("remember", {
|
|
@@ -374,23 +494,31 @@ export function buildMcpServer(ctx) {
|
|
|
374
494
|
title: z.string().max(500).optional().describe("Optional; first body line is used as fallback."),
|
|
375
495
|
body: z.string().default(""),
|
|
376
496
|
tags: z.array(z.string()).default([]),
|
|
377
|
-
parentId:
|
|
497
|
+
parentId: EntityRef.optional().describe("Link this note under a task or other entity. Accepts UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
378
498
|
actorKind: z.enum(ACTOR_KINDS).default("agent"),
|
|
379
499
|
pinned: z.boolean().default(false).describe("Pin to project opening brief (must-read context)."),
|
|
380
500
|
idempotencyKey: z.string().min(1).max(200).optional()
|
|
381
501
|
}
|
|
382
502
|
}, async (input) => {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
503
|
+
try {
|
|
504
|
+
const resolvedParentId = input.parentId
|
|
505
|
+
? await resolveEntityRef(c, input.parentId)
|
|
506
|
+
: null;
|
|
507
|
+
const entity = await c.remember({
|
|
508
|
+
projectId: input.projectId ?? null,
|
|
509
|
+
title: input.title ?? null,
|
|
510
|
+
body: input.body,
|
|
511
|
+
tags: input.tags,
|
|
512
|
+
parentId: resolvedParentId,
|
|
513
|
+
actorKind: input.actorKind,
|
|
514
|
+
pinned: input.pinned,
|
|
515
|
+
idempotencyKey: input.idempotencyKey
|
|
516
|
+
});
|
|
517
|
+
return { content: [{ type: "text", text: `remembered ${await summarize(entity)}\nid: ${entity.id}` }] };
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
return formatToolError(err);
|
|
521
|
+
}
|
|
394
522
|
});
|
|
395
523
|
// ----- 12. update_entity -----
|
|
396
524
|
server.registerTool("update_entity", {
|
|
@@ -401,6 +529,7 @@ export function buildMcpServer(ctx) {
|
|
|
401
529
|
"- task completion: `status='done'`. Optionally `remember` a closing note with `parentId` set to this task.\n" +
|
|
402
530
|
"- task cancellation / give up: `status='rejected'`.\n" +
|
|
403
531
|
"- start work: `status='in_progress'` ONLY when work actually begins in this turn (not just on every mention).\n" +
|
|
532
|
+
"- move between projects: pass `projectId` (or `null` to move to workspace scope).\n" +
|
|
404
533
|
"- re-parent: pass `parentId` (or `null` to detach).\n" +
|
|
405
534
|
"- pin/unpin: `pinned: true/false`. Pinned notes drive opening_brief — use sparingly.\n" +
|
|
406
535
|
"- title=null clears the title (notes only).",
|
|
@@ -411,17 +540,27 @@ export function buildMcpServer(ctx) {
|
|
|
411
540
|
openWorldHint: false
|
|
412
541
|
},
|
|
413
542
|
inputSchema: {
|
|
414
|
-
id:
|
|
543
|
+
id: EntityRef,
|
|
544
|
+
projectId: z
|
|
545
|
+
.string()
|
|
546
|
+
.uuid()
|
|
547
|
+
.nullable()
|
|
548
|
+
.optional()
|
|
549
|
+
.describe("Move to a project UUID, or pass null for workspace scope."),
|
|
415
550
|
title: z
|
|
416
551
|
.string()
|
|
417
552
|
.max(500)
|
|
418
553
|
.nullable()
|
|
419
554
|
.optional()
|
|
420
555
|
.describe("Pass null to clear the title (notes only)."),
|
|
421
|
-
body: z.string().optional(),
|
|
556
|
+
body: z.string().optional().describe("Replace the entire body. Mutually exclusive with bodyAppend."),
|
|
557
|
+
bodyAppend: z
|
|
558
|
+
.string()
|
|
559
|
+
.optional()
|
|
560
|
+
.describe("Atomically append text to the existing body, separated by a blank line when the current body is non-empty. Mutually exclusive with body."),
|
|
422
561
|
tags: z.array(z.string()).optional(),
|
|
423
562
|
status: z.enum(TASK_STATUSES).optional(),
|
|
424
|
-
parentId:
|
|
563
|
+
parentId: EntityRef.nullable().optional().describe("Re-link or unlink (null) parent. Accepts UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
425
564
|
dueAt: z.string().datetime().nullable().optional().describe("ISO datetime or null to clear."),
|
|
426
565
|
priority: z.enum(PRIORITIES).optional(),
|
|
427
566
|
pinned: z.boolean().optional().describe("Pin or unpin from project opening brief."),
|
|
@@ -430,10 +569,19 @@ export function buildMcpServer(ctx) {
|
|
|
430
569
|
.optional()
|
|
431
570
|
.describe("For recurring task occurrences: 'this' applies the edit to this occurrence only (does not propagate to future); 'future' (default) propagates the edit to future occurrences.")
|
|
432
571
|
}
|
|
433
|
-
}, async ({ id, ...fields }) => {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
572
|
+
}, async ({ id, parentId, ...fields }) => {
|
|
573
|
+
try {
|
|
574
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
575
|
+
const resolvedParentId = parentId === null ? null : parentId !== undefined ? await resolveEntityRef(c, parentId) : undefined;
|
|
576
|
+
const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
577
|
+
if (resolvedParentId !== undefined)
|
|
578
|
+
defined.parentId = resolvedParentId;
|
|
579
|
+
const updated = await c.updateEntity(resolvedId, defined);
|
|
580
|
+
return { content: [{ type: "text", text: `updated ${await summarize(updated)}` }] };
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
return formatToolError(err);
|
|
584
|
+
}
|
|
437
585
|
});
|
|
438
586
|
// ----- 13. recurrence -----
|
|
439
587
|
server.registerTool("configure_recurrence", {
|
|
@@ -446,7 +594,7 @@ export function buildMcpServer(ctx) {
|
|
|
446
594
|
openWorldHint: false
|
|
447
595
|
},
|
|
448
596
|
inputSchema: {
|
|
449
|
-
taskId:
|
|
597
|
+
taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
450
598
|
rrule: z.string().min(1).max(1000).optional().describe("Raw RRULE such as FREQ=WEEKLY;BYDAY=MO."),
|
|
451
599
|
preset: z.enum(RECURRENCE_PRESETS).optional(),
|
|
452
600
|
interval: z.number().int().min(1).max(999).optional(),
|
|
@@ -462,16 +610,22 @@ export function buildMcpServer(ctx) {
|
|
|
462
610
|
anchor: z.enum(RECURRENCE_ANCHORS).optional()
|
|
463
611
|
}
|
|
464
612
|
}, async ({ taskId, ...fields }) => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
613
|
+
try {
|
|
614
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
615
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
616
|
+
const rule = await c.configureRecurrence(resolvedTaskId, body);
|
|
617
|
+
return {
|
|
618
|
+
content: [
|
|
619
|
+
{
|
|
620
|
+
type: "text",
|
|
621
|
+
text: `configured recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
|
|
622
|
+
}
|
|
623
|
+
]
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
return formatToolError(err);
|
|
628
|
+
}
|
|
475
629
|
});
|
|
476
630
|
server.registerTool("get_recurrence", {
|
|
477
631
|
title: "Get Recurrence",
|
|
@@ -482,9 +636,10 @@ export function buildMcpServer(ctx) {
|
|
|
482
636
|
idempotentHint: true,
|
|
483
637
|
openWorldHint: false
|
|
484
638
|
},
|
|
485
|
-
inputSchema: { taskId:
|
|
639
|
+
inputSchema: { taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55.") }
|
|
486
640
|
}, async ({ taskId }) => {
|
|
487
|
-
const
|
|
641
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
642
|
+
const details = await c.getRecurrence(resolvedTaskId);
|
|
488
643
|
const serialized = {
|
|
489
644
|
...details,
|
|
490
645
|
currentOccurrence: details.currentOccurrence
|
|
@@ -503,19 +658,25 @@ export function buildMcpServer(ctx) {
|
|
|
503
658
|
openWorldHint: false
|
|
504
659
|
},
|
|
505
660
|
inputSchema: {
|
|
506
|
-
taskId:
|
|
661
|
+
taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
507
662
|
reason: z.string().max(500).optional()
|
|
508
663
|
}
|
|
509
664
|
}, async ({ taskId, reason }) => {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
665
|
+
try {
|
|
666
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
667
|
+
const result = await c.skipOccurrence(resolvedTaskId, { reason, actorKind: "agent" });
|
|
668
|
+
return {
|
|
669
|
+
content: [
|
|
670
|
+
{
|
|
671
|
+
type: "text",
|
|
672
|
+
text: `skipped ${await summarize(result.skipped)}\nnext: ${result.next ? await summarize(result.next) : "-"}`
|
|
673
|
+
}
|
|
674
|
+
]
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
return formatToolError(err);
|
|
679
|
+
}
|
|
519
680
|
});
|
|
520
681
|
server.registerTool("stop_recurrence", {
|
|
521
682
|
title: "Stop Recurrence",
|
|
@@ -531,8 +692,13 @@ export function buildMcpServer(ctx) {
|
|
|
531
692
|
reason: z.string().max(500).optional()
|
|
532
693
|
}
|
|
533
694
|
}, async ({ ruleId, reason }) => {
|
|
534
|
-
|
|
535
|
-
|
|
695
|
+
try {
|
|
696
|
+
const rule = await c.stopRecurrence(ruleId, { reason });
|
|
697
|
+
return { content: [{ type: "text", text: `stopped recurrence ${rule.id}` }] };
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
return formatToolError(err);
|
|
701
|
+
}
|
|
536
702
|
});
|
|
537
703
|
server.registerTool("split_recurrence", {
|
|
538
704
|
title: "Split Recurrence",
|
|
@@ -559,16 +725,21 @@ export function buildMcpServer(ctx) {
|
|
|
559
725
|
anchor: z.enum(RECURRENCE_ANCHORS).optional()
|
|
560
726
|
}
|
|
561
727
|
}, async ({ ruleId, ...fields }) => {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
728
|
+
try {
|
|
729
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
730
|
+
const rule = await c.splitRecurrence(ruleId, body);
|
|
731
|
+
return {
|
|
732
|
+
content: [
|
|
733
|
+
{
|
|
734
|
+
type: "text",
|
|
735
|
+
text: `split into recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
|
|
736
|
+
}
|
|
737
|
+
]
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
return formatToolError(err);
|
|
742
|
+
}
|
|
572
743
|
});
|
|
573
744
|
// ----- 14. related -----
|
|
574
745
|
server.registerTool("related", {
|
|
@@ -580,15 +751,17 @@ export function buildMcpServer(ctx) {
|
|
|
580
751
|
idempotentHint: true,
|
|
581
752
|
openWorldHint: false
|
|
582
753
|
},
|
|
583
|
-
inputSchema: { id:
|
|
754
|
+
inputSchema: { id: EntityRef }
|
|
584
755
|
}, async ({ id }) => {
|
|
585
|
-
const
|
|
756
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
757
|
+
const rows = await c.listRelated(resolvedId);
|
|
758
|
+
const summaries = await summarizeAll(rows);
|
|
586
759
|
return {
|
|
587
760
|
content: [
|
|
588
761
|
{
|
|
589
762
|
type: "text",
|
|
590
763
|
text: rows.length
|
|
591
|
-
?
|
|
764
|
+
? summaries.map((s) => `- ${s}`).join("\n")
|
|
592
765
|
: "no related entities"
|
|
593
766
|
}
|
|
594
767
|
]
|
|
@@ -605,22 +778,222 @@ export function buildMcpServer(ctx) {
|
|
|
605
778
|
openWorldHint: false
|
|
606
779
|
},
|
|
607
780
|
inputSchema: {
|
|
608
|
-
fromId:
|
|
609
|
-
toId:
|
|
781
|
+
fromId: EntityRef.describe("Source entity: UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
782
|
+
toId: EntityRef.describe("Target entity: UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
610
783
|
kind: z.enum(EDGE_KINDS)
|
|
611
784
|
}
|
|
612
785
|
}, async ({ fromId, toId, kind }) => {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
:
|
|
786
|
+
try {
|
|
787
|
+
const resolvedFromId = await resolveEntityRef(c, fromId);
|
|
788
|
+
const resolvedToId = await resolveEntityRef(c, toId);
|
|
789
|
+
const result = await c.link(resolvedFromId, { toId: resolvedToId, kind: kind });
|
|
790
|
+
return {
|
|
791
|
+
content: [
|
|
792
|
+
{
|
|
793
|
+
type: "text",
|
|
794
|
+
text: result.created
|
|
795
|
+
? `linked ${fromId} -[${kind}]-> ${toId}`
|
|
796
|
+
: `link already exists`
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
return formatToolError(err);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
// ----- 16. list_tasks -----
|
|
806
|
+
server.registerTool("list_tasks", {
|
|
807
|
+
title: "List Tasks",
|
|
808
|
+
description: "Return tasks split into Overdue / Scheduled / Backlog buckets for a given date range. Scheduled includes tasks whose display date falls in [from, to). Overdue is unfinished work before the range start (or before now when from is omitted). Backlog is undated tasks (when includeBacklog is true).",
|
|
809
|
+
annotations: {
|
|
810
|
+
readOnlyHint: true,
|
|
811
|
+
destructiveHint: false,
|
|
812
|
+
idempotentHint: true,
|
|
813
|
+
openWorldHint: false
|
|
814
|
+
},
|
|
815
|
+
inputSchema: {
|
|
816
|
+
projectId: z.string().uuid().optional().describe("Scope to a single project UUID."),
|
|
817
|
+
projectIds: z.array(z.string().uuid()).optional().describe("Scope to multiple project UUIDs."),
|
|
818
|
+
from: z.string().datetime().optional().describe("Range start (ISO datetime, inclusive)."),
|
|
819
|
+
to: z.string().datetime().optional().describe("Range end (ISO datetime, exclusive)."),
|
|
820
|
+
includeBacklog: z.boolean().default(true).describe("Include undated tasks."),
|
|
821
|
+
includeDone: z.boolean().default(false).describe("Include completed tasks."),
|
|
822
|
+
includeVirtualRecurrences: z.boolean().default(false).describe("Include ephemeral future ghost occurrences for recurring tasks.")
|
|
823
|
+
}
|
|
824
|
+
}, async ({ projectId, projectIds, from, to, includeBacklog, includeDone, includeVirtualRecurrences }) => {
|
|
825
|
+
// Merge projectId into projectIds for the REST call
|
|
826
|
+
const ids = [];
|
|
827
|
+
if (projectId)
|
|
828
|
+
ids.push(projectId);
|
|
829
|
+
if (projectIds)
|
|
830
|
+
ids.push(...projectIds);
|
|
831
|
+
const result = await c.tasksRange({
|
|
832
|
+
from: from ?? null,
|
|
833
|
+
to: to ?? null,
|
|
834
|
+
projectIds: ids.length ? ids : null,
|
|
835
|
+
includeBacklog,
|
|
836
|
+
includeDone,
|
|
837
|
+
includeVirtualRecurrences
|
|
838
|
+
});
|
|
839
|
+
const lines = [];
|
|
840
|
+
const buckets = [
|
|
841
|
+
["## Overdue", result.overdue.map(serializeEntity)],
|
|
842
|
+
["## Scheduled", result.scheduled.map(serializeEntity)],
|
|
843
|
+
["## Backlog", result.backlog.map(serializeEntity)]
|
|
844
|
+
];
|
|
845
|
+
for (const [header, rows] of buckets) {
|
|
846
|
+
if (!rows.length)
|
|
847
|
+
continue;
|
|
848
|
+
lines.push(header);
|
|
849
|
+
for (const s of await summarizeAll(rows))
|
|
850
|
+
lines.push(`- ${s}`);
|
|
851
|
+
}
|
|
852
|
+
if (result.virtualOccurrences.length) {
|
|
853
|
+
lines.push("## Virtual Occurrences (upcoming, not yet materialized)");
|
|
854
|
+
for (const v of result.virtualOccurrences) {
|
|
855
|
+
lines.push(`- ${v.title ?? "(untitled)"} · ${v.dueAt.slice(0, 10)} [virtual]`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const text = lines.length
|
|
859
|
+
? lines.join("\n")
|
|
860
|
+
: "no tasks";
|
|
861
|
+
return { content: [{ type: "text", text }] };
|
|
862
|
+
});
|
|
863
|
+
// ----- 17. list_tags -----
|
|
864
|
+
server.registerTool("list_tags", {
|
|
865
|
+
title: "List Tags",
|
|
866
|
+
description: "Return distinct tags across all entities, ordered by usage count (most-used first). Optionally scope to a single project. Useful for discovering existing tag vocabulary before creating tasks or notes.",
|
|
867
|
+
annotations: {
|
|
868
|
+
readOnlyHint: true,
|
|
869
|
+
destructiveHint: false,
|
|
870
|
+
idempotentHint: true,
|
|
871
|
+
openWorldHint: false
|
|
872
|
+
},
|
|
873
|
+
inputSchema: {
|
|
874
|
+
projectId: z.string().uuid().optional().describe("Scope to a single project UUID.")
|
|
875
|
+
}
|
|
876
|
+
}, async ({ projectId }) => {
|
|
877
|
+
const tags = await c.listTags(projectId ?? null);
|
|
878
|
+
const text = tags.length
|
|
879
|
+
? tags.map((t) => `${t.tag} (${t.count})`).join("\n")
|
|
880
|
+
: "no tags";
|
|
881
|
+
return { content: [{ type: "text", text }] };
|
|
882
|
+
});
|
|
883
|
+
// ----- 18. get_links -----
|
|
884
|
+
server.registerTool("get_links", {
|
|
885
|
+
title: "Get Links",
|
|
886
|
+
description: "Read typed graph edges for an entity. direction='outgoing' returns edges where the entity is the source; direction='incoming' returns edges where the entity is the target; direction='both' (default) returns all. Filter by kind when you only need a specific edge type (blocks, references, or parent_of).",
|
|
887
|
+
annotations: {
|
|
888
|
+
readOnlyHint: true,
|
|
889
|
+
destructiveHint: false,
|
|
890
|
+
idempotentHint: true,
|
|
891
|
+
openWorldHint: false
|
|
892
|
+
},
|
|
893
|
+
inputSchema: {
|
|
894
|
+
id: EntityRef,
|
|
895
|
+
kind: z.enum(EDGE_KINDS).optional().describe("Edge type filter: blocks, references, or parent_of."),
|
|
896
|
+
direction: z.enum(["outgoing", "incoming", "both"]).default("both").describe("Edge direction relative to the given entity.")
|
|
897
|
+
}
|
|
898
|
+
}, async ({ id, kind, direction }) => {
|
|
899
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
900
|
+
const links = await c.getLinks(resolvedId, { kind: kind ?? null, direction });
|
|
901
|
+
const summaries = await summarizeAll(links.map((l) => serializeEntity(l.entity)));
|
|
902
|
+
const text = links.length
|
|
903
|
+
? links
|
|
904
|
+
.map((l, i) => `- [${l.kind} ▸ ${l.direction}] ${summaries[i]}`)
|
|
905
|
+
.join("\n")
|
|
906
|
+
: "no links";
|
|
907
|
+
return { content: [{ type: "text", text }] };
|
|
908
|
+
});
|
|
909
|
+
// ----- 19. update_entities (batch) -----
|
|
910
|
+
const UpdateEntityItem = z.object({
|
|
911
|
+
id: EntityRef,
|
|
912
|
+
projectId: z
|
|
913
|
+
.string()
|
|
914
|
+
.uuid()
|
|
915
|
+
.nullable()
|
|
916
|
+
.optional()
|
|
917
|
+
.describe("Move to a project UUID, or pass null for workspace scope."),
|
|
918
|
+
title: z.string().max(500).nullable().optional().describe("Pass null to clear the title (notes only)."),
|
|
919
|
+
body: z.string().optional().describe("Replace the entire body. Mutually exclusive with bodyAppend."),
|
|
920
|
+
bodyAppend: z.string().optional().describe("Atomically append text to the body. Mutually exclusive with body."),
|
|
921
|
+
tags: z.array(z.string()).optional(),
|
|
922
|
+
status: z.enum(TASK_STATUSES).optional(),
|
|
923
|
+
parentId: EntityRef.nullable().optional().describe("Re-link or unlink (null) parent."),
|
|
924
|
+
dueAt: z.string().datetime().nullable().optional().describe("ISO datetime or null to clear."),
|
|
925
|
+
priority: z.enum(PRIORITIES).optional(),
|
|
926
|
+
pinned: z.boolean().optional(),
|
|
927
|
+
recurrenceScope: z.enum(["this", "future"]).optional()
|
|
928
|
+
});
|
|
929
|
+
server.registerTool("update_entities", {
|
|
930
|
+
title: "Batch Update Entities",
|
|
931
|
+
description: "Apply per-item patches to multiple tasks or notes in a single call. Each item specifies an entity ref (UUID, UUID prefix, or KEY-SEQ like BOT-55) and the fields to change — same as update_entity. A per-item failure does NOT abort the rest; partial success is reported. Returns one line per item plus a final tally.",
|
|
932
|
+
annotations: {
|
|
933
|
+
readOnlyHint: false,
|
|
934
|
+
destructiveHint: true,
|
|
935
|
+
idempotentHint: false,
|
|
936
|
+
openWorldHint: false
|
|
937
|
+
},
|
|
938
|
+
inputSchema: {
|
|
939
|
+
items: z
|
|
940
|
+
.array(UpdateEntityItem)
|
|
941
|
+
.min(1)
|
|
942
|
+
.max(50)
|
|
943
|
+
.describe("List of entities to update (1–50 items).")
|
|
944
|
+
}
|
|
945
|
+
}, async ({ items }) => {
|
|
946
|
+
try {
|
|
947
|
+
const lines = [];
|
|
948
|
+
let okCount = 0;
|
|
949
|
+
let failCount = 0;
|
|
950
|
+
for (const item of items) {
|
|
951
|
+
const { id, parentId, ...fields } = item;
|
|
952
|
+
try {
|
|
953
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
954
|
+
const resolvedParentId = parentId === null ? null : parentId !== undefined ? await resolveEntityRef(c, parentId) : undefined;
|
|
955
|
+
const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
956
|
+
if (resolvedParentId !== undefined)
|
|
957
|
+
defined.parentId = resolvedParentId;
|
|
958
|
+
const updated = await c.updateEntity(resolvedId, defined);
|
|
959
|
+
lines.push(`✓ ${await summarize(updated)}`);
|
|
960
|
+
okCount++;
|
|
621
961
|
}
|
|
622
|
-
|
|
623
|
-
|
|
962
|
+
catch (err) {
|
|
963
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
964
|
+
lines.push(`✗ ${id}: ${msg}`);
|
|
965
|
+
failCount++;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
lines.push(`\n${okCount} ok, ${failCount} failed`);
|
|
969
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
return formatToolError(err);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
// ----- 20. context -----
|
|
976
|
+
server.registerTool("context", {
|
|
977
|
+
title: "Server Context",
|
|
978
|
+
description: "Return the current server time, workspace timezone, botnote version, and project list. Call this tool first to resolve relative dates ('tomorrow', 'next Monday') and to pick the correct project UUID before creating tasks.",
|
|
979
|
+
annotations: {
|
|
980
|
+
readOnlyHint: true,
|
|
981
|
+
destructiveHint: false,
|
|
982
|
+
idempotentHint: true,
|
|
983
|
+
openWorldHint: false
|
|
984
|
+
},
|
|
985
|
+
inputSchema: {}
|
|
986
|
+
}, async () => {
|
|
987
|
+
const ctx = await c.getContext();
|
|
988
|
+
const projectLines = ctx.projects.map((p) => ` ${p.key} — ${p.name} [${p.status}]`);
|
|
989
|
+
const text = [
|
|
990
|
+
`now: ${ctx.now}`,
|
|
991
|
+
`timezone: ${ctx.timezone}`,
|
|
992
|
+
`version: ${ctx.version}`,
|
|
993
|
+
`projects:`,
|
|
994
|
+
...projectLines
|
|
995
|
+
].join("\n");
|
|
996
|
+
return { content: [{ type: "text", text }] };
|
|
624
997
|
});
|
|
625
998
|
// ----- bonus resource: workspace overview -----
|
|
626
999
|
server.registerResource("workspace_overview", "botnote://workspace", {
|
|
@@ -642,8 +1015,9 @@ export function buildMcpServer(ctx) {
|
|
|
642
1015
|
}
|
|
643
1016
|
lines.push("");
|
|
644
1017
|
lines.push("## Recent (workspace-wide)");
|
|
645
|
-
|
|
646
|
-
|
|
1018
|
+
const summaries = await summarizeAll(recentRows);
|
|
1019
|
+
for (let i = 0; i < recentRows.length; i++) {
|
|
1020
|
+
lines.push(`- ${recentRows[i].createdAt.slice(0, 16).replace("T", " ")} · ${summaries[i]}`);
|
|
647
1021
|
}
|
|
648
1022
|
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: lines.join("\n") }] };
|
|
649
1023
|
});
|