@abraca/mcp 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -42,6 +42,17 @@ declare class AbracadabraMCPServer {
42
42
  /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
43
43
  private _toolHistory;
44
44
  private static readonly TOOL_HISTORY_MAX;
45
+ private _inboxStarted;
46
+ private _inboxDocId;
47
+ private _inboxDoc;
48
+ private _inboxProvider;
49
+ private _inboxDisposers;
50
+ private _inboxInitialized;
51
+ private _seenInboxIds;
52
+ /** Shared message-id dedupe so the inbox path and `_handleStatelessChat`
53
+ * (subscribed-doc broadcast) never dispatch the same message twice. */
54
+ private _dispatchedMessageIds;
55
+ private static readonly DEDUPE_MAX;
45
56
  constructor(config: MCPServerConfig);
46
57
  get agentName(): string;
47
58
  get agentColor(): string;
@@ -89,6 +100,36 @@ declare class AbracadabraMCPServer {
89
100
  private evictIdle;
90
101
  /** Wire up real-time channel notifications via awareness observation and stateless chat. */
91
102
  startChannelNotifications(mcpServer: McpServer): void;
103
+ /**
104
+ * Bootstrap inbox observation. Sends `messages:inbox_fetch` over the active
105
+ * provider; the server's `messages:inbox_history` reply carries the
106
+ * `inbox_doc_id`. We then open a dedicated provider on that doc and observe
107
+ * its `entries` Y.Array for live DM/mention dispatch. Mirrors the
108
+ * dashboard's `useNotifications` pattern.
109
+ */
110
+ private _startInboxNotifications;
111
+ /**
112
+ * Open a dedicated provider on the agent's inbox doc and observe its
113
+ * `entries` Y.Array. The server is the only writer; we only read.
114
+ */
115
+ private _ensureInboxObserver;
116
+ /**
117
+ * Diff the inbox `entries` array against what we've already seen. On the
118
+ * first pump we only record a baseline (don't replay history). New entries
119
+ * are classified by their authoritative `kind` and dispatched.
120
+ */
121
+ private _pumpInbox;
122
+ /** Classify + dispatch one inbox entry as a channel notification. */
123
+ private _dispatchInboxEntry;
124
+ /**
125
+ * Resolve full message content. The inbox `preview` is truncated to ≤200
126
+ * bytes (and null for E2E), so for fidelity we read the actual record from
127
+ * the channel/DM doc's active period — same shape the dashboard reads.
128
+ * Falls back to the preview, then a short notice.
129
+ */
130
+ private _resolveInboxContent;
131
+ private _rememberDispatched;
132
+ private _trimSet;
92
133
  /** Attach awareness observer to detect `ai:task` fields from human users. */
93
134
  private _observeRootAwareness;
94
135
  /** Dispatch an ai:task as a channel notification. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "y-protocols": "^1.0.6",
37
37
  "yjs": "^13.6.8",
38
38
  "zod": "^4.3.6",
39
- "@abraca/convert": "2.3.0"
39
+ "@abraca/convert": "2.5.0"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * Dynamic resource: document tree as navigable JSON.
3
3
  */
4
+ import { toPlain } from '@abraca/dabra'
4
5
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
6
  import type { AbracadabraMCPServer } from '../server.ts'
6
7
  import type { TreeEntry } from '../converters/types.ts'
7
8
 
8
9
  function readEntries(treeMap: any): TreeEntry[] {
9
10
  const entries: TreeEntry[] = []
10
- treeMap.forEach((value: any, id: string) => {
11
+ treeMap.forEach((raw: any, id: string) => {
12
+ // Entries are nested Y.Map values — raw field access returns undefined;
13
+ // resolve via toPlain first (mirrors tree.ts:readEntries).
14
+ const value = toPlain(raw) as any
15
+ if (typeof value !== 'object' || value === null) return // skip non-entry keys (e.g. ":updatedAt")
11
16
  entries.push({
12
17
  id,
13
18
  label: value.label || 'Untitled',
package/src/server.ts CHANGED
@@ -6,7 +6,15 @@
6
6
  * one; use switchSpace(docId) to change.
7
7
  */
8
8
  import * as Y from 'yjs'
9
- import { AbracadabraProvider, AbracadabraClient, Kind, SERVER_ROOT_ID } from '@abraca/dabra'
9
+ import {
10
+ AbracadabraProvider,
11
+ AbracadabraClient,
12
+ Kind,
13
+ SERVER_ROOT_ID,
14
+ foldRecords,
15
+ recordFromYAny,
16
+ isEncryptedContent,
17
+ } from '@abraca/dabra'
10
18
  import type { ServerInfo, DocumentMeta } from '@abraca/dabra'
11
19
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
12
20
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
@@ -69,6 +77,25 @@ export class AbracadabraMCPServer {
69
77
  private _toolHistory: Array<{ tool: string; target?: string; ts: number; channel: string | null }> = []
70
78
  private static readonly TOOL_HISTORY_MAX = 20
71
79
 
80
+ // ── Inbox-driven dispatch ──────────────────────────────────────────────────
81
+ // The server fans DMs + @mentions out as entries on this agent's per-user
82
+ // `kind="inbox"` doc (server is the only writer). That is the *only*
83
+ // recipient-correct signal for a DM the agent isn't subscribed to — the
84
+ // `messages:new_message` stateless broadcast only reaches subscribers of the
85
+ // channel/DM doc, and the agent never subscribes to DM docs. So we observe
86
+ // the inbox's `entries` Y.Array and dispatch from there.
87
+ private _inboxStarted = false
88
+ private _inboxDocId: string | null = null
89
+ private _inboxDoc: Y.Doc | null = null
90
+ private _inboxProvider: AbracadabraProvider | null = null
91
+ private _inboxDisposers: Array<() => void> = []
92
+ private _inboxInitialized = false
93
+ private _seenInboxIds = new Set<string>()
94
+ /** Shared message-id dedupe so the inbox path and `_handleStatelessChat`
95
+ * (subscribed-doc broadcast) never dispatch the same message twice. */
96
+ private _dispatchedMessageIds = new Set<string>()
97
+ private static readonly DEDUPE_MAX = 1000
98
+
72
99
  constructor(config: MCPServerConfig) {
73
100
  this.config = config
74
101
  this.client = new AbracadabraClient({
@@ -374,6 +401,232 @@ export class AbracadabraMCPServer {
374
401
  this._handleStatelessChat(payload)
375
402
  })
376
403
  console.error('[abracadabra-mcp] Stateless chat listener attached')
404
+ void this._startInboxNotifications()
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Bootstrap inbox observation. Sends `messages:inbox_fetch` over the active
410
+ * provider; the server's `messages:inbox_history` reply carries the
411
+ * `inbox_doc_id`. We then open a dedicated provider on that doc and observe
412
+ * its `entries` Y.Array for live DM/mention dispatch. Mirrors the
413
+ * dashboard's `useNotifications` pattern.
414
+ */
415
+ private async _startInboxNotifications(): Promise<void> {
416
+ if (this._inboxStarted) return
417
+ const provider = this._activeConnection?.provider
418
+ if (!provider) return
419
+ this._inboxStarted = true
420
+
421
+ provider.on('stateless', ({ payload }: { payload: string }) => {
422
+ if (!payload.includes('"messages:inbox_history"')) return
423
+ try {
424
+ const data = JSON.parse(payload)
425
+ if (data?.type !== 'messages:inbox_history') return
426
+ const inboxDocId = typeof data.inbox_doc_id === 'string' ? data.inbox_doc_id : null
427
+ if (inboxDocId) void this._ensureInboxObserver(inboxDocId)
428
+ } catch {
429
+ /* ignore malformed */
430
+ }
431
+ })
432
+
433
+ // Elicit the inbox_history (and thus inbox_doc_id). unread_only:false so a
434
+ // fresh agent learns its inbox doc even with no unread entries.
435
+ provider.sendStateless(JSON.stringify({
436
+ type: 'messages:inbox_fetch',
437
+ limit: 50,
438
+ unread_only: false,
439
+ }))
440
+ console.error('[abracadabra-mcp] Inbox bootstrap sent (messages:inbox_fetch)')
441
+ }
442
+
443
+ /**
444
+ * Open a dedicated provider on the agent's inbox doc and observe its
445
+ * `entries` Y.Array. The server is the only writer; we only read.
446
+ */
447
+ private async _ensureInboxObserver(inboxDocId: string): Promise<void> {
448
+ if (this._inboxDocId) return // already observing
449
+ this._inboxDocId = inboxDocId
450
+
451
+ // Re-authenticate if the JWT expired (mirrors _connectToSpace).
452
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
453
+ await this.client.loginWithKey(this._userId, this._signFn)
454
+ }
455
+
456
+ const doc = new Y.Doc({ guid: inboxDocId })
457
+ const provider = new AbracadabraProvider({
458
+ name: inboxDocId,
459
+ document: doc,
460
+ client: this.client,
461
+ disableOfflineStore: true,
462
+ subdocLoading: 'lazy',
463
+ })
464
+ this._inboxDoc = doc
465
+ this._inboxProvider = provider
466
+
467
+ try {
468
+ await waitForSync(provider)
469
+ } catch (err: any) {
470
+ console.error(`[abracadabra-mcp] Inbox sync failed: ${err?.message ?? err}`)
471
+ return
472
+ }
473
+
474
+ const entriesArr = doc.getArray('entries')
475
+ const readMap = doc.getMap('read')
476
+ const onChange = () => { void this._pumpInbox(entriesArr, readMap) }
477
+ entriesArr.observe(onChange)
478
+ readMap.observe(onChange)
479
+ this._inboxDisposers.push(() => entriesArr.unobserve(onChange))
480
+ this._inboxDisposers.push(() => readMap.unobserve(onChange))
481
+
482
+ await this._pumpInbox(entriesArr, readMap)
483
+ console.error(`[abracadabra-mcp] Inbox observer attached (${inboxDocId})`)
484
+ }
485
+
486
+ /**
487
+ * Diff the inbox `entries` array against what we've already seen. On the
488
+ * first pump we only record a baseline (don't replay history). New entries
489
+ * are classified by their authoritative `kind` and dispatched.
490
+ */
491
+ private async _pumpInbox(entriesArr: Y.Array<any>, readMap: Y.Map<any>): Promise<void> {
492
+ const raw = entriesArr.toArray() as any[]
493
+ const entries = raw.map((e) => (typeof e?.toJSON === 'function' ? e.toJSON() : e))
494
+
495
+ if (!this._inboxInitialized) {
496
+ for (const e of entries) if (e?.id) this._seenInboxIds.add(e.id)
497
+ this._inboxInitialized = true
498
+ console.error(`[abracadabra-mcp] Inbox baseline: ${this._seenInboxIds.size} existing entries (not replayed)`)
499
+ return
500
+ }
501
+
502
+ for (const e of entries) {
503
+ const id: string | undefined = e?.id
504
+ if (!id || this._seenInboxIds.has(id)) continue
505
+ this._seenInboxIds.add(id)
506
+ if (readMap.get(id)) continue // already read elsewhere
507
+ try {
508
+ await this._dispatchInboxEntry(e)
509
+ } catch (err: any) {
510
+ console.error(`[abracadabra-mcp] Inbox dispatch failed for ${id}: ${err?.message ?? err}`)
511
+ }
512
+ }
513
+ this._trimSet(this._seenInboxIds)
514
+ }
515
+
516
+ /** Classify + dispatch one inbox entry as a channel notification. */
517
+ private async _dispatchInboxEntry(entry: any): Promise<void> {
518
+ if (!this._serverRef) return
519
+ const kind = typeof entry?.kind === 'string' ? entry.kind : ''
520
+ const channelDocId = typeof entry?.channel_doc_id === 'string' ? entry.channel_doc_id : ''
521
+ const messageId = typeof entry?.message_id === 'string' ? entry.message_id : null
522
+ const senderId = typeof entry?.sender_id === 'string' ? entry.sender_id : ''
523
+ if (!channelDocId) return
524
+ if (senderId && senderId === this._userId) return // own message (defensive)
525
+
526
+ // Authoritative trigger gate — `kind` comes from the server, no guessing.
527
+ // dm → always dispatch (DMs trigger regardless of mode)
528
+ // mention/reply → dispatch unless mode === 'task' (chat-ignoring)
529
+ // system/other → skip
530
+ const mode = this.triggerMode
531
+ if (kind === 'mention' || kind === 'reply') {
532
+ if (mode === 'task') return
533
+ } else if (kind !== 'dm') {
534
+ return
535
+ }
536
+
537
+ // Cross-path dedupe: a mention on a doc the agent is also subscribed to
538
+ // arrives via both the inbox and `_handleStatelessChat`. First one wins.
539
+ if (messageId && this._rememberDispatched(messageId)) return
540
+
541
+ const content = await this._resolveInboxContent(channelDocId, messageId, entry?.preview)
542
+
543
+ this._lastChatChannel = channelDocId
544
+ this._beginTurn()
545
+ this.setAutoStatus('thinking')
546
+
547
+ await this._serverRef.notification({
548
+ method: 'notifications/claude/channel',
549
+ params: {
550
+ content,
551
+ instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.`,
552
+ meta: {
553
+ source: 'abracadabra',
554
+ type: kind === 'dm' ? 'dm_message' : 'chat_message',
555
+ channel_doc_id: channelDocId,
556
+ sender: entry?.sender_name ?? 'Unknown',
557
+ sender_id: senderId,
558
+ doc_id: channelDocId,
559
+ },
560
+ },
561
+ })
562
+ console.error(`[abracadabra-mcp] Dispatched ${kind} on ${channelDocId} from ${entry?.sender_name ?? (senderId || 'unknown')}`)
563
+
564
+ // Mark the inbox entry read so it doesn't re-pump on reconnect.
565
+ this._activeConnection?.provider?.sendStateless(JSON.stringify({
566
+ type: 'messages:inbox_mark_read',
567
+ id: entry.id,
568
+ }))
569
+ }
570
+
571
+ /**
572
+ * Resolve full message content. The inbox `preview` is truncated to ≤200
573
+ * bytes (and null for E2E), so for fidelity we read the actual record from
574
+ * the channel/DM doc's active period — same shape the dashboard reads.
575
+ * Falls back to the preview, then a short notice.
576
+ */
577
+ private async _resolveInboxContent(
578
+ channelDocId: string,
579
+ messageId: string | null,
580
+ preview: unknown,
581
+ ): Promise<string> {
582
+ const previewStr = typeof preview === 'string' && preview.length > 0 ? preview : null
583
+ const root = this._activeConnection?.provider
584
+ if (root && messageId) {
585
+ try {
586
+ const wrapper = await root.loadChild(channelDocId)
587
+ if (!wrapper.synced) await waitForSync(wrapper)
588
+ const periods = wrapper.document.getArray('periods')
589
+ const len = periods.length
590
+ // Scan the last two periods (resilient to a rollover between send + read).
591
+ for (let i = len - 1; i >= 0 && i >= len - 2; i--) {
592
+ const p: any = periods.get(i)
593
+ const periodId: string | undefined = p?.id ?? p?.get?.('id')
594
+ if (!periodId) continue
595
+ const period = await root.loadChild(periodId)
596
+ if (!period.synced) await waitForSync(period)
597
+ const records = (period.document.getArray('messages').toArray() as unknown[])
598
+ .map((v) => recordFromYAny(v))
599
+ .filter((r): r is NonNullable<ReturnType<typeof recordFromYAny>> => r !== null)
600
+ const folded = foldRecords(records)
601
+ const hit = folded.find((f) => f.id === messageId)
602
+ if (hit) {
603
+ if (isEncryptedContent(hit.content)) {
604
+ return previewStr ?? '[encrypted message — this agent is not provisioned to read this channel]'
605
+ }
606
+ return hit.content
607
+ }
608
+ }
609
+ } catch (err: any) {
610
+ console.error(`[abracadabra-mcp] content read failed for ${channelDocId}/${messageId}: ${err?.message ?? err}`)
611
+ }
612
+ }
613
+ return previewStr ?? '[message content unavailable]'
614
+ }
615
+
616
+ private _rememberDispatched(messageId: string): boolean {
617
+ if (this._dispatchedMessageIds.has(messageId)) return true
618
+ this._dispatchedMessageIds.add(messageId)
619
+ this._trimSet(this._dispatchedMessageIds)
620
+ return false
621
+ }
622
+
623
+ private _trimSet(s: Set<string>): void {
624
+ if (s.size <= AbracadabraMCPServer.DEDUPE_MAX) return
625
+ const excess = s.size - AbracadabraMCPServer.DEDUPE_MAX
626
+ let i = 0
627
+ for (const v of s) {
628
+ if (i++ >= excess) break
629
+ s.delete(v)
377
630
  }
378
631
  }
379
632
 
@@ -477,12 +730,17 @@ export class AbracadabraMCPServer {
477
730
  const channelDocId = data.channel_doc_id as string | undefined
478
731
  if (!channelDocId) return
479
732
 
480
- // Look up the channel kind (channel vs dm) via the doc tree, since the
481
- // new model has no synthetic channel string. Caller-side filtering of
482
- // DMs (only dispatch when the agent is a participant) relies on the
483
- // permissions on the dm doc — server enforces; nothing to check here.
484
- const isDM = false // Without a kind lookup, treat all as group for trigger semantics.
485
- const isGroup = !isDM
733
+ // Cross-path dedupe: DMs + @mentions also arrive via the inbox observer
734
+ // (the authoritative, recipient-correct path that knows `kind`). If the
735
+ // inbox already dispatched this message id, don't fire it again here.
736
+ if (data.id && this._rememberDispatched(data.id)) return
737
+
738
+ // This path only ever sees docs the agent is *subscribed* to (the active
739
+ // space root + lazily-loaded children) — i.e. group/channel/space chat,
740
+ // never a DM doc (the agent never subscribes to those). DM correctness
741
+ // lives entirely in the inbox observer, so group trigger semantics are
742
+ // correct here.
743
+ const isGroup = true
486
744
 
487
745
  // ── Trigger mode gate ─────────────────────────────────────────────
488
746
  const mode = this.triggerMode
@@ -680,6 +938,14 @@ export class AbracadabraMCPServer {
680
938
  this.evictionTimer = null
681
939
  }
682
940
 
941
+ for (const dispose of this._inboxDisposers) {
942
+ try { dispose() } catch { /* ignore */ }
943
+ }
944
+ this._inboxDisposers = []
945
+ this._inboxProvider?.destroy()
946
+ this._inboxProvider = null
947
+ this._inboxDoc = null
948
+
683
949
  for (const [, cached] of this.childCache) {
684
950
  cached.provider.destroy()
685
951
  }
@@ -94,7 +94,7 @@ export function registerAwarenessTools(mcp: McpServer, server: AbracadabraMCPSer
94
94
  const resolvedInboxId: string = inboxId
95
95
  const provider = await server.getChildProvider(resolvedInboxId)
96
96
  const fragment = provider.document.getXmlFragment('default')
97
- const { markdown } = yjsToMarkdown(fragment)
97
+ const markdown = yjsToMarkdown(fragment, 'AI Inbox')
98
98
 
99
99
  // Collect direct children of inbox (pending task sub-docs)
100
100
  const pendingTasks: Array<{ id: string; label: string }> = []
@@ -4,6 +4,7 @@
4
4
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { z } from 'zod'
6
6
  import type { AbracadabraMCPServer } from '../server.ts'
7
+ import { makeEntryMap } from '@abraca/dabra'
7
8
  import { populateYDocFromMarkdown } from '../converters/markdownToYjs.ts'
8
9
 
9
10
  export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServer) {
@@ -33,18 +34,19 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
33
34
  const now = Date.now()
34
35
 
35
36
  rootDoc.transact(() => {
36
- treeMap.set(replyId, {
37
+ treeMap.set(replyId, makeEntryMap({
37
38
  label,
38
39
  parentId: doc_id,
39
40
  order: now,
40
41
  type: 'doc',
41
42
  createdAt: now,
42
43
  updatedAt: now,
43
- })
44
+ }))
44
45
  })
45
46
 
46
47
  const replyProvider = await server.getChildProvider(replyId)
47
- populateYDocFromMarkdown(replyProvider.document, text)
48
+ const fragment = replyProvider.document.getXmlFragment('default')
49
+ populateYDocFromMarkdown(fragment, text)
48
50
 
49
51
  if (task_id) {
50
52
  server.clearAiTask(task_id)
@@ -7,6 +7,7 @@ import { z } from 'zod'
7
7
  import type { AbracadabraMCPServer } from '../server.ts'
8
8
  import { populateYDocFromMarkdown, parseFrontmatter } from '../converters/markdownToYjs.ts'
9
9
  import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
10
+ import { toPlain, patchEntry } from '@abraca/dabra'
10
11
 
11
12
  export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServer) {
12
13
  mcp.tool(
@@ -23,34 +24,43 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
23
24
  const provider = await server.getChildProvider(docId)
24
25
  const fragment = provider.document.getXmlFragment('default')
25
26
 
26
- const { title, markdown } = yjsToMarkdown(fragment)
27
-
28
27
  // Auto-update presence: mark this doc as focused and place cursor at start
29
28
  server.setFocusedDoc(docId)
30
29
  server.setDocCursor(docId, 0)
31
30
 
32
31
  // Get tree metadata + immediate children
33
32
  const treeMap = server.getTreeMap()
34
- let label = title
33
+ let label = 'Untitled'
35
34
  let type: string | undefined
36
35
  let meta: Record<string, unknown> | undefined
37
36
  let children: Array<{ id: string; label: string; type?: string; meta?: unknown }> = []
38
37
  if (treeMap) {
39
- const entry = treeMap.get(docId)
40
- if (entry) {
41
- label = entry.label || title
38
+ // Entries are nested Y.Map values — raw field access returns
39
+ // undefined; resolve via toPlain first (mirrors tree.ts:readEntries).
40
+ const entry = toPlain(treeMap.get(docId)) as any
41
+ if (entry && typeof entry === 'object') {
42
+ label = entry.label || label
42
43
  type = entry.type
43
44
  meta = entry.meta
44
45
  }
45
46
  // Collect immediate children sorted by order
46
- treeMap.forEach((value: any, id: string) => {
47
+ const collected: Array<{ id: string; label: string; type?: string; meta?: unknown; order: number }> = []
48
+ treeMap.forEach((raw: any, id: string) => {
49
+ const value = toPlain(raw) as any
50
+ if (typeof value !== 'object' || value === null) return // skip non-entry keys (e.g. ":updatedAt")
47
51
  if (value.parentId === docId) {
48
- children.push({ id, label: value.label || 'Untitled', type: value.type, meta: value.meta })
52
+ collected.push({ id, label: value.label || 'Untitled', type: value.type, meta: value.meta, order: value.order ?? 0 })
49
53
  }
50
54
  })
51
- children.sort((a: any, b: any) => ((treeMap.get(a.id)?.order ?? 0) - (treeMap.get(b.id)?.order ?? 0)))
55
+ collected.sort((a, b) => a.order - b.order)
56
+ children = collected.map(({ order, ...rest }) => rest)
52
57
  }
53
58
 
59
+ // yjsToMarkdown returns a string and takes the resolved label/meta/type
60
+ // so frontmatter round-trips. (Older code destructured an object and
61
+ // passed only the fragment — that silently produced `undefined`.)
62
+ const markdown = yjsToMarkdown(fragment, label, meta as any, type)
63
+
54
64
  const result: Record<string, unknown> = { label, type, meta, markdown, children }
55
65
  return {
56
66
  content: [{
@@ -93,15 +103,16 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
93
103
  const treeMap = server.getTreeMap()
94
104
  const rootDoc = server.rootDocument
95
105
  if (treeMap && rootDoc) {
96
- const entry = treeMap.get(docId)
97
- if (entry) {
106
+ const rawEntry = treeMap.get(docId)
107
+ if (rawEntry) {
108
+ const cur = toPlain(rawEntry) as { meta?: Record<string, unknown> }
98
109
  rootDoc.transact(() => {
99
- const updates: Record<string, unknown> = { ...entry, updatedAt: Date.now() }
100
- if (title) updates.label = title
110
+ const patch: Record<string, unknown> = { updatedAt: Date.now() }
111
+ if (title) patch.label = title
101
112
  if (Object.keys(meta).length > 0) {
102
- updates.meta = { ...(entry.meta ?? {}), ...meta }
113
+ patch.meta = { ...(cur.meta ?? {}), ...meta }
103
114
  }
104
- treeMap.set(docId, updates)
115
+ patchEntry(treeMap, docId, patch)
105
116
  })
106
117
  }
107
118
  }
package/src/tools/meta.ts CHANGED
@@ -5,6 +5,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { z } from 'zod'
6
6
  import type { AbracadabraMCPServer } from '../server.ts'
7
7
  import type { SchemaBundleValidator } from '../schema/validator.ts'
8
+ import { toPlain, patchEntry } from '@abraca/dabra'
8
9
 
9
10
  export function registerMetaTools(
10
11
  mcp: McpServer,
@@ -59,10 +60,14 @@ export function registerMetaTools(
59
60
  return { content: [{ type: 'text', text: 'Not connected' }] }
60
61
  }
61
62
 
62
- const entry = treeMap.get(docId)
63
- if (!entry) {
63
+ const rawEntry = treeMap.get(docId)
64
+ if (!rawEntry) {
64
65
  return { content: [{ type: 'text', text: `Document ${docId} not found` }] }
65
66
  }
67
+ // Read via toPlain: entries are nested Y.Maps (the Rust provider
68
+ // already writes them so), so raw `.meta`/`.type` access would be
69
+ // undefined — skipping validation and dropping existing meta.
70
+ const entry = toPlain(rawEntry) as { meta?: Record<string, unknown>; type?: string }
66
71
 
67
72
  const mergedMeta = { ...(entry.meta ?? {}), ...meta }
68
73
 
@@ -80,8 +85,7 @@ export function registerMetaTools(
80
85
  }
81
86
  }
82
87
 
83
- treeMap.set(docId, {
84
- ...entry,
88
+ patchEntry(treeMap, docId, {
85
89
  meta: mergedMeta,
86
90
  updatedAt: Date.now(),
87
91
  })
package/src/tools/tree.ts CHANGED
@@ -8,6 +8,7 @@ import type { AbracadabraMCPServer } from '../server.ts'
8
8
  import type { TreeEntry, PageMeta } from '../converters/types.ts'
9
9
  import { PAGE_TYPES, TYPE_ALIASES, resolvePageType } from '../converters/page-types.ts'
10
10
  import type { SchemaBundleValidator } from '../schema/validator.ts'
11
+ import { makeEntryMap, patchEntry } from '@abraca/dabra'
11
12
 
12
13
  /**
13
14
  * Normalize a document ID so the hub/root doc ID is treated as the tree root (null).
@@ -284,15 +285,18 @@ export function registerTreeTools(
284
285
  const normalizedParent = normalizeRootId(parentId, server)
285
286
  const now = Date.now()
286
287
  rootDoc.transact(() => {
287
- treeMap.set(id, {
288
- label,
289
- parentId: normalizedParent,
290
- order: now,
291
- type,
292
- meta: meta as PageMeta,
293
- createdAt: now,
294
- updatedAt: now,
295
- })
288
+ treeMap.set(
289
+ id,
290
+ makeEntryMap({
291
+ label,
292
+ parentId: normalizedParent,
293
+ order: now,
294
+ type,
295
+ meta: meta as PageMeta,
296
+ createdAt: now,
297
+ updatedAt: now,
298
+ }),
299
+ )
296
300
  })
297
301
 
298
302
  server.setFocusedDoc(id)
@@ -326,8 +330,7 @@ export function registerTreeTools(
326
330
  return { content: [{ type: 'text', text: `Document ${id} not found` }] }
327
331
  }
328
332
 
329
- const entry = toPlain(raw)
330
- treeMap.set(id, { ...entry, label, updatedAt: Date.now() })
333
+ patchEntry(treeMap, id, { label, updatedAt: Date.now() })
331
334
  return { content: [{ type: 'text', text: `Renamed to "${label}"` }] }
332
335
  }
333
336
  )
@@ -353,9 +356,7 @@ export function registerTreeTools(
353
356
  return { content: [{ type: 'text', text: `Document ${id} not found` }] }
354
357
  }
355
358
 
356
- const entry = toPlain(raw)
357
- treeMap.set(id, {
358
- ...entry,
359
+ patchEntry(treeMap, id, {
359
360
  parentId: normalizeRootId(newParentId, server),
360
361
  order: order ?? Date.now(),
361
362
  updatedAt: Date.now(),
@@ -425,8 +426,7 @@ export function registerTreeTools(
425
426
  return { content: [{ type: 'text', text: `Document ${id} not found` }] }
426
427
  }
427
428
 
428
- const entry = toPlain(raw)
429
- treeMap.set(id, { ...entry, type, updatedAt: Date.now() })
429
+ patchEntry(treeMap, id, { type, updatedAt: Date.now() })
430
430
  return { content: [{ type: 'text', text: `Changed type to "${type}"` }] }
431
431
  }
432
432
  )
@@ -473,11 +473,14 @@ export function registerTreeTools(
473
473
 
474
474
  const entry = toPlain(raw)
475
475
  const newId = crypto.randomUUID()
476
- treeMap.set(newId, {
477
- ...entry,
478
- label: (entry.label || 'Untitled') + ' (copy)',
479
- order: Date.now(),
480
- })
476
+ treeMap.set(
477
+ newId,
478
+ makeEntryMap({
479
+ ...entry,
480
+ label: (entry.label || 'Untitled') + ' (copy)',
481
+ order: Date.now(),
482
+ }),
483
+ )
481
484
 
482
485
  server.setFocusedDoc(newId)
483
486