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.
@@ -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;
@@ -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
- function summarizeEntity(e) {
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
- return `${e.kind}/${e.id} · id: ${e.id} · ${displayTitle(e)}${tagPart}`;
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 lines = hits.map((h) => `${h.score.toFixed(4)} ${summarizeEntity(h.entity)}\n ${h.entity.body.slice(0, 200).replace(/\n/g, " ")}`);
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", " ")} · ${summarizeEntity(r)}`)
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 or unique UUID prefix.",
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: EntityIdRef }
376
+ inputSchema: { id: EntityRef }
273
377
  }, async ({ id }) => {
274
378
  try {
275
- const entity = await c.getEntity(id);
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: z.string().uuid().optional().describe("Link this task under another entity."),
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
- 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}` }] };
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: z.string().uuid().optional().describe("Link this note under a task or other entity."),
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
- 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}` }] };
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: EntityIdRef,
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: z.string().uuid().nullable().optional().describe("Re-link or unlink (null) parent."),
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
- 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)}` }] };
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: z.string().uuid(),
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
- 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
- };
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: z.string().uuid() }
639
+ inputSchema: { taskId: EntityRef.describe("Task UUID, unique UUID prefix, or KEY-SEQ such as BOT-55.") }
486
640
  }, async ({ taskId }) => {
487
- const details = await c.getRecurrence(taskId);
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: z.string().uuid(),
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
- 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
- };
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
- const rule = await c.stopRecurrence(ruleId, { reason });
535
- return { content: [{ type: "text", text: `stopped recurrence ${rule.id}` }] };
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
- 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
- };
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: EntityIdRef }
754
+ inputSchema: { id: EntityRef }
584
755
  }, async ({ id }) => {
585
- const rows = await c.listRelated(id);
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
- ? rows.map((r) => `- ${summarizeEntity(r)}`).join("\n")
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: z.string().uuid(),
609
- toId: z.string().uuid(),
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
- 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`
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
- for (const r of recentRows) {
646
- lines.push(`- ${r.createdAt.slice(0, 16).replace("T", " ")} · ${r.kind}/${r.id} · ${displayTitle(r)}`);
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
  });