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.
@@ -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
- const EntityIdRef = z
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(8)
24
- .max(36)
25
- .regex(/^[0-9a-fA-F-]+$/)
26
- .describe("Full entity UUID or a unique UUID prefix such as the first 8 hex characters.");
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
- return `${e.kind}/${e.id} · id: ${e.id} · ${displayTitle(e)}${tagPart}`;
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 or unique UUID prefix.",
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: EntityIdRef }
326
+ inputSchema: { id: EntityRef }
273
327
  }, async ({ id }) => {
274
328
  try {
275
- const entity = await c.getEntity(id);
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: z.string().uuid().optional().describe("Link this task under another entity."),
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
- const entity = await c.createTask({
341
- projectId: input.projectId ?? null,
342
- title: input.title,
343
- body: input.body,
344
- tags: input.tags,
345
- status: input.status,
346
- parentId: input.parentId ?? null,
347
- actorKind: input.actorKind,
348
- dueAt: input.dueAt ?? null,
349
- priority: input.priority,
350
- idempotencyKey: input.idempotencyKey
351
- });
352
- return { content: [{ type: "text", text: `created task ${summarizeEntity(entity)}\nid: ${entity.id}` }] };
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: z.string().uuid().optional().describe("Link this note under a task or other entity."),
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
- const entity = await c.remember({
384
- projectId: input.projectId ?? null,
385
- title: input.title ?? null,
386
- body: input.body,
387
- tags: input.tags,
388
- parentId: input.parentId ?? null,
389
- actorKind: input.actorKind,
390
- pinned: input.pinned,
391
- idempotencyKey: input.idempotencyKey
392
- });
393
- return { content: [{ type: "text", text: `remembered ${summarizeEntity(entity)}\nid: ${entity.id}` }] };
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: EntityIdRef,
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: z.string().uuid().nullable().optional().describe("Re-link or unlink (null) parent."),
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
- const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
435
- const updated = await c.updateEntity(id, defined);
436
- return { content: [{ type: "text", text: `updated ${summarizeEntity(updated)}` }] };
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: z.string().uuid(),
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
- const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
466
- const rule = await c.configureRecurrence(taskId, body);
467
- return {
468
- content: [
469
- {
470
- type: "text",
471
- text: `configured recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
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: z.string().uuid() }
575
+ inputSchema: { taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55.") }
486
576
  }, async ({ taskId }) => {
487
- const details = await c.getRecurrence(taskId);
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: z.string().uuid(),
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
- const result = await c.skipOccurrence(taskId, { reason, actorKind: "agent" });
511
- return {
512
- content: [
513
- {
514
- type: "text",
515
- text: `skipped ${summarizeEntity(result.skipped)}\nnext: ${result.next ? summarizeEntity(result.next) : "-"}`
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
- const rule = await c.stopRecurrence(ruleId, { reason });
535
- return { content: [{ type: "text", text: `stopped recurrence ${rule.id}` }] };
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
- const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
563
- const rule = await c.splitRecurrence(ruleId, body);
564
- return {
565
- content: [
566
- {
567
- type: "text",
568
- text: `split into recurrence ${rule.id}\nrrule: ${rule.rrule}\nanchor: ${rule.anchor}\nnext: ${rule.nextOccurrenceAt ?? "-"}`
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: EntityIdRef }
690
+ inputSchema: { id: EntityRef }
584
691
  }, async ({ id }) => {
585
- const rows = await c.listRelated(id);
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: z.string().uuid(),
609
- toId: z.string().uuid(),
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
- const result = await c.link(fromId, { toId, kind: kind });
614
- return {
615
- content: [
616
- {
617
- type: "text",
618
- text: result.created
619
- ? `linked ${fromId} -[${kind}]-> ${toId}`
620
- : `link already exists`
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", {