@abraca/mcp 2.7.0 → 2.9.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/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
- function readEntries(treeMap: Y.Map<any>): TreeEntry[] {
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
- return { content: [{ type: 'text', text: `Renamed to "${label}"` }] }
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: normalizeRootId(newParentId, server),
452
+ parentId: normalizedParent,
361
453
  order: order ?? Date.now(),
362
454
  updatedAt: Date.now(),
363
455
  })
364
- return { content: [{ type: 'text', text: `Moved ${id} to parent ${newParentId}` }] }
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
- return { content: [{ type: 'text', text: `Deleted ${toDelete.length} document(s)` }] }
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: { on(event: string, cb: () => void): void; off(event: string, cb: () => void): void },
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