@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/dist/abracadabra-mcp.cjs +21007 -0
- package/dist/abracadabra-mcp.cjs.map +1 -0
- package/dist/abracadabra-mcp.esm.js +20999 -0
- package/dist/abracadabra-mcp.esm.js.map +1 -0
- package/dist/index.d.ts +8785 -0
- package/package.json +39 -0
- package/src/converters/markdownToYjs.ts +852 -0
- package/src/converters/types.ts +75 -0
- package/src/converters/yjsToMarkdown.ts +334 -0
- package/src/index.ts +103 -0
- package/src/resources/agent-guide.ts +324 -0
- package/src/resources/server-info.ts +47 -0
- package/src/resources/tree-resource.ts +71 -0
- package/src/server.ts +431 -0
- package/src/tools/awareness.ts +156 -0
- package/src/tools/channel.ts +92 -0
- package/src/tools/content.ts +133 -0
- package/src/tools/files.ts +110 -0
- package/src/tools/meta.ts +59 -0
- package/src/tools/tree.ts +296 -0
- package/src/utils.ts +37 -0
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
|
+
}
|