@abraca/mcp 2.8.0 → 2.10.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 +299 -18
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +299 -18
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +36 -0
- package/package.json +2 -2
- package/src/index.ts +10 -0
- package/src/server.ts +79 -0
- package/src/tool-detail.ts +147 -0
- package/src/tools/content.ts +16 -2
- package/src/tools/meta.ts +2 -0
- package/src/tools/tree.ts +135 -10
- package/src/utils.ts +16 -1
package/src/tools/tree.ts
CHANGED
|
@@ -25,9 +25,22 @@ function toPlain(val: any): any {
|
|
|
25
25
|
return val instanceof Y.Map ? val.toJSON() : val
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Read every doc-tree entry, optionally skipping the space's self-descriptor.
|
|
30
|
+
*
|
|
31
|
+
* A space's own `doc-tree` Y.Map holds an entry keyed by the space's OWN id
|
|
32
|
+
* (the connected root doc), with `parentId: null` and `order: -1`. It stores
|
|
33
|
+
* the space root's own icon/color/type/label — it is NOT a child document.
|
|
34
|
+
* Because its `parentId` is null it otherwise shows up alongside real
|
|
35
|
+
* top-level docs, making the space render as a phantom first child of itself
|
|
36
|
+
* ("Health space's first document is 'Development'"). Pass `selfId =
|
|
37
|
+
* server.rootDocId` to drop it. cou-sh's DocumentTree applies the same guard
|
|
38
|
+
* (`if (id === spaceDocId) continue`).
|
|
39
|
+
*/
|
|
40
|
+
export function readEntries(treeMap: Y.Map<any>, selfId?: string | null): TreeEntry[] {
|
|
29
41
|
const entries: TreeEntry[] = []
|
|
30
42
|
treeMap.forEach((raw: any, id: string) => {
|
|
43
|
+
if (selfId && id === selfId) return // skip the space root's self-descriptor entry
|
|
31
44
|
const value = toPlain(raw)
|
|
32
45
|
if (typeof value !== 'object' || value === null) return // skip non-entry keys (e.g. ":updatedAt" timestamps)
|
|
33
46
|
entries.push({
|
|
@@ -65,7 +78,7 @@ function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
|
|
|
65
78
|
return result
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0, visited = new Set<string>()): any[] {
|
|
81
|
+
export function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0, visited = new Set<string>()): any[] {
|
|
69
82
|
if (maxDepth >= 0 && currentDepth >= maxDepth) return []
|
|
70
83
|
const children = childrenOf(entries, rootId)
|
|
71
84
|
return children.filter(e => !visited.has(e.id)).map(entry => {
|
|
@@ -94,13 +107,14 @@ export function registerTreeTools(
|
|
|
94
107
|
async ({ parentId }) => {
|
|
95
108
|
server.setAutoStatus('reading')
|
|
96
109
|
server.setActiveToolCall({ name: 'list_documents' })
|
|
110
|
+
await server.ensureSpaceActive(parentId)
|
|
97
111
|
const treeMap = server.getTreeMap()
|
|
98
112
|
if (!treeMap) {
|
|
99
113
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
const targetId = normalizeRootId(parentId, server)
|
|
103
|
-
const entries = readEntries(treeMap)
|
|
117
|
+
const entries = readEntries(treeMap, server.rootDocId)
|
|
104
118
|
const children = childrenOf(entries, targetId)
|
|
105
119
|
|
|
106
120
|
return {
|
|
@@ -122,6 +136,7 @@ export function registerTreeTools(
|
|
|
122
136
|
async ({ rootId, depth }) => {
|
|
123
137
|
server.setAutoStatus('reading')
|
|
124
138
|
server.setActiveToolCall({ name: 'get_document_tree' })
|
|
139
|
+
await server.ensureSpaceActive(rootId)
|
|
125
140
|
const treeMap = server.getTreeMap()
|
|
126
141
|
if (!treeMap) {
|
|
127
142
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
@@ -129,7 +144,7 @@ export function registerTreeTools(
|
|
|
129
144
|
|
|
130
145
|
const targetId = normalizeRootId(rootId, server)
|
|
131
146
|
const maxDepth = depth ?? 3
|
|
132
|
-
const entries = readEntries(treeMap)
|
|
147
|
+
const entries = readEntries(treeMap, server.rootDocId)
|
|
133
148
|
const tree = buildTree(entries, targetId, maxDepth)
|
|
134
149
|
|
|
135
150
|
return {
|
|
@@ -151,12 +166,13 @@ export function registerTreeTools(
|
|
|
151
166
|
async ({ query, rootId }) => {
|
|
152
167
|
server.setAutoStatus('searching')
|
|
153
168
|
server.setActiveToolCall({ name: 'find_document', target: query })
|
|
169
|
+
await server.ensureSpaceActive(rootId)
|
|
154
170
|
const treeMap = server.getTreeMap()
|
|
155
171
|
if (!treeMap) {
|
|
156
172
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
const entries = readEntries(treeMap)
|
|
175
|
+
const entries = readEntries(treeMap, server.rootDocId)
|
|
160
176
|
const lowerQuery = query.toLowerCase()
|
|
161
177
|
|
|
162
178
|
// If rootId specified, restrict to descendants only
|
|
@@ -261,6 +277,11 @@ export function registerTreeTools(
|
|
|
261
277
|
server.setAutoStatus('creating')
|
|
262
278
|
server.setActiveToolCall({ name: 'create_document', target: label })
|
|
263
279
|
|
|
280
|
+
// Activate the space that owns `parentId` first, so the new doc lands in
|
|
281
|
+
// the right space's tree instead of being orphaned into whatever space
|
|
282
|
+
// happened to be active (the silent data-loss path).
|
|
283
|
+
await server.ensureSpaceActive(parentId)
|
|
284
|
+
|
|
264
285
|
const treeMap = server.getTreeMap()
|
|
265
286
|
const rootDoc = server.rootDocument
|
|
266
287
|
if (!treeMap || !rootDoc) {
|
|
@@ -284,6 +305,48 @@ export function registerTreeTools(
|
|
|
284
305
|
const id = crypto.randomUUID()
|
|
285
306
|
const normalizedParent = normalizeRootId(parentId, server)
|
|
286
307
|
const now = Date.now()
|
|
308
|
+
|
|
309
|
+
// Guard: never write under a parent that doesn't exist in the resolved
|
|
310
|
+
// space's tree — that silently orphans the doc (it would be invisible in
|
|
311
|
+
// the intended space forever). Refuse with actionable guidance instead.
|
|
312
|
+
if (normalizedParent && !treeMap.has(normalizedParent)) {
|
|
313
|
+
return {
|
|
314
|
+
content: [{
|
|
315
|
+
type: 'text',
|
|
316
|
+
text: `Parent "${parentId}" was not found in any space the MCP can currently reach (active space: ${server.rootDocId}). Refusing to create — writing here would orphan the document. If the parent lives in another space, call switch_space to that space first, then retry.`,
|
|
317
|
+
}],
|
|
318
|
+
isError: true,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Register the SQL `documents` row BEFORE writing the Yjs tree entry.
|
|
323
|
+
// The RBAC cascade seeds from `documents WHERE id=?`, so a doc with no
|
|
324
|
+
// row can't walk up to inherit its parent's permissions — it falls to
|
|
325
|
+
// the flat `[access].authenticated` floor and is missing from
|
|
326
|
+
// `GET /docs` + FTS. `createChild` registers the row under the right
|
|
327
|
+
// parent (the current space root when no explicit parent is given) so
|
|
328
|
+
// permissions inherit. Doing it first means a failure here doesn't leave
|
|
329
|
+
// a permission-orphaned Yjs ghost.
|
|
330
|
+
const restParent = normalizedParent ?? server.rootDocId
|
|
331
|
+
if (restParent) {
|
|
332
|
+
try {
|
|
333
|
+
await server.client.createChild(restParent, {
|
|
334
|
+
child_id: id,
|
|
335
|
+
label,
|
|
336
|
+
doc_type: type,
|
|
337
|
+
kind: 'page',
|
|
338
|
+
})
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return {
|
|
341
|
+
content: [{
|
|
342
|
+
type: 'text',
|
|
343
|
+
text: `Failed to register document ${id} under ${restParent}: ${e instanceof Error ? e.message : String(e)}`,
|
|
344
|
+
}],
|
|
345
|
+
isError: true,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
287
350
|
rootDoc.transact(() => {
|
|
288
351
|
treeMap.set(
|
|
289
352
|
id,
|
|
@@ -320,6 +383,7 @@ export function registerTreeTools(
|
|
|
320
383
|
async ({ id, label }) => {
|
|
321
384
|
server.setAutoStatus('writing')
|
|
322
385
|
server.setActiveToolCall({ name: 'rename_document', target: id })
|
|
386
|
+
await server.ensureSpaceActive(id)
|
|
323
387
|
const treeMap = server.getTreeMap()
|
|
324
388
|
if (!treeMap) {
|
|
325
389
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
@@ -331,7 +395,21 @@ export function registerTreeTools(
|
|
|
331
395
|
}
|
|
332
396
|
|
|
333
397
|
patchEntry(treeMap, id, { label, updatedAt: Date.now() })
|
|
334
|
-
|
|
398
|
+
// Keep the SQL `documents.label` registry in step with the Yjs label so
|
|
399
|
+
// `list_spaces` / `GET /docs` / FTS reflect the rename. Critical for
|
|
400
|
+
// SPACE docs: the server-side label reconcile deliberately skips
|
|
401
|
+
// `kind='space'` rows (their name is REST-canonical) and cou-sh renders
|
|
402
|
+
// the registry name for space roots — so without this a renamed space
|
|
403
|
+
// would keep its old name everywhere but the Yjs self-entry (the exact
|
|
404
|
+
// "Health shows as Development" bug). Best-effort: a purely-Yjs (no-row)
|
|
405
|
+
// doc PATCHes to a harmless no-op.
|
|
406
|
+
let restNote = ''
|
|
407
|
+
try {
|
|
408
|
+
await server.client.updateDocumentMeta(id, { label })
|
|
409
|
+
} catch (e) {
|
|
410
|
+
restNote = ` (registry update failed: ${e instanceof Error ? e.message : String(e)})`
|
|
411
|
+
}
|
|
412
|
+
return { content: [{ type: 'text', text: `Renamed to "${label}"${restNote}` }] }
|
|
335
413
|
}
|
|
336
414
|
)
|
|
337
415
|
|
|
@@ -346,6 +424,7 @@ export function registerTreeTools(
|
|
|
346
424
|
async ({ id, newParentId, order }) => {
|
|
347
425
|
server.setAutoStatus('writing')
|
|
348
426
|
server.setActiveToolCall({ name: 'move_document', target: id })
|
|
427
|
+
await server.ensureSpaceActive(id)
|
|
349
428
|
const treeMap = server.getTreeMap()
|
|
350
429
|
if (!treeMap) {
|
|
351
430
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
@@ -356,12 +435,41 @@ export function registerTreeTools(
|
|
|
356
435
|
return { content: [{ type: 'text', text: `Document ${id} not found` }] }
|
|
357
436
|
}
|
|
358
437
|
|
|
438
|
+
const normalizedParent = normalizeRootId(newParentId, server)
|
|
439
|
+
// Cross-space moves aren't supported through a single tree map: the new
|
|
440
|
+
// parent must live in the same space as the doc being moved. Refuse
|
|
441
|
+
// rather than reparent onto a non-node (which would orphan the doc).
|
|
442
|
+
if (normalizedParent && !treeMap.has(normalizedParent)) {
|
|
443
|
+
return {
|
|
444
|
+
content: [{
|
|
445
|
+
type: 'text',
|
|
446
|
+
text: `New parent "${newParentId}" is not in the same space as "${id}" (active space: ${server.rootDocId}). Cross-space moves aren't supported — refusing to avoid orphaning the document.`,
|
|
447
|
+
}],
|
|
448
|
+
isError: true,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
359
451
|
patchEntry(treeMap, id, {
|
|
360
|
-
parentId:
|
|
452
|
+
parentId: normalizedParent,
|
|
361
453
|
order: order ?? Date.now(),
|
|
362
454
|
updatedAt: Date.now(),
|
|
363
455
|
})
|
|
364
|
-
|
|
456
|
+
// Mirror the reparent into the SQL `documents` row via the
|
|
457
|
+
// permission-checked REST path. The RBAC cascade walks `documents.parent_id`,
|
|
458
|
+
// so a Yjs-only move would leave permission resolution on the OLD ancestry.
|
|
459
|
+
// PATCH enforces manage-on-new-parent + cycle checks server-side (we never
|
|
460
|
+
// project Yjs parentId straight to SQL — that would be an escalation hole).
|
|
461
|
+
// REST parent for "top level" is the current space root. No-op for a
|
|
462
|
+
// purely-Yjs (no-row) doc.
|
|
463
|
+
const restParent = normalizedParent ?? server.rootDocId
|
|
464
|
+
let restNote = ''
|
|
465
|
+
if (restParent) {
|
|
466
|
+
try {
|
|
467
|
+
await server.client.updateDocumentMeta(id, { parent_id: restParent })
|
|
468
|
+
} catch (e) {
|
|
469
|
+
restNote = ` (registry reparent failed: ${e instanceof Error ? e.message : String(e)})`
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { content: [{ type: 'text', text: `Moved ${id} to parent ${newParentId}${restNote}` }] }
|
|
365
473
|
}
|
|
366
474
|
)
|
|
367
475
|
|
|
@@ -374,6 +482,7 @@ export function registerTreeTools(
|
|
|
374
482
|
async ({ id }) => {
|
|
375
483
|
server.setAutoStatus('writing')
|
|
376
484
|
server.setActiveToolCall({ name: 'delete_document', target: id })
|
|
485
|
+
await server.ensureSpaceActive(id)
|
|
377
486
|
const treeMap = server.getTreeMap()
|
|
378
487
|
const trashMap = server.getTrashMap()
|
|
379
488
|
const rootDoc = server.rootDocument
|
|
@@ -381,7 +490,7 @@ export function registerTreeTools(
|
|
|
381
490
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
382
491
|
}
|
|
383
492
|
|
|
384
|
-
const entries = readEntries(treeMap)
|
|
493
|
+
const entries = readEntries(treeMap, server.rootDocId)
|
|
385
494
|
const toDelete = [id, ...descendantsOf(entries, id).map(e => e.id)]
|
|
386
495
|
|
|
387
496
|
const now = Date.now()
|
|
@@ -402,7 +511,17 @@ export function registerTreeTools(
|
|
|
402
511
|
}
|
|
403
512
|
})
|
|
404
513
|
|
|
405
|
-
|
|
514
|
+
// Soft-delete the SQL `documents` row too (server cascades to descendants),
|
|
515
|
+
// else the row stays live and the doc keeps showing up in `GET /docs` + FTS
|
|
516
|
+
// search while hidden from the tree. Best-effort; no-op for a no-row doc.
|
|
517
|
+
let restNote = ''
|
|
518
|
+
try {
|
|
519
|
+
await server.client.deleteDoc(id)
|
|
520
|
+
} catch (e) {
|
|
521
|
+
restNote = ` (registry delete failed: ${e instanceof Error ? e.message : String(e)})`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { content: [{ type: 'text', text: `Deleted ${toDelete.length} document(s)${restNote}` }] }
|
|
406
525
|
}
|
|
407
526
|
)
|
|
408
527
|
|
|
@@ -416,6 +535,7 @@ export function registerTreeTools(
|
|
|
416
535
|
async ({ id, type }) => {
|
|
417
536
|
server.setAutoStatus('writing')
|
|
418
537
|
server.setActiveToolCall({ name: 'change_document_type', target: id })
|
|
538
|
+
await server.ensureSpaceActive(id)
|
|
419
539
|
const treeMap = server.getTreeMap()
|
|
420
540
|
if (!treeMap) {
|
|
421
541
|
return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
@@ -436,6 +556,7 @@ export function registerTreeTools(
|
|
|
436
556
|
'List all Spaces available on the server. Spaces are top-level documents (direct children of the server root) tagged with kind="space". Returns id, label, public_access, and active flag. Use switch_space to navigate.',
|
|
437
557
|
{},
|
|
438
558
|
async () => {
|
|
559
|
+
server.setActiveToolCall({ name: 'list_spaces' })
|
|
439
560
|
const spaces = server.spaces
|
|
440
561
|
if (!spaces.length) {
|
|
441
562
|
return { content: [{ type: 'text', text: 'No spaces available — create one to get started.' }] }
|
|
@@ -451,6 +572,7 @@ export function registerTreeTools(
|
|
|
451
572
|
'Switch the active Space. All subsequent tree operations will target the new Space. Use list_spaces to discover available ids.',
|
|
452
573
|
{ docId: z.string().describe('The id of the Space to switch to (from list_spaces).') },
|
|
453
574
|
async ({ docId }) => {
|
|
575
|
+
server.setActiveToolCall({ name: 'switch_space', target: docId })
|
|
454
576
|
await server.switchSpace(docId)
|
|
455
577
|
const space = server.spaces.find(s => s.id === docId)
|
|
456
578
|
const name = space?.label ?? docId
|
|
@@ -465,6 +587,8 @@ export function registerTreeTools(
|
|
|
465
587
|
id: z.string().describe('Document ID to duplicate.'),
|
|
466
588
|
},
|
|
467
589
|
async ({ id }) => {
|
|
590
|
+
server.setActiveToolCall({ name: 'duplicate_document', target: id })
|
|
591
|
+
await server.ensureSpaceActive(id)
|
|
468
592
|
const treeMap = server.getTreeMap()
|
|
469
593
|
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
470
594
|
|
|
@@ -500,6 +624,7 @@ export function registerTreeTools(
|
|
|
500
624
|
key: z.string().optional().describe('Filter to a single type by key (e.g. "kanban", "calendar"). Aliases are resolved (e.g. "desktop" → "dashboard"). Omit to list all types.'),
|
|
501
625
|
},
|
|
502
626
|
async ({ key }) => {
|
|
627
|
+
server.setActiveToolCall({ name: 'list_page_types', target: key })
|
|
503
628
|
if (key) {
|
|
504
629
|
const resolved = resolvePageType(key)
|
|
505
630
|
if (!resolved) {
|
package/src/utils.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wait for a provider's `synced` event with a timeout.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `@abraca/dabra`'s `DocUtils.waitForSync`: the `isSynced` short-circuit
|
|
5
|
+
* is load-bearing. `synced` is emitted once per sync transition (and deferred a
|
|
6
|
+
* microtask), so a provider that has ALREADY synced — e.g. the cached child
|
|
7
|
+
* provider that `loadChild` returns on a repeat read/write of the same doc —
|
|
8
|
+
* will never re-emit. Without this guard those callers block for the full
|
|
9
|
+
* `timeoutMs` ("works once, then every later op on that doc times out at 15s").
|
|
3
10
|
*/
|
|
4
11
|
export function waitForSync(
|
|
5
|
-
provider: {
|
|
12
|
+
provider: {
|
|
13
|
+
isSynced?: boolean
|
|
14
|
+
on(event: string, cb: () => void): void
|
|
15
|
+
off(event: string, cb: () => void): void
|
|
16
|
+
},
|
|
6
17
|
timeoutMs = 15000
|
|
7
18
|
): Promise<void> {
|
|
19
|
+
// Already synced — resolve immediately (no event will fire again).
|
|
20
|
+
if (provider.isSynced) return Promise.resolve()
|
|
21
|
+
|
|
8
22
|
return new Promise<void>((resolve, reject) => {
|
|
9
23
|
const timer = setTimeout(() => {
|
|
10
24
|
provider.off('synced', handler)
|
|
@@ -13,6 +27,7 @@ export function waitForSync(
|
|
|
13
27
|
|
|
14
28
|
function handler() {
|
|
15
29
|
clearTimeout(timer)
|
|
30
|
+
provider.off('synced', handler)
|
|
16
31
|
resolve()
|
|
17
32
|
}
|
|
18
33
|
|