@abraca/mcp 1.0.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/src/server.ts ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * AbracadabraMCPServer — manages connection lifecycle, provider cache, and cleanup.
3
+ *
4
+ * Space-aware: on connect, discovers spaces via the spaces extension.
5
+ * If available, defaults to the hub space; otherwise falls back to index_doc_id.
6
+ * Use switchSpace(docId) to change the active space.
7
+ */
8
+ import * as Y from 'yjs'
9
+ import { AbracadabraProvider, AbracadabraClient } from '@abraca/dabra'
10
+ import type { ServerInfo, SpaceMeta } from '@abraca/dabra'
11
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
12
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
+ import { waitForSync } from './utils.ts'
14
+
15
+ export interface MCPServerConfig {
16
+ url: string
17
+ username: string
18
+ password: string
19
+ agentName?: string
20
+ agentColor?: string
21
+ }
22
+
23
+ interface SpaceConnection {
24
+ doc: Y.Doc
25
+ provider: AbracadabraProvider
26
+ docId: string
27
+ }
28
+
29
+ interface CachedProvider {
30
+ provider: AbracadabraProvider
31
+ lastAccessed: number
32
+ }
33
+
34
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
35
+
36
+ export class AbracadabraMCPServer {
37
+ readonly config: MCPServerConfig
38
+ readonly client: AbracadabraClient
39
+ private _serverInfo: ServerInfo | null = null
40
+ private _rootDocId: string | null = null
41
+ private _spaces: SpaceMeta[] = []
42
+ private _activeConnection: SpaceConnection | null = null
43
+ private _spaceConnections = new Map<string, SpaceConnection>()
44
+ private childCache = new Map<string, CachedProvider>()
45
+ private evictionTimer: ReturnType<typeof setInterval> | null = null
46
+ private _mcpServerRef: McpServer | null = null
47
+ private _serverRef: Server | null = null
48
+ private _handledTaskIds = new Set<string>()
49
+ private _userId: string | null = null
50
+
51
+ constructor(config: MCPServerConfig) {
52
+ this.config = config
53
+ this.client = new AbracadabraClient({
54
+ url: config.url,
55
+ persistAuth: false,
56
+ })
57
+ }
58
+
59
+ get agentName(): string {
60
+ return this.config.agentName || 'AI Assistant'
61
+ }
62
+
63
+ get agentColor(): string {
64
+ return this.config.agentColor || 'hsl(270, 80%, 60%)'
65
+ }
66
+
67
+ get serverInfo(): ServerInfo | null {
68
+ return this._serverInfo
69
+ }
70
+
71
+ get rootDocId(): string | null {
72
+ return this._rootDocId
73
+ }
74
+
75
+ get spaces(): SpaceMeta[] {
76
+ return this._spaces
77
+ }
78
+
79
+ get rootDocument(): Y.Doc | null {
80
+ return this._activeConnection?.doc ?? null
81
+ }
82
+
83
+ get rootYProvider(): AbracadabraProvider | null {
84
+ return this._activeConnection?.provider ?? null
85
+ }
86
+
87
+ get userId(): string | null {
88
+ return this._userId
89
+ }
90
+
91
+ /** Start the server: login, discover spaces/root doc, connect provider. */
92
+ async connect(): Promise<void> {
93
+ // Step 1: Login
94
+ await this.client.login({
95
+ username: this.config.username,
96
+ password: this.config.password,
97
+ })
98
+ console.error(`[abracadabra-mcp] Logged in as ${this.config.username}`)
99
+
100
+ // Step 1b: Get user profile for publicKey identity
101
+ try {
102
+ const me = await this.client.getMe()
103
+ this._userId = me.id
104
+ console.error(`[abracadabra-mcp] User ID: ${this._userId}`)
105
+ } catch {
106
+ console.error('[abracadabra-mcp] Could not fetch user profile, proceeding without userId')
107
+ }
108
+
109
+ // Step 2: Discover server info
110
+ this._serverInfo = await this.client.serverInfo()
111
+
112
+ // Step 3: Discover spaces (optional extension)
113
+ let initialDocId: string | null = this._serverInfo.index_doc_id ?? null
114
+ try {
115
+ this._spaces = await this.client.listSpaces()
116
+ const hub = this._spaces.find(s => s.is_hub)
117
+ if (hub) {
118
+ initialDocId = hub.doc_id
119
+ console.error(`[abracadabra-mcp] Spaces extension active. Hub space: ${hub.name} (${hub.doc_id})`)
120
+ } else if (this._spaces.length > 0) {
121
+ initialDocId = this._spaces[0].doc_id
122
+ console.error(`[abracadabra-mcp] Spaces active but no hub, using first space: ${this._spaces[0].name} (${this._spaces[0].doc_id})`)
123
+ }
124
+ } catch {
125
+ console.error('[abracadabra-mcp] Spaces extension not available, using index_doc_id')
126
+ }
127
+
128
+ if (!initialDocId) {
129
+ throw new Error('No entry point found: server has neither spaces nor index_doc_id configured.')
130
+ }
131
+
132
+ this._rootDocId = initialDocId
133
+ console.error(`[abracadabra-mcp] Active space doc: ${initialDocId}`)
134
+
135
+ // Step 4: Connect to initial space
136
+ await this._connectToSpace(initialDocId)
137
+ console.error('[abracadabra-mcp] Space doc synced')
138
+
139
+ // Step 5: Start eviction timer
140
+ this.evictionTimer = setInterval(() => this.evictIdle(), 60_000)
141
+ }
142
+
143
+ /** Connect to a space's root doc and cache it. Sets it as the active connection. */
144
+ private async _connectToSpace(docId: string): Promise<SpaceConnection> {
145
+ const existing = this._spaceConnections.get(docId)
146
+ if (existing) {
147
+ this._activeConnection = existing
148
+ this._rootDocId = docId
149
+ return existing
150
+ }
151
+
152
+ const doc = new Y.Doc({ guid: docId })
153
+ const provider = new AbracadabraProvider({
154
+ name: docId,
155
+ document: doc,
156
+ client: this.client,
157
+ disableOfflineStore: true,
158
+ subdocLoading: 'lazy',
159
+ })
160
+
161
+ await waitForSync(provider)
162
+
163
+ provider.awareness.setLocalStateField('user', {
164
+ name: this.agentName,
165
+ color: this.agentColor,
166
+ publicKey: this._userId,
167
+ })
168
+
169
+ const conn: SpaceConnection = { doc, provider, docId }
170
+ this._spaceConnections.set(docId, conn)
171
+ this._activeConnection = conn
172
+ this._rootDocId = docId
173
+
174
+ // Re-attach awareness + stateless listeners if messaging is active (handles space switches)
175
+ if (this._mcpServerRef) {
176
+ this._observeRootAwareness(provider)
177
+ provider.on('stateless', ({ payload }: { payload: string }) => {
178
+ this._handleStatelessChat(payload)
179
+ })
180
+ }
181
+
182
+ return conn
183
+ }
184
+
185
+ /**
186
+ * Switch the active space to the given doc ID.
187
+ * Clears the child provider cache (children belong to the previous space).
188
+ */
189
+ async switchSpace(docId: string): Promise<void> {
190
+ for (const [, cached] of this.childCache) {
191
+ cached.provider.destroy()
192
+ }
193
+ this.childCache.clear()
194
+ await this._connectToSpace(docId)
195
+ console.error(`[abracadabra-mcp] Switched active space to ${docId}`)
196
+ }
197
+
198
+ /** Get the root doc-tree Y.Map of the active space. */
199
+ getTreeMap(): Y.Map<any> | null {
200
+ return this._activeConnection?.doc.getMap('doc-tree') ?? null
201
+ }
202
+
203
+ /** Get the root doc-trash Y.Map of the active space. */
204
+ getTrashMap(): Y.Map<any> | null {
205
+ return this._activeConnection?.doc.getMap('doc-trash') ?? null
206
+ }
207
+
208
+ /**
209
+ * Get or create a child provider for a given document ID.
210
+ * Caches providers and waits for sync before returning.
211
+ */
212
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
213
+ const cached = this.childCache.get(docId)
214
+ if (cached) {
215
+ cached.lastAccessed = Date.now()
216
+ return cached.provider
217
+ }
218
+
219
+ const activeProvider = this._activeConnection?.provider
220
+ if (!activeProvider) {
221
+ throw new Error('Not connected. Call connect() first.')
222
+ }
223
+
224
+ const childProvider = await activeProvider.loadChild(docId)
225
+ await waitForSync(childProvider)
226
+
227
+ childProvider.awareness.setLocalStateField('user', {
228
+ name: this.agentName,
229
+ color: this.agentColor,
230
+ publicKey: this._userId,
231
+ })
232
+
233
+ this.childCache.set(docId, {
234
+ provider: childProvider,
235
+ lastAccessed: Date.now(),
236
+ })
237
+
238
+ return childProvider
239
+ }
240
+
241
+ /** Update root awareness to reflect the currently focused document. */
242
+ setFocusedDoc(docId: string): void {
243
+ this.rootYProvider?.awareness.setLocalStateField('docId', docId)
244
+ }
245
+
246
+ /**
247
+ * Set a TipTap-compatible collapsed cursor (anchor === head) on a child doc's awareness.
248
+ * Only call after getChildProvider has cached the provider.
249
+ * @param index Character index in xmlFragment ('default'). Clamped to [0, length].
250
+ */
251
+ setDocCursor(docId: string, index: number): void {
252
+ const cached = this.childCache.get(docId)
253
+ if (!cached) return
254
+
255
+ const fragment = cached.provider.document.getXmlFragment('default')
256
+ const clampedIndex = Math.max(0, Math.min(index, fragment.length))
257
+ const relPos = Y.createRelativePositionFromTypeIndex(fragment, clampedIndex)
258
+ const relPosJson = Y.relativePositionToJSON(relPos)
259
+
260
+ cached.provider.awareness.setLocalStateField('anchor', relPosJson)
261
+ cached.provider.awareness.setLocalStateField('head', relPosJson)
262
+ }
263
+
264
+ /** Evict child providers that have been idle for too long. */
265
+ private evictIdle(): void {
266
+ const now = Date.now()
267
+ for (const [docId, cached] of this.childCache) {
268
+ if (now - cached.lastAccessed > IDLE_TIMEOUT_MS) {
269
+ // Clear cursor before destroying so TipTap removes the caret overlay
270
+ cached.provider.awareness.setLocalStateField('anchor', null)
271
+ cached.provider.awareness.setLocalStateField('head', null)
272
+ cached.provider.destroy()
273
+ this.childCache.delete(docId)
274
+ console.error(`[abracadabra-mcp] Evicted idle provider: ${docId}`)
275
+ }
276
+ }
277
+ }
278
+
279
+ /** Wire up real-time channel notifications via awareness observation and stateless chat. */
280
+ startChannelNotifications(mcpServer: McpServer): void {
281
+ this._mcpServerRef = mcpServer
282
+ this._serverRef = mcpServer.server
283
+ const provider = this._activeConnection?.provider
284
+ if (provider) {
285
+ this._observeRootAwareness(provider)
286
+ provider.on('stateless', ({ payload }: { payload: string }) => {
287
+ this._handleStatelessChat(payload)
288
+ })
289
+ console.error('[abracadabra-mcp] Stateless chat listener attached')
290
+ }
291
+ }
292
+
293
+ /** Attach awareness observer to detect `ai:task` fields from human users. */
294
+ private _observeRootAwareness(provider: AbracadabraProvider): void {
295
+ const selfId = provider.awareness.clientID
296
+ provider.awareness.on('change', () => {
297
+ const states = provider.awareness.getStates()
298
+ for (const [clientId, state] of states) {
299
+ if (clientId === selfId) continue
300
+ const task = (state as Record<string, any>)['ai:task']
301
+ if (!task || typeof task !== 'object') continue
302
+ const { id, text } = task as { id?: string; text?: string }
303
+ if (!id || !text) continue
304
+ if (this._handledTaskIds.has(id)) continue
305
+ this._handledTaskIds.add(id)
306
+
307
+ // Extract sender name from awareness state
308
+ const user = (state as Record<string, any>)['user']
309
+ const senderName = (user && typeof user === 'object' && typeof user.name === 'string')
310
+ ? user.name
311
+ : 'Unknown'
312
+
313
+ console.error(`[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`)
314
+ this._dispatchAiTask({
315
+ id,
316
+ text,
317
+ docId: (state as Record<string, any>)['docId'],
318
+ senderName,
319
+ })
320
+ }
321
+ })
322
+ console.error('[abracadabra-mcp] Root awareness observation started')
323
+ }
324
+
325
+ /** Dispatch an ai:task as a channel notification. */
326
+ private async _dispatchAiTask(task: { id: string; text: string; docId?: string; senderName: string }): Promise<void> {
327
+ if (!this._serverRef) return
328
+ try {
329
+ await this._serverRef.notification({
330
+ method: 'notifications/claude/channel',
331
+ params: {
332
+ content: task.text,
333
+ meta: {
334
+ source: 'abracadabra',
335
+ type: 'ai_task',
336
+ task_id: task.id,
337
+ sender: task.senderName,
338
+ doc_id: task.docId ?? '',
339
+ },
340
+ },
341
+ })
342
+ console.error(`[abracadabra-mcp] Channel notification sent for ai:task id=${task.id}`)
343
+ } catch (error: any) {
344
+ console.error(`[abracadabra-mcp] Channel notification failed: ${error.message}`)
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Clear an ai:task from awareness to signal completion.
350
+ * Scans all awareness states for the given task ID and removes it.
351
+ */
352
+ clearAiTask(taskId: string): void {
353
+ const provider = this._activeConnection?.provider
354
+ if (!provider) return
355
+ const selfId = provider.awareness.clientID
356
+ const states = provider.awareness.getStates()
357
+ for (const [clientId, state] of states) {
358
+ if (clientId === selfId) continue
359
+ const task = (state as Record<string, any>)['ai:task']
360
+ if (task && typeof task === 'object' && (task as any).id === taskId) {
361
+ // We can't clear another client's awareness directly, but we can
362
+ // signal completion by setting our own awareness field
363
+ provider.awareness.setLocalStateField('ai:task:done', taskId)
364
+ break
365
+ }
366
+ }
367
+ }
368
+
369
+ /** Handle incoming stateless chat messages and dispatch as channel notifications. */
370
+ private async _handleStatelessChat(payload: string): Promise<void> {
371
+ if (!this._serverRef) return
372
+ if (!payload.includes('"chat:message"')) return
373
+
374
+ try {
375
+ const data = JSON.parse(payload)
376
+ if (data.type !== 'chat:message') return
377
+ // Skip own messages
378
+ if (data.sender_id && data.sender_id === this._userId) return
379
+
380
+ const channel = data.channel as string | undefined
381
+ const docId = channel?.startsWith('group:') ? channel.slice(6) : ''
382
+
383
+ // Only process DMs where the agent is a participant
384
+ if (channel?.startsWith('dm:')) {
385
+ const parts = channel.split(':')
386
+ if (parts.length === 3 && parts[1] !== this._userId && parts[2] !== this._userId) {
387
+ return
388
+ }
389
+ }
390
+
391
+ await this._serverRef.notification({
392
+ method: 'notifications/claude/channel',
393
+ params: {
394
+ content: data.content ?? '',
395
+ meta: {
396
+ source: 'abracadabra',
397
+ type: 'chat_message',
398
+ channel: channel ?? '',
399
+ sender: data.sender_name ?? 'Unknown',
400
+ sender_id: data.sender_id ?? '',
401
+ doc_id: docId,
402
+ },
403
+ },
404
+ })
405
+ console.error(`[abracadabra-mcp] Chat message from ${data.sender_name ?? 'unknown'} on ${channel}`)
406
+ } catch {
407
+ // Ignore non-chat or malformed payloads
408
+ }
409
+ }
410
+
411
+ /** Graceful shutdown. */
412
+ async destroy(): Promise<void> {
413
+ if (this.evictionTimer) {
414
+ clearInterval(this.evictionTimer)
415
+ this.evictionTimer = null
416
+ }
417
+
418
+ for (const [, cached] of this.childCache) {
419
+ cached.provider.destroy()
420
+ }
421
+ this.childCache.clear()
422
+
423
+ for (const [, conn] of this._spaceConnections) {
424
+ conn.provider.destroy()
425
+ }
426
+ this._spaceConnections.clear()
427
+ this._activeConnection = null
428
+
429
+ console.error('[abracadabra-mcp] Shutdown complete')
430
+ }
431
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Awareness tools — read/write presence state.
3
+ */
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
+ import { z } from 'zod'
6
+ import { awarenessStatesToArray } from '@abraca/dabra'
7
+ import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
8
+ import type { AbracadabraMCPServer } from '../server.ts'
9
+
10
+ export function registerAwarenessTools(mcp: McpServer, server: AbracadabraMCPServer) {
11
+ mcp.tool(
12
+ 'set_presence',
13
+ 'Set the AI agent\'s presence state. Use this to indicate which document you are viewing or your current status.',
14
+ {
15
+ docId: z.string().optional().describe('Document ID the agent is currently viewing. Sets docId in root awareness.'),
16
+ status: z.string().optional().describe('Status text (e.g. "writing", "reviewing", "thinking").'),
17
+ },
18
+ async ({ docId, status }) => {
19
+ const rootProvider = server.rootYProvider
20
+ if (!rootProvider) return { content: [{ type: 'text', text: 'Not connected' }] }
21
+
22
+ if (docId !== undefined) {
23
+ rootProvider.awareness.setLocalStateField('docId', docId)
24
+ }
25
+ if (status !== undefined) {
26
+ rootProvider.awareness.setLocalStateField('status', status)
27
+ }
28
+
29
+ return { content: [{ type: 'text', text: 'Presence updated' }] }
30
+ }
31
+ )
32
+
33
+ mcp.tool(
34
+ 'set_doc_awareness',
35
+ 'Set renderer-specific awareness fields on a child document. Use this to broadcast item-level presence (e.g. which kanban card, table cell, or slide you are interacting with). Setting a field to null clears it.',
36
+ {
37
+ docId: z.string().describe('Document ID to set awareness on.'),
38
+ fields: z.record(z.string(), z.unknown()).describe('Key-value pairs to set on the child document\'s awareness state. Use namespaced keys like "kanban:hovering", "table:editing", "slides:viewing", "outline:editing", "calendar:focused", "gallery:focused", "timeline:focused", "graph:focused", "map:focused", "doc:scroll". Set a key to null to clear it.'),
39
+ },
40
+ async ({ docId, fields }) => {
41
+ try {
42
+ const provider = await server.getChildProvider(docId)
43
+ for (const [key, value] of Object.entries(fields)) {
44
+ provider.awareness.setLocalStateField(key, value ?? null)
45
+ }
46
+ return { content: [{ type: 'text', text: 'Doc awareness updated' }] }
47
+ } catch (error: any) {
48
+ return {
49
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
50
+ isError: true,
51
+ }
52
+ }
53
+ }
54
+ )
55
+
56
+ mcp.tool(
57
+ 'poll_inbox',
58
+ 'Check the "AI Inbox" document for pending instructions from humans. Returns the inbox content and any pending task sub-documents. Create the inbox as a doc called "AI Inbox" under the hub doc if it does not exist yet. Note: channel-based watching via watch_chat is preferred for real-time use.',
59
+ {},
60
+ async () => {
61
+ try {
62
+ const treeMap = server.getTreeMap()
63
+ const rootDocId = server.rootDocId
64
+ if (!treeMap || !rootDocId) {
65
+ return { content: [{ type: 'text', text: 'Not connected' }] }
66
+ }
67
+
68
+ // Find "AI Inbox" doc that is a direct child of the hub doc
69
+ let inboxId: string | null = null
70
+ treeMap.forEach((value: any, id: string) => {
71
+ if (
72
+ value.parentId === rootDocId &&
73
+ typeof value.label === 'string' &&
74
+ value.label.toLowerCase() === 'ai inbox'
75
+ ) {
76
+ inboxId = id
77
+ }
78
+ })
79
+
80
+ if (!inboxId) {
81
+ return {
82
+ content: [{
83
+ type: 'text',
84
+ text: JSON.stringify({
85
+ pending: [],
86
+ message: 'No AI Inbox document found. Create one under the hub doc with label "AI Inbox".',
87
+ }),
88
+ }],
89
+ }
90
+ }
91
+
92
+ const resolvedInboxId: string = inboxId
93
+ const provider = await server.getChildProvider(resolvedInboxId)
94
+ const fragment = provider.document.getXmlFragment('default')
95
+ const { markdown } = yjsToMarkdown(fragment)
96
+
97
+ // Collect direct children of inbox (pending task sub-docs)
98
+ const pendingTasks: Array<{ id: string; label: string }> = []
99
+ treeMap.forEach((value: any, id: string) => {
100
+ if (value.parentId === resolvedInboxId) {
101
+ pendingTasks.push({ id, label: value.label || 'Untitled' })
102
+ }
103
+ })
104
+
105
+ return {
106
+ content: [{
107
+ type: 'text',
108
+ text: JSON.stringify({ inboxId: resolvedInboxId, content: markdown, pendingTasks }, null, 2),
109
+ }],
110
+ }
111
+ } catch (error: any) {
112
+ return {
113
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
114
+ isError: true,
115
+ }
116
+ }
117
+ }
118
+ )
119
+
120
+ mcp.tool(
121
+ 'list_connected_users',
122
+ 'List all connected users and their awareness state. Shows who is online and what they are doing.',
123
+ {
124
+ docId: z.string().optional().describe('If provided, list users connected to this specific document. Otherwise lists users from root awareness.'),
125
+ },
126
+ async ({ docId }) => {
127
+ try {
128
+ let awareness
129
+ if (docId) {
130
+ const provider = await server.getChildProvider(docId)
131
+ awareness = provider.awareness
132
+ } else {
133
+ const rootProvider = server.rootYProvider
134
+ if (!rootProvider) return { content: [{ type: 'text', text: 'Not connected' }] }
135
+ awareness = rootProvider.awareness
136
+ }
137
+
138
+ const states = awarenessStatesToArray(awareness.getStates())
139
+ // Filter out empty states
140
+ const users = states.filter(s => s.user)
141
+
142
+ return {
143
+ content: [{
144
+ type: 'text',
145
+ text: JSON.stringify(users, null, 2),
146
+ }],
147
+ }
148
+ } catch (error: any) {
149
+ return {
150
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
151
+ isError: true,
152
+ }
153
+ }
154
+ }
155
+ )
156
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Channel tools — reply to channel events and send chat messages.
3
+ */
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
+ import { z } from 'zod'
6
+ import type { AbracadabraMCPServer } from '../server.ts'
7
+ import { populateYDocFromMarkdown } from '../converters/markdownToYjs.ts'
8
+
9
+ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServer) {
10
+ mcp.tool(
11
+ 'reply',
12
+ 'Create a document reply. Creates a child document under doc_id with the reply content as a rich-text document. Use this for structured, persistent responses. For conversational chat, use send_chat_message instead.',
13
+ {
14
+ doc_id: z.string().describe('Document ID to create the reply under.'),
15
+ text: z.string().describe('Markdown content for the reply.'),
16
+ task_id: z.string().optional().describe('If replying to an ai:task, the task ID to clear from awareness.'),
17
+ },
18
+ async ({ doc_id, text, task_id }) => {
19
+ try {
20
+ const treeMap = server.getTreeMap()
21
+ const rootDoc = server.rootDocument
22
+ if (!treeMap || !rootDoc) {
23
+ return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
24
+ }
25
+
26
+ const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
27
+ const snippet = text.slice(0, 40).replace(/\n/g, ' ')
28
+ const label = `AI Reply — ${timestamp}: ${snippet}`
29
+ const replyId = crypto.randomUUID()
30
+ const now = Date.now()
31
+
32
+ rootDoc.transact(() => {
33
+ treeMap.set(replyId, {
34
+ label,
35
+ parentId: doc_id,
36
+ order: now,
37
+ type: 'doc',
38
+ createdAt: now,
39
+ updatedAt: now,
40
+ })
41
+ })
42
+
43
+ const replyProvider = await server.getChildProvider(replyId)
44
+ populateYDocFromMarkdown(replyProvider.document, text)
45
+
46
+ if (task_id) {
47
+ server.clearAiTask(task_id)
48
+ }
49
+
50
+ return {
51
+ content: [{ type: 'text' as const, text: JSON.stringify({ replyDocId: replyId, parentId: doc_id }) }],
52
+ }
53
+ } catch (error: any) {
54
+ return {
55
+ content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
56
+ isError: true,
57
+ }
58
+ }
59
+ }
60
+ )
61
+
62
+ mcp.tool(
63
+ 'send_chat_message',
64
+ 'Send a chat message visible in the dashboard chat UI. For document group chat use channel "group:<docId>". For DM use "dm:<key1>:<key2>" (keys sorted alphabetically). Use list_connected_users to find user publicKeys for DM channels.',
65
+ {
66
+ channel: z.string().describe('Channel ID, e.g. "group:<docId>" or "dm:<key1>:<key2>"'),
67
+ text: z.string().describe('Message text'),
68
+ },
69
+ async ({ channel, text }) => {
70
+ try {
71
+ const rootProvider = server.rootYProvider
72
+ if (!rootProvider) {
73
+ return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
74
+ }
75
+
76
+ rootProvider.sendStateless(JSON.stringify({
77
+ type: 'chat:send',
78
+ channel,
79
+ content: text,
80
+ sender_name: server.agentName,
81
+ }))
82
+
83
+ return { content: [{ type: 'text' as const, text: `Sent to ${channel}` }] }
84
+ } catch (error: any) {
85
+ return {
86
+ content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
87
+ isError: true,
88
+ }
89
+ }
90
+ }
91
+ )
92
+ }