@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/README.md +32 -0
- package/dist/abracadabra-mcp.cjs +251 -54
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +252 -55
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +41 -0
- package/package.json +2 -2
- package/src/resources/tree-resource.ts +6 -1
- package/src/server.ts +273 -7
- package/src/tools/awareness.ts +1 -1
- package/src/tools/channel.ts +5 -3
- package/src/tools/content.ts +26 -15
- package/src/tools/meta.ts +8 -4
- package/src/tools/tree.ts +24 -21
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
|
+
"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.
|
|
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((
|
|
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 {
|
|
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
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
}
|
package/src/tools/awareness.ts
CHANGED
|
@@ -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
|
|
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 }> = []
|
package/src/tools/channel.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
+
const fragment = replyProvider.document.getXmlFragment('default')
|
|
49
|
+
populateYDocFromMarkdown(fragment, text)
|
|
48
50
|
|
|
49
51
|
if (task_id) {
|
|
50
52
|
server.clearAiTask(task_id)
|
package/src/tools/content.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
collected.push({ id, label: value.label || 'Untitled', type: value.type, meta: value.meta, order: value.order ?? 0 })
|
|
49
53
|
}
|
|
50
54
|
})
|
|
51
|
-
|
|
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
|
|
97
|
-
if (
|
|
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
|
|
100
|
-
if (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
|
-
|
|
113
|
+
patch.meta = { ...(cur.meta ?? {}), ...meta }
|
|
103
114
|
}
|
|
104
|
-
treeMap
|
|
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
|
|
63
|
-
if (!
|
|
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
|
|
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(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|