botnote 0.1.30 → 0.1.31
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 +56 -0
- package/dist/mcp/http-client.js +22 -0
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.js +396 -92
- 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 +8 -2
- package/dist/service/opening_brief.js.map +1 -1
- package/dist/service/types.d.ts +52 -3
- package/dist/service/types.js +14 -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;
|
|
@@ -34,7 +50,45 @@ function displayTitle(e) {
|
|
|
34
50
|
}
|
|
35
51
|
function summarizeEntity(e) {
|
|
36
52
|
const tagPart = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
|
|
37
|
-
|
|
53
|
+
const base = `${e.kind}/${e.id} · id: ${e.id} · ${displayTitle(e)}${tagPart}`;
|
|
54
|
+
if (e.kind !== "task")
|
|
55
|
+
return base;
|
|
56
|
+
// Compact task suffix: status, optional priority, and due/completedAt date.
|
|
57
|
+
const parts = [e.status];
|
|
58
|
+
if (e.priority && e.priority !== "none")
|
|
59
|
+
parts.push(e.priority);
|
|
60
|
+
if (e.status === "done" && e.completedAt) {
|
|
61
|
+
parts.push(`done ${e.completedAt.slice(0, 10)}`);
|
|
62
|
+
}
|
|
63
|
+
else if (e.dueAt) {
|
|
64
|
+
parts.push(`due ${e.dueAt.slice(0, 10)}`);
|
|
65
|
+
}
|
|
66
|
+
return `${base} (${parts.join(", ")})`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Format a caught error from a write-tool handler into a structured isError
|
|
70
|
+
* response. For BotnoteHttpError produces a human-readable hint keyed to the
|
|
71
|
+
* status code; for everything else falls back to the error message.
|
|
72
|
+
*/
|
|
73
|
+
function formatToolError(err) {
|
|
74
|
+
let text;
|
|
75
|
+
if (err instanceof BotnoteHttpError) {
|
|
76
|
+
const body = err.body?.trim() || err.statusText;
|
|
77
|
+
if (err.status === 404) {
|
|
78
|
+
text = `not found: ${body}`;
|
|
79
|
+
}
|
|
80
|
+
else if (err.status === 400 || err.status === 422) {
|
|
81
|
+
text = `invalid request: ${body}`;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
text = `HTTP ${err.status} ${err.statusText}: ${body}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const e = err;
|
|
89
|
+
text = String(e?.message ?? err);
|
|
90
|
+
}
|
|
91
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
38
92
|
}
|
|
39
93
|
const ProjectKey = z
|
|
40
94
|
.string()
|
|
@@ -262,17 +316,18 @@ export function buildMcpServer(ctx) {
|
|
|
262
316
|
// ----- 8. get_entity -----
|
|
263
317
|
server.registerTool("get_entity", {
|
|
264
318
|
title: "Get Entity",
|
|
265
|
-
description: "Fetch a single task or note by its UUID
|
|
319
|
+
description: "Fetch a single task or note by its UUID, unique UUID prefix, or human-readable KEY-SEQ (e.g. BOT-55).",
|
|
266
320
|
annotations: {
|
|
267
321
|
readOnlyHint: true,
|
|
268
322
|
destructiveHint: false,
|
|
269
323
|
idempotentHint: true,
|
|
270
324
|
openWorldHint: false
|
|
271
325
|
},
|
|
272
|
-
inputSchema: { id:
|
|
326
|
+
inputSchema: { id: EntityRef }
|
|
273
327
|
}, async ({ id }) => {
|
|
274
328
|
try {
|
|
275
|
-
const
|
|
329
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
330
|
+
const entity = await c.getEntity(resolvedId);
|
|
276
331
|
return { content: [{ type: "text", text: JSON.stringify(serializeEntity(entity), null, 2) }] };
|
|
277
332
|
}
|
|
278
333
|
catch {
|
|
@@ -330,26 +385,34 @@ export function buildMcpServer(ctx) {
|
|
|
330
385
|
body: z.string().default(""),
|
|
331
386
|
tags: z.array(z.string()).default([]),
|
|
332
387
|
status: z.enum(TASK_STATUSES).default("open"),
|
|
333
|
-
parentId:
|
|
388
|
+
parentId: EntityRef.optional().describe("Link this task under another entity. Accepts UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
334
389
|
actorKind: z.enum(ACTOR_KINDS).default("agent"),
|
|
335
390
|
dueAt: z.string().datetime().optional().describe("ISO datetime."),
|
|
336
391
|
priority: z.enum(PRIORITIES).default("none"),
|
|
337
392
|
idempotencyKey: z.string().min(1).max(200).optional()
|
|
338
393
|
}
|
|
339
394
|
}, async (input) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
395
|
+
try {
|
|
396
|
+
const resolvedParentId = input.parentId
|
|
397
|
+
? await resolveEntityRef(c, input.parentId)
|
|
398
|
+
: null;
|
|
399
|
+
const entity = await c.createTask({
|
|
400
|
+
projectId: input.projectId ?? null,
|
|
401
|
+
title: input.title,
|
|
402
|
+
body: input.body,
|
|
403
|
+
tags: input.tags,
|
|
404
|
+
status: input.status,
|
|
405
|
+
parentId: resolvedParentId,
|
|
406
|
+
actorKind: input.actorKind,
|
|
407
|
+
dueAt: input.dueAt ?? null,
|
|
408
|
+
priority: input.priority,
|
|
409
|
+
idempotencyKey: input.idempotencyKey
|
|
410
|
+
});
|
|
411
|
+
return { content: [{ type: "text", text: `created task ${summarizeEntity(entity)}\nid: ${entity.id}` }] };
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
return formatToolError(err);
|
|
415
|
+
}
|
|
353
416
|
});
|
|
354
417
|
// ----- 11. remember (= create_note) -----
|
|
355
418
|
server.registerTool("remember", {
|
|
@@ -374,23 +437,31 @@ export function buildMcpServer(ctx) {
|
|
|
374
437
|
title: z.string().max(500).optional().describe("Optional; first body line is used as fallback."),
|
|
375
438
|
body: z.string().default(""),
|
|
376
439
|
tags: z.array(z.string()).default([]),
|
|
377
|
-
parentId:
|
|
440
|
+
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
441
|
actorKind: z.enum(ACTOR_KINDS).default("agent"),
|
|
379
442
|
pinned: z.boolean().default(false).describe("Pin to project opening brief (must-read context)."),
|
|
380
443
|
idempotencyKey: z.string().min(1).max(200).optional()
|
|
381
444
|
}
|
|
382
445
|
}, async (input) => {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
446
|
+
try {
|
|
447
|
+
const resolvedParentId = input.parentId
|
|
448
|
+
? await resolveEntityRef(c, input.parentId)
|
|
449
|
+
: null;
|
|
450
|
+
const entity = await c.remember({
|
|
451
|
+
projectId: input.projectId ?? null,
|
|
452
|
+
title: input.title ?? null,
|
|
453
|
+
body: input.body,
|
|
454
|
+
tags: input.tags,
|
|
455
|
+
parentId: resolvedParentId,
|
|
456
|
+
actorKind: input.actorKind,
|
|
457
|
+
pinned: input.pinned,
|
|
458
|
+
idempotencyKey: input.idempotencyKey
|
|
459
|
+
});
|
|
460
|
+
return { content: [{ type: "text", text: `remembered ${summarizeEntity(entity)}\nid: ${entity.id}` }] };
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
return formatToolError(err);
|
|
464
|
+
}
|
|
394
465
|
});
|
|
395
466
|
// ----- 12. update_entity -----
|
|
396
467
|
server.registerTool("update_entity", {
|
|
@@ -411,17 +482,21 @@ export function buildMcpServer(ctx) {
|
|
|
411
482
|
openWorldHint: false
|
|
412
483
|
},
|
|
413
484
|
inputSchema: {
|
|
414
|
-
id:
|
|
485
|
+
id: EntityRef,
|
|
415
486
|
title: z
|
|
416
487
|
.string()
|
|
417
488
|
.max(500)
|
|
418
489
|
.nullable()
|
|
419
490
|
.optional()
|
|
420
491
|
.describe("Pass null to clear the title (notes only)."),
|
|
421
|
-
body: z.string().optional(),
|
|
492
|
+
body: z.string().optional().describe("Replace the entire body. Mutually exclusive with bodyAppend."),
|
|
493
|
+
bodyAppend: z
|
|
494
|
+
.string()
|
|
495
|
+
.optional()
|
|
496
|
+
.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
497
|
tags: z.array(z.string()).optional(),
|
|
423
498
|
status: z.enum(TASK_STATUSES).optional(),
|
|
424
|
-
parentId:
|
|
499
|
+
parentId: EntityRef.nullable().optional().describe("Re-link or unlink (null) parent. Accepts UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
425
500
|
dueAt: z.string().datetime().nullable().optional().describe("ISO datetime or null to clear."),
|
|
426
501
|
priority: z.enum(PRIORITIES).optional(),
|
|
427
502
|
pinned: z.boolean().optional().describe("Pin or unpin from project opening brief."),
|
|
@@ -430,10 +505,19 @@ export function buildMcpServer(ctx) {
|
|
|
430
505
|
.optional()
|
|
431
506
|
.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
507
|
}
|
|
433
|
-
}, async ({ id, ...fields }) => {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
508
|
+
}, async ({ id, parentId, ...fields }) => {
|
|
509
|
+
try {
|
|
510
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
511
|
+
const resolvedParentId = parentId === null ? null : parentId !== undefined ? await resolveEntityRef(c, parentId) : undefined;
|
|
512
|
+
const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
513
|
+
if (resolvedParentId !== undefined)
|
|
514
|
+
defined.parentId = resolvedParentId;
|
|
515
|
+
const updated = await c.updateEntity(resolvedId, defined);
|
|
516
|
+
return { content: [{ type: "text", text: `updated ${summarizeEntity(updated)}` }] };
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
return formatToolError(err);
|
|
520
|
+
}
|
|
437
521
|
});
|
|
438
522
|
// ----- 13. recurrence -----
|
|
439
523
|
server.registerTool("configure_recurrence", {
|
|
@@ -446,7 +530,7 @@ export function buildMcpServer(ctx) {
|
|
|
446
530
|
openWorldHint: false
|
|
447
531
|
},
|
|
448
532
|
inputSchema: {
|
|
449
|
-
taskId:
|
|
533
|
+
taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
450
534
|
rrule: z.string().min(1).max(1000).optional().describe("Raw RRULE such as FREQ=WEEKLY;BYDAY=MO."),
|
|
451
535
|
preset: z.enum(RECURRENCE_PRESETS).optional(),
|
|
452
536
|
interval: z.number().int().min(1).max(999).optional(),
|
|
@@ -462,16 +546,22 @@ export function buildMcpServer(ctx) {
|
|
|
462
546
|
anchor: z.enum(RECURRENCE_ANCHORS).optional()
|
|
463
547
|
}
|
|
464
548
|
}, async ({ taskId, ...fields }) => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
549
|
+
try {
|
|
550
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
551
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
552
|
+
const rule = await c.configureRecurrence(resolvedTaskId, body);
|
|
553
|
+
return {
|
|
554
|
+
content: [
|
|
555
|
+
{
|
|
556
|
+
type: "text",
|
|
557
|
+
text: `configured recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
|
|
558
|
+
}
|
|
559
|
+
]
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
return formatToolError(err);
|
|
564
|
+
}
|
|
475
565
|
});
|
|
476
566
|
server.registerTool("get_recurrence", {
|
|
477
567
|
title: "Get Recurrence",
|
|
@@ -482,9 +572,10 @@ export function buildMcpServer(ctx) {
|
|
|
482
572
|
idempotentHint: true,
|
|
483
573
|
openWorldHint: false
|
|
484
574
|
},
|
|
485
|
-
inputSchema: { taskId:
|
|
575
|
+
inputSchema: { taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55.") }
|
|
486
576
|
}, async ({ taskId }) => {
|
|
487
|
-
const
|
|
577
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
578
|
+
const details = await c.getRecurrence(resolvedTaskId);
|
|
488
579
|
const serialized = {
|
|
489
580
|
...details,
|
|
490
581
|
currentOccurrence: details.currentOccurrence
|
|
@@ -503,19 +594,25 @@ export function buildMcpServer(ctx) {
|
|
|
503
594
|
openWorldHint: false
|
|
504
595
|
},
|
|
505
596
|
inputSchema: {
|
|
506
|
-
taskId:
|
|
597
|
+
taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
507
598
|
reason: z.string().max(500).optional()
|
|
508
599
|
}
|
|
509
600
|
}, async ({ taskId, reason }) => {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
601
|
+
try {
|
|
602
|
+
const resolvedTaskId = await resolveEntityRef(c, taskId);
|
|
603
|
+
const result = await c.skipOccurrence(resolvedTaskId, { reason, actorKind: "agent" });
|
|
604
|
+
return {
|
|
605
|
+
content: [
|
|
606
|
+
{
|
|
607
|
+
type: "text",
|
|
608
|
+
text: `skipped ${summarizeEntity(result.skipped)}\nnext: ${result.next ? summarizeEntity(result.next) : "-"}`
|
|
609
|
+
}
|
|
610
|
+
]
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
return formatToolError(err);
|
|
615
|
+
}
|
|
519
616
|
});
|
|
520
617
|
server.registerTool("stop_recurrence", {
|
|
521
618
|
title: "Stop Recurrence",
|
|
@@ -531,8 +628,13 @@ export function buildMcpServer(ctx) {
|
|
|
531
628
|
reason: z.string().max(500).optional()
|
|
532
629
|
}
|
|
533
630
|
}, async ({ ruleId, reason }) => {
|
|
534
|
-
|
|
535
|
-
|
|
631
|
+
try {
|
|
632
|
+
const rule = await c.stopRecurrence(ruleId, { reason });
|
|
633
|
+
return { content: [{ type: "text", text: `stopped recurrence ${rule.id}` }] };
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
return formatToolError(err);
|
|
637
|
+
}
|
|
536
638
|
});
|
|
537
639
|
server.registerTool("split_recurrence", {
|
|
538
640
|
title: "Split Recurrence",
|
|
@@ -559,16 +661,21 @@ export function buildMcpServer(ctx) {
|
|
|
559
661
|
anchor: z.enum(RECURRENCE_ANCHORS).optional()
|
|
560
662
|
}
|
|
561
663
|
}, async ({ ruleId, ...fields }) => {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
664
|
+
try {
|
|
665
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
666
|
+
const rule = await c.splitRecurrence(ruleId, body);
|
|
667
|
+
return {
|
|
668
|
+
content: [
|
|
669
|
+
{
|
|
670
|
+
type: "text",
|
|
671
|
+
text: `split into recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
|
|
672
|
+
}
|
|
673
|
+
]
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
return formatToolError(err);
|
|
678
|
+
}
|
|
572
679
|
});
|
|
573
680
|
// ----- 14. related -----
|
|
574
681
|
server.registerTool("related", {
|
|
@@ -580,9 +687,10 @@ export function buildMcpServer(ctx) {
|
|
|
580
687
|
idempotentHint: true,
|
|
581
688
|
openWorldHint: false
|
|
582
689
|
},
|
|
583
|
-
inputSchema: { id:
|
|
690
|
+
inputSchema: { id: EntityRef }
|
|
584
691
|
}, async ({ id }) => {
|
|
585
|
-
const
|
|
692
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
693
|
+
const rows = await c.listRelated(resolvedId);
|
|
586
694
|
return {
|
|
587
695
|
content: [
|
|
588
696
|
{
|
|
@@ -605,22 +713,218 @@ export function buildMcpServer(ctx) {
|
|
|
605
713
|
openWorldHint: false
|
|
606
714
|
},
|
|
607
715
|
inputSchema: {
|
|
608
|
-
fromId:
|
|
609
|
-
toId:
|
|
716
|
+
fromId: EntityRef.describe("Source entity: UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
717
|
+
toId: EntityRef.describe("Target entity: UUID, UUID prefix, or KEY-SEQ such as BOT-55."),
|
|
610
718
|
kind: z.enum(EDGE_KINDS)
|
|
611
719
|
}
|
|
612
720
|
}, async ({ fromId, toId, kind }) => {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
:
|
|
721
|
+
try {
|
|
722
|
+
const resolvedFromId = await resolveEntityRef(c, fromId);
|
|
723
|
+
const resolvedToId = await resolveEntityRef(c, toId);
|
|
724
|
+
const result = await c.link(resolvedFromId, { toId: resolvedToId, kind: kind });
|
|
725
|
+
return {
|
|
726
|
+
content: [
|
|
727
|
+
{
|
|
728
|
+
type: "text",
|
|
729
|
+
text: result.created
|
|
730
|
+
? `linked ${fromId} -[${kind}]-> ${toId}`
|
|
731
|
+
: `link already exists`
|
|
732
|
+
}
|
|
733
|
+
]
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
return formatToolError(err);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
// ----- 16. list_tasks -----
|
|
741
|
+
server.registerTool("list_tasks", {
|
|
742
|
+
title: "List Tasks",
|
|
743
|
+
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).",
|
|
744
|
+
annotations: {
|
|
745
|
+
readOnlyHint: true,
|
|
746
|
+
destructiveHint: false,
|
|
747
|
+
idempotentHint: true,
|
|
748
|
+
openWorldHint: false
|
|
749
|
+
},
|
|
750
|
+
inputSchema: {
|
|
751
|
+
projectId: z.string().uuid().optional().describe("Scope to a single project UUID."),
|
|
752
|
+
projectIds: z.array(z.string().uuid()).optional().describe("Scope to multiple project UUIDs."),
|
|
753
|
+
from: z.string().datetime().optional().describe("Range start (ISO datetime, inclusive)."),
|
|
754
|
+
to: z.string().datetime().optional().describe("Range end (ISO datetime, exclusive)."),
|
|
755
|
+
includeBacklog: z.boolean().default(true).describe("Include undated tasks."),
|
|
756
|
+
includeDone: z.boolean().default(false).describe("Include completed tasks."),
|
|
757
|
+
includeVirtualRecurrences: z.boolean().default(false).describe("Include ephemeral future ghost occurrences for recurring tasks.")
|
|
758
|
+
}
|
|
759
|
+
}, async ({ projectId, projectIds, from, to, includeBacklog, includeDone, includeVirtualRecurrences }) => {
|
|
760
|
+
// Merge projectId into projectIds for the REST call
|
|
761
|
+
const ids = [];
|
|
762
|
+
if (projectId)
|
|
763
|
+
ids.push(projectId);
|
|
764
|
+
if (projectIds)
|
|
765
|
+
ids.push(...projectIds);
|
|
766
|
+
const result = await c.tasksRange({
|
|
767
|
+
from: from ?? null,
|
|
768
|
+
to: to ?? null,
|
|
769
|
+
projectIds: ids.length ? ids : null,
|
|
770
|
+
includeBacklog,
|
|
771
|
+
includeDone,
|
|
772
|
+
includeVirtualRecurrences
|
|
773
|
+
});
|
|
774
|
+
const lines = [];
|
|
775
|
+
if (result.overdue.length) {
|
|
776
|
+
lines.push("## Overdue");
|
|
777
|
+
for (const e of result.overdue)
|
|
778
|
+
lines.push(`- ${summarizeEntity(serializeEntity(e))}`);
|
|
779
|
+
}
|
|
780
|
+
if (result.scheduled.length) {
|
|
781
|
+
lines.push("## Scheduled");
|
|
782
|
+
for (const e of result.scheduled)
|
|
783
|
+
lines.push(`- ${summarizeEntity(serializeEntity(e))}`);
|
|
784
|
+
}
|
|
785
|
+
if (result.backlog.length) {
|
|
786
|
+
lines.push("## Backlog");
|
|
787
|
+
for (const e of result.backlog)
|
|
788
|
+
lines.push(`- ${summarizeEntity(serializeEntity(e))}`);
|
|
789
|
+
}
|
|
790
|
+
if (result.virtualOccurrences.length) {
|
|
791
|
+
lines.push("## Virtual Occurrences (upcoming, not yet materialized)");
|
|
792
|
+
for (const v of result.virtualOccurrences) {
|
|
793
|
+
lines.push(`- ${v.title ?? "(untitled)"} · ${v.dueAt.slice(0, 10)} [virtual]`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const text = lines.length
|
|
797
|
+
? lines.join("\n")
|
|
798
|
+
: "no tasks";
|
|
799
|
+
return { content: [{ type: "text", text }] };
|
|
800
|
+
});
|
|
801
|
+
// ----- 17. list_tags -----
|
|
802
|
+
server.registerTool("list_tags", {
|
|
803
|
+
title: "List Tags",
|
|
804
|
+
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.",
|
|
805
|
+
annotations: {
|
|
806
|
+
readOnlyHint: true,
|
|
807
|
+
destructiveHint: false,
|
|
808
|
+
idempotentHint: true,
|
|
809
|
+
openWorldHint: false
|
|
810
|
+
},
|
|
811
|
+
inputSchema: {
|
|
812
|
+
projectId: z.string().uuid().optional().describe("Scope to a single project UUID.")
|
|
813
|
+
}
|
|
814
|
+
}, async ({ projectId }) => {
|
|
815
|
+
const tags = await c.listTags(projectId ?? null);
|
|
816
|
+
const text = tags.length
|
|
817
|
+
? tags.map((t) => `${t.tag} (${t.count})`).join("\n")
|
|
818
|
+
: "no tags";
|
|
819
|
+
return { content: [{ type: "text", text }] };
|
|
820
|
+
});
|
|
821
|
+
// ----- 18. get_links -----
|
|
822
|
+
server.registerTool("get_links", {
|
|
823
|
+
title: "Get Links",
|
|
824
|
+
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).",
|
|
825
|
+
annotations: {
|
|
826
|
+
readOnlyHint: true,
|
|
827
|
+
destructiveHint: false,
|
|
828
|
+
idempotentHint: true,
|
|
829
|
+
openWorldHint: false
|
|
830
|
+
},
|
|
831
|
+
inputSchema: {
|
|
832
|
+
id: EntityRef,
|
|
833
|
+
kind: z.enum(EDGE_KINDS).optional().describe("Edge type filter: blocks, references, or parent_of."),
|
|
834
|
+
direction: z.enum(["outgoing", "incoming", "both"]).default("both").describe("Edge direction relative to the given entity.")
|
|
835
|
+
}
|
|
836
|
+
}, async ({ id, kind, direction }) => {
|
|
837
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
838
|
+
const links = await c.getLinks(resolvedId, { kind: kind ?? null, direction });
|
|
839
|
+
const text = links.length
|
|
840
|
+
? links
|
|
841
|
+
.map((l) => `- [${l.kind} ▸ ${l.direction}] ${summarizeEntity(serializeEntity(l.entity))}`)
|
|
842
|
+
.join("\n")
|
|
843
|
+
: "no links";
|
|
844
|
+
return { content: [{ type: "text", text }] };
|
|
845
|
+
});
|
|
846
|
+
// ----- 19. update_entities (batch) -----
|
|
847
|
+
const UpdateEntityItem = z.object({
|
|
848
|
+
id: EntityRef,
|
|
849
|
+
title: z.string().max(500).nullable().optional().describe("Pass null to clear the title (notes only)."),
|
|
850
|
+
body: z.string().optional().describe("Replace the entire body. Mutually exclusive with bodyAppend."),
|
|
851
|
+
bodyAppend: z.string().optional().describe("Atomically append text to the body. Mutually exclusive with body."),
|
|
852
|
+
tags: z.array(z.string()).optional(),
|
|
853
|
+
status: z.enum(TASK_STATUSES).optional(),
|
|
854
|
+
parentId: EntityRef.nullable().optional().describe("Re-link or unlink (null) parent."),
|
|
855
|
+
dueAt: z.string().datetime().nullable().optional().describe("ISO datetime or null to clear."),
|
|
856
|
+
priority: z.enum(PRIORITIES).optional(),
|
|
857
|
+
pinned: z.boolean().optional(),
|
|
858
|
+
recurrenceScope: z.enum(["this", "future"]).optional()
|
|
859
|
+
});
|
|
860
|
+
server.registerTool("update_entities", {
|
|
861
|
+
title: "Batch Update Entities",
|
|
862
|
+
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.",
|
|
863
|
+
annotations: {
|
|
864
|
+
readOnlyHint: false,
|
|
865
|
+
destructiveHint: true,
|
|
866
|
+
idempotentHint: false,
|
|
867
|
+
openWorldHint: false
|
|
868
|
+
},
|
|
869
|
+
inputSchema: {
|
|
870
|
+
items: z
|
|
871
|
+
.array(UpdateEntityItem)
|
|
872
|
+
.min(1)
|
|
873
|
+
.max(50)
|
|
874
|
+
.describe("List of entities to update (1–50 items).")
|
|
875
|
+
}
|
|
876
|
+
}, async ({ items }) => {
|
|
877
|
+
try {
|
|
878
|
+
const lines = [];
|
|
879
|
+
let okCount = 0;
|
|
880
|
+
let failCount = 0;
|
|
881
|
+
for (const item of items) {
|
|
882
|
+
const { id, parentId, ...fields } = item;
|
|
883
|
+
try {
|
|
884
|
+
const resolvedId = await resolveEntityRef(c, id);
|
|
885
|
+
const resolvedParentId = parentId === null ? null : parentId !== undefined ? await resolveEntityRef(c, parentId) : undefined;
|
|
886
|
+
const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
887
|
+
if (resolvedParentId !== undefined)
|
|
888
|
+
defined.parentId = resolvedParentId;
|
|
889
|
+
const updated = await c.updateEntity(resolvedId, defined);
|
|
890
|
+
lines.push(`✓ ${summarizeEntity(updated)}`);
|
|
891
|
+
okCount++;
|
|
621
892
|
}
|
|
622
|
-
|
|
623
|
-
|
|
893
|
+
catch (err) {
|
|
894
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
895
|
+
lines.push(`✗ ${id}: ${msg}`);
|
|
896
|
+
failCount++;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
lines.push(`\n${okCount} ok, ${failCount} failed`);
|
|
900
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
return formatToolError(err);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
// ----- 20. context -----
|
|
907
|
+
server.registerTool("context", {
|
|
908
|
+
title: "Server Context",
|
|
909
|
+
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.",
|
|
910
|
+
annotations: {
|
|
911
|
+
readOnlyHint: true,
|
|
912
|
+
destructiveHint: false,
|
|
913
|
+
idempotentHint: true,
|
|
914
|
+
openWorldHint: false
|
|
915
|
+
},
|
|
916
|
+
inputSchema: {}
|
|
917
|
+
}, async () => {
|
|
918
|
+
const ctx = await c.getContext();
|
|
919
|
+
const projectLines = ctx.projects.map((p) => ` ${p.key} — ${p.name} [${p.status}]`);
|
|
920
|
+
const text = [
|
|
921
|
+
`now: ${ctx.now}`,
|
|
922
|
+
`timezone: ${ctx.timezone}`,
|
|
923
|
+
`version: ${ctx.version}`,
|
|
924
|
+
`projects:`,
|
|
925
|
+
...projectLines
|
|
926
|
+
].join("\n");
|
|
927
|
+
return { content: [{ type: "text", text }] };
|
|
624
928
|
});
|
|
625
929
|
// ----- bonus resource: workspace overview -----
|
|
626
930
|
server.registerResource("workspace_overview", "botnote://workspace", {
|