@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content tools — read/write document content via markdown ↔ Y.js conversion.
|
|
3
|
+
*/
|
|
4
|
+
import * as Y from 'yjs'
|
|
5
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import type { AbracadabraMCPServer } from '../server.ts'
|
|
8
|
+
import { populateYDocFromMarkdown, parseFrontmatter } from '../converters/markdownToYjs.ts'
|
|
9
|
+
import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
|
|
10
|
+
|
|
11
|
+
export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServer) {
|
|
12
|
+
mcp.tool(
|
|
13
|
+
'read_document',
|
|
14
|
+
'Read a document\'s content as markdown, along with its immediate children. IMPORTANT: A document\'s full content is its body text PLUS all its children. An empty body does not mean empty content — the children ARE the content (sub-docs, kanban columns, table columns, calendar events, etc.). Always check the returned "children" array and read child documents too.',
|
|
15
|
+
{
|
|
16
|
+
docId: z.string().describe('Document ID to read.'),
|
|
17
|
+
},
|
|
18
|
+
async ({ docId }) => {
|
|
19
|
+
try {
|
|
20
|
+
const provider = await server.getChildProvider(docId)
|
|
21
|
+
const fragment = provider.document.getXmlFragment('default')
|
|
22
|
+
|
|
23
|
+
const { title, markdown } = yjsToMarkdown(fragment)
|
|
24
|
+
|
|
25
|
+
// Auto-update presence: mark this doc as focused and place cursor at start
|
|
26
|
+
server.setFocusedDoc(docId)
|
|
27
|
+
server.setDocCursor(docId, 0)
|
|
28
|
+
|
|
29
|
+
// Get tree metadata + immediate children
|
|
30
|
+
const treeMap = server.getTreeMap()
|
|
31
|
+
let label = title
|
|
32
|
+
let type: string | undefined
|
|
33
|
+
let meta: Record<string, unknown> | undefined
|
|
34
|
+
let children: Array<{ id: string; label: string; type?: string; meta?: unknown }> = []
|
|
35
|
+
if (treeMap) {
|
|
36
|
+
const entry = treeMap.get(docId)
|
|
37
|
+
if (entry) {
|
|
38
|
+
label = entry.label || title
|
|
39
|
+
type = entry.type
|
|
40
|
+
meta = entry.meta
|
|
41
|
+
}
|
|
42
|
+
// Collect immediate children sorted by order
|
|
43
|
+
treeMap.forEach((value: any, id: string) => {
|
|
44
|
+
if (value.parentId === docId) {
|
|
45
|
+
children.push({ id, label: value.label || 'Untitled', type: value.type, meta: value.meta })
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
children.sort((a: any, b: any) => ((treeMap.get(a.id)?.order ?? 0) - (treeMap.get(b.id)?.order ?? 0)))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result: Record<string, unknown> = { label, type, meta, markdown, children }
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: JSON.stringify(result, null, 2),
|
|
56
|
+
}],
|
|
57
|
+
}
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: 'text', text: `Error reading document: ${error.message}` }],
|
|
61
|
+
isError: true,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
mcp.tool(
|
|
68
|
+
'write_document',
|
|
69
|
+
'Write markdown content to a document. Parses the markdown and writes it to the Y.js CRDT document, which syncs in real-time to all connected clients. Supports optional YAML frontmatter for title and metadata.',
|
|
70
|
+
{
|
|
71
|
+
docId: z.string().describe('Document ID to write to.'),
|
|
72
|
+
markdown: z.string().describe('Markdown content to write. Can include YAML frontmatter with title and metadata fields.'),
|
|
73
|
+
mode: z.enum(['replace', 'append']).optional().describe('Write mode. "replace" clears existing content first (default). "append" adds to the end.'),
|
|
74
|
+
},
|
|
75
|
+
async ({ docId, markdown, mode }) => {
|
|
76
|
+
try {
|
|
77
|
+
const writeMode = mode ?? 'replace'
|
|
78
|
+
const provider = await server.getChildProvider(docId)
|
|
79
|
+
const doc = provider.document
|
|
80
|
+
const fragment = doc.getXmlFragment('default')
|
|
81
|
+
|
|
82
|
+
// Parse optional frontmatter
|
|
83
|
+
const { title, meta, body } = parseFrontmatter(markdown)
|
|
84
|
+
|
|
85
|
+
// Update tree metadata if frontmatter provided title or meta
|
|
86
|
+
if (title || Object.keys(meta).length > 0) {
|
|
87
|
+
const treeMap = server.getTreeMap()
|
|
88
|
+
const rootDoc = server.rootDocument
|
|
89
|
+
if (treeMap && rootDoc) {
|
|
90
|
+
const entry = treeMap.get(docId)
|
|
91
|
+
if (entry) {
|
|
92
|
+
rootDoc.transact(() => {
|
|
93
|
+
const updates: Record<string, unknown> = { ...entry, updatedAt: Date.now() }
|
|
94
|
+
if (title) updates.label = title
|
|
95
|
+
if (Object.keys(meta).length > 0) {
|
|
96
|
+
updates.meta = { ...(entry.meta ?? {}), ...meta }
|
|
97
|
+
}
|
|
98
|
+
treeMap.set(docId, updates)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (writeMode === 'replace') {
|
|
105
|
+
// Clear existing content
|
|
106
|
+
doc.transact(() => {
|
|
107
|
+
while (fragment.length > 0) {
|
|
108
|
+
fragment.delete(0)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Write new content
|
|
114
|
+
const contentToWrite = body || markdown
|
|
115
|
+
const fallbackTitle = title || 'Untitled'
|
|
116
|
+
populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle)
|
|
117
|
+
|
|
118
|
+
// Auto-update presence: mark this doc as focused and place cursor at end
|
|
119
|
+
server.setFocusedDoc(docId)
|
|
120
|
+
server.setDocCursor(docId, fragment.length)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: 'text', text: `Document ${docId} updated (${writeMode} mode)` }],
|
|
124
|
+
}
|
|
125
|
+
} catch (error: any) {
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: 'text', text: `Error writing document: ${error.message}` }],
|
|
128
|
+
isError: true,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tools — upload/download/list/delete files via the REST API.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import type { AbracadabraMCPServer } from '../server.ts'
|
|
9
|
+
|
|
10
|
+
export function registerFileTools(mcp: McpServer, server: AbracadabraMCPServer) {
|
|
11
|
+
mcp.tool(
|
|
12
|
+
'list_uploads',
|
|
13
|
+
'List file attachments for a document.',
|
|
14
|
+
{
|
|
15
|
+
docId: z.string().describe('Document ID.'),
|
|
16
|
+
},
|
|
17
|
+
async ({ docId }) => {
|
|
18
|
+
try {
|
|
19
|
+
const uploads = await server.client.listUploads(docId)
|
|
20
|
+
return {
|
|
21
|
+
content: [{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify(uploads, null, 2),
|
|
24
|
+
}],
|
|
25
|
+
}
|
|
26
|
+
} catch (error: any) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text: `Error listing uploads: ${error.message}` }],
|
|
29
|
+
isError: true,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
mcp.tool(
|
|
36
|
+
'upload_file',
|
|
37
|
+
'Upload a local file to a document.',
|
|
38
|
+
{
|
|
39
|
+
docId: z.string().describe('Document ID to attach the file to.'),
|
|
40
|
+
filePath: z.string().describe('Absolute path to the local file to upload.'),
|
|
41
|
+
filename: z.string().optional().describe('Override filename (defaults to basename of filePath).'),
|
|
42
|
+
},
|
|
43
|
+
async ({ docId, filePath, filename }) => {
|
|
44
|
+
try {
|
|
45
|
+
const resolvedPath = path.resolve(filePath)
|
|
46
|
+
const data = fs.readFileSync(resolvedPath)
|
|
47
|
+
const name = filename ?? path.basename(resolvedPath)
|
|
48
|
+
const blob = new Blob([data])
|
|
49
|
+
const result = await server.client.upload(docId, blob, name)
|
|
50
|
+
return {
|
|
51
|
+
content: [{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify(result, null, 2),
|
|
54
|
+
}],
|
|
55
|
+
}
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: `Error uploading file: ${error.message}` }],
|
|
59
|
+
isError: true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
mcp.tool(
|
|
66
|
+
'download_file',
|
|
67
|
+
'Download a file attachment from a document to a local path.',
|
|
68
|
+
{
|
|
69
|
+
docId: z.string().describe('Document ID.'),
|
|
70
|
+
uploadId: z.string().describe('Upload ID to download.'),
|
|
71
|
+
saveTo: z.string().describe('Absolute local file path to save the download.'),
|
|
72
|
+
},
|
|
73
|
+
async ({ docId, uploadId, saveTo }) => {
|
|
74
|
+
try {
|
|
75
|
+
const blob = await server.client.getUpload(docId, uploadId)
|
|
76
|
+
const buffer = Buffer.from(await blob.arrayBuffer())
|
|
77
|
+
const resolvedPath = path.resolve(saveTo)
|
|
78
|
+
fs.writeFileSync(resolvedPath, buffer)
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: 'text', text: `Downloaded to ${resolvedPath} (${buffer.length} bytes)` }],
|
|
81
|
+
}
|
|
82
|
+
} catch (error: any) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: 'text', text: `Error downloading file: ${error.message}` }],
|
|
85
|
+
isError: true,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
mcp.tool(
|
|
92
|
+
'delete_file',
|
|
93
|
+
'Delete a file attachment from a document.',
|
|
94
|
+
{
|
|
95
|
+
docId: z.string().describe('Document ID.'),
|
|
96
|
+
uploadId: z.string().describe('Upload ID to delete.'),
|
|
97
|
+
},
|
|
98
|
+
async ({ docId, uploadId }) => {
|
|
99
|
+
try {
|
|
100
|
+
await server.client.deleteUpload(docId, uploadId)
|
|
101
|
+
return { content: [{ type: 'text', text: `Deleted upload ${uploadId}` }] }
|
|
102
|
+
} catch (error: any) {
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text', text: `Error deleting file: ${error.message}` }],
|
|
105
|
+
isError: true,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata tools — read/update PageMeta on tree entries.
|
|
3
|
+
*/
|
|
4
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import type { AbracadabraMCPServer } from '../server.ts'
|
|
7
|
+
|
|
8
|
+
export function registerMetaTools(mcp: McpServer, server: AbracadabraMCPServer) {
|
|
9
|
+
mcp.tool(
|
|
10
|
+
'get_metadata',
|
|
11
|
+
'Read the metadata (PageMeta) of a document from the tree.',
|
|
12
|
+
{
|
|
13
|
+
docId: z.string().describe('Document ID.'),
|
|
14
|
+
},
|
|
15
|
+
async ({ docId }) => {
|
|
16
|
+
const treeMap = server.getTreeMap()
|
|
17
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
18
|
+
|
|
19
|
+
const entry = treeMap.get(docId)
|
|
20
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${docId} not found` }] }
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
content: [{
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: JSON.stringify({
|
|
26
|
+
id: docId,
|
|
27
|
+
label: entry.label,
|
|
28
|
+
type: entry.type,
|
|
29
|
+
meta: entry.meta ?? {},
|
|
30
|
+
}, null, 2),
|
|
31
|
+
}],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
mcp.tool(
|
|
37
|
+
'update_metadata',
|
|
38
|
+
'Update metadata fields on a document. Merges the provided fields into existing metadata.',
|
|
39
|
+
{
|
|
40
|
+
docId: z.string().describe('Document ID.'),
|
|
41
|
+
meta: z.record(z.unknown()).describe('Metadata fields to update (merged with existing). Standard PageMeta keys: color (hex string), icon (Lucide kebab-case name like "star"/"code-2"/"users" — NEVER emoji), dateStart, dateEnd, datetimeStart, datetimeEnd, allDay, tags, checked, priority (0-4), status, rating, url, taskProgress (0-100), subtitle, note. Set a key to null to clear it.'),
|
|
42
|
+
},
|
|
43
|
+
async ({ docId, meta }) => {
|
|
44
|
+
const treeMap = server.getTreeMap()
|
|
45
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
46
|
+
|
|
47
|
+
const entry = treeMap.get(docId)
|
|
48
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${docId} not found` }] }
|
|
49
|
+
|
|
50
|
+
treeMap.set(docId, {
|
|
51
|
+
...entry,
|
|
52
|
+
meta: { ...(entry.meta ?? {}), ...meta },
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return { content: [{ type: 'text', text: `Metadata updated for ${docId}` }] }
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document tree tools — operate on the root Y.Doc's "doc-tree" Y.Map.
|
|
3
|
+
*/
|
|
4
|
+
import type * as Y from 'yjs'
|
|
5
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import type { AbracadabraMCPServer } from '../server.ts'
|
|
8
|
+
import type { TreeEntry, PageMeta } from '../converters/types.ts'
|
|
9
|
+
|
|
10
|
+
function readEntries(treeMap: Y.Map<any>): TreeEntry[] {
|
|
11
|
+
const entries: TreeEntry[] = []
|
|
12
|
+
treeMap.forEach((value: any, id: string) => {
|
|
13
|
+
entries.push({
|
|
14
|
+
id,
|
|
15
|
+
label: value.label || 'Untitled',
|
|
16
|
+
parentId: value.parentId ?? null,
|
|
17
|
+
order: value.order ?? 0,
|
|
18
|
+
type: value.type,
|
|
19
|
+
meta: value.meta,
|
|
20
|
+
createdAt: value.createdAt,
|
|
21
|
+
updatedAt: value.updatedAt,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
return entries
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function childrenOf(entries: TreeEntry[], parentId: string | null): TreeEntry[] {
|
|
28
|
+
return entries
|
|
29
|
+
.filter(e => e.parentId === parentId)
|
|
30
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
|
|
34
|
+
const result: TreeEntry[] = []
|
|
35
|
+
function collect(pid: string) {
|
|
36
|
+
for (const child of childrenOf(entries, pid)) {
|
|
37
|
+
result.push(child)
|
|
38
|
+
collect(child.id)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
collect(id)
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0): any[] {
|
|
46
|
+
if (maxDepth >= 0 && currentDepth >= maxDepth) return []
|
|
47
|
+
const children = childrenOf(entries, rootId)
|
|
48
|
+
return children.map(entry => ({
|
|
49
|
+
id: entry.id,
|
|
50
|
+
label: entry.label,
|
|
51
|
+
type: entry.type,
|
|
52
|
+
meta: entry.meta,
|
|
53
|
+
order: entry.order,
|
|
54
|
+
children: buildTree(entries, entry.id, maxDepth, currentDepth + 1),
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer) {
|
|
59
|
+
mcp.tool(
|
|
60
|
+
'list_documents',
|
|
61
|
+
'List direct children of a document (defaults to root). Returns id, label, type, meta, order.',
|
|
62
|
+
{ parentId: z.string().optional().describe('Parent document ID. Omit for root-level documents.') },
|
|
63
|
+
async ({ parentId }) => {
|
|
64
|
+
const treeMap = server.getTreeMap()
|
|
65
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
66
|
+
|
|
67
|
+
const targetId = parentId ?? null
|
|
68
|
+
const entries = readEntries(treeMap)
|
|
69
|
+
const children = childrenOf(entries, targetId)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: JSON.stringify(children, null, 2),
|
|
75
|
+
}],
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
mcp.tool(
|
|
81
|
+
'get_document_tree',
|
|
82
|
+
'Get the full document tree as nested JSON. Useful for understanding the document structure.',
|
|
83
|
+
{
|
|
84
|
+
rootId: z.string().optional().describe('Root document ID to start from. Omit for the entire tree.'),
|
|
85
|
+
depth: z.number().optional().describe('Maximum depth to traverse. Default 3. Use -1 for unlimited.'),
|
|
86
|
+
},
|
|
87
|
+
async ({ rootId, depth }) => {
|
|
88
|
+
const treeMap = server.getTreeMap()
|
|
89
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
90
|
+
|
|
91
|
+
const targetId = rootId ?? null
|
|
92
|
+
const maxDepth = depth ?? 3
|
|
93
|
+
const entries = readEntries(treeMap)
|
|
94
|
+
const tree = buildTree(entries, targetId, maxDepth)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
content: [{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: JSON.stringify(tree, null, 2),
|
|
100
|
+
}],
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
mcp.tool(
|
|
106
|
+
'create_document',
|
|
107
|
+
'Create a new document in the tree. Returns the new document ID.',
|
|
108
|
+
{
|
|
109
|
+
parentId: z.string().optional().describe('Parent document ID. Omit for top-level pages. Use a document ID for nested/child pages.'),
|
|
110
|
+
label: z.string().describe('Display name / title for the document.'),
|
|
111
|
+
type: z.string().optional().describe('Page type: "doc", "kanban", "calendar", "table", "outline", "gallery", "slides", "timeline", "whiteboard", "map", "desktop", "mindmap", "graph". Omit to inherit parent view.'),
|
|
112
|
+
meta: z.record(z.unknown()).optional().describe('Initial metadata (PageMeta fields: color as hex string, icon as Lucide kebab-case name like "star"/"code-2"/"users" — never emoji, dateStart, dateEnd, priority 0-4, tags array, etc). Omit icon entirely to use page type default.'),
|
|
113
|
+
},
|
|
114
|
+
async ({ parentId, label, type, meta }) => {
|
|
115
|
+
const treeMap = server.getTreeMap()
|
|
116
|
+
const rootDoc = server.rootDocument
|
|
117
|
+
if (!treeMap || !rootDoc) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
118
|
+
|
|
119
|
+
const id = crypto.randomUUID()
|
|
120
|
+
const now = Date.now()
|
|
121
|
+
rootDoc.transact(() => {
|
|
122
|
+
treeMap.set(id, {
|
|
123
|
+
label,
|
|
124
|
+
parentId: parentId ?? null,
|
|
125
|
+
order: now,
|
|
126
|
+
type,
|
|
127
|
+
meta: meta as PageMeta,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: JSON.stringify({ id, label, parentId, type }, null, 2),
|
|
137
|
+
}],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
mcp.tool(
|
|
143
|
+
'rename_document',
|
|
144
|
+
'Rename a document (updates its display label everywhere).',
|
|
145
|
+
{
|
|
146
|
+
id: z.string().describe('Document ID to rename.'),
|
|
147
|
+
label: z.string().describe('New display name.'),
|
|
148
|
+
},
|
|
149
|
+
async ({ id, label }) => {
|
|
150
|
+
const treeMap = server.getTreeMap()
|
|
151
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
152
|
+
|
|
153
|
+
const entry = treeMap.get(id)
|
|
154
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${id} not found` }] }
|
|
155
|
+
|
|
156
|
+
treeMap.set(id, { ...entry, label, updatedAt: Date.now() })
|
|
157
|
+
return { content: [{ type: 'text', text: `Renamed to "${label}"` }] }
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
mcp.tool(
|
|
162
|
+
'move_document',
|
|
163
|
+
'Move a document to a new parent and/or reorder it.',
|
|
164
|
+
{
|
|
165
|
+
id: z.string().describe('Document ID to move.'),
|
|
166
|
+
newParentId: z.string().optional().describe('New parent document ID. Omit to move to top level.'),
|
|
167
|
+
order: z.number().optional().describe('New sort order. Defaults to Date.now() (append to end).'),
|
|
168
|
+
},
|
|
169
|
+
async ({ id, newParentId, order }) => {
|
|
170
|
+
const treeMap = server.getTreeMap()
|
|
171
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
172
|
+
|
|
173
|
+
const entry = treeMap.get(id)
|
|
174
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${id} not found` }] }
|
|
175
|
+
|
|
176
|
+
treeMap.set(id, {
|
|
177
|
+
...entry,
|
|
178
|
+
parentId: newParentId ?? null,
|
|
179
|
+
order: order ?? Date.now(),
|
|
180
|
+
updatedAt: Date.now(),
|
|
181
|
+
})
|
|
182
|
+
return { content: [{ type: 'text', text: `Moved ${id} to parent ${newParentId}` }] }
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
mcp.tool(
|
|
187
|
+
'delete_document',
|
|
188
|
+
'Soft-delete a document and its descendants (moves to trash).',
|
|
189
|
+
{
|
|
190
|
+
id: z.string().describe('Document ID to delete.'),
|
|
191
|
+
},
|
|
192
|
+
async ({ id }) => {
|
|
193
|
+
const treeMap = server.getTreeMap()
|
|
194
|
+
const trashMap = server.getTrashMap()
|
|
195
|
+
const rootDoc = server.rootDocument
|
|
196
|
+
if (!treeMap || !trashMap || !rootDoc) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
197
|
+
|
|
198
|
+
const entries = readEntries(treeMap)
|
|
199
|
+
const toDelete = [id, ...descendantsOf(entries, id).map(e => e.id)]
|
|
200
|
+
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
rootDoc.transact(() => {
|
|
203
|
+
for (const nid of toDelete) {
|
|
204
|
+
const entry = treeMap.get(nid)
|
|
205
|
+
if (!entry) continue
|
|
206
|
+
trashMap.set(nid, {
|
|
207
|
+
label: entry.label || 'Untitled',
|
|
208
|
+
parentId: entry.parentId ?? null,
|
|
209
|
+
order: entry.order ?? 0,
|
|
210
|
+
type: entry.type,
|
|
211
|
+
meta: entry.meta,
|
|
212
|
+
deletedAt: now,
|
|
213
|
+
})
|
|
214
|
+
treeMap.delete(nid)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
return { content: [{ type: 'text', text: `Deleted ${toDelete.length} document(s)` }] }
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
mcp.tool(
|
|
223
|
+
'change_document_type',
|
|
224
|
+
'Change the page type view of a document (data is preserved).',
|
|
225
|
+
{
|
|
226
|
+
id: z.string().describe('Document ID.'),
|
|
227
|
+
type: z.string().describe('New page type (e.g. "doc", "kanban", "table", "calendar", "outline", "gallery", "slides", "timeline", "whiteboard", "map", "desktop", "mindmap", "graph").'),
|
|
228
|
+
},
|
|
229
|
+
async ({ id, type }) => {
|
|
230
|
+
const treeMap = server.getTreeMap()
|
|
231
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
232
|
+
|
|
233
|
+
const entry = treeMap.get(id)
|
|
234
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${id} not found` }] }
|
|
235
|
+
|
|
236
|
+
treeMap.set(id, { ...entry, type, updatedAt: Date.now() })
|
|
237
|
+
return { content: [{ type: 'text', text: `Changed type to "${type}"` }] }
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
mcp.tool(
|
|
242
|
+
'list_spaces',
|
|
243
|
+
'List all spaces available on the server. Returns id, name, doc_id, is_hub, and visibility. Use switch_space to navigate to a space.',
|
|
244
|
+
{},
|
|
245
|
+
async () => {
|
|
246
|
+
const spaces = server.spaces
|
|
247
|
+
if (!spaces.length) {
|
|
248
|
+
return { content: [{ type: 'text', text: 'No spaces available (spaces extension not loaded on this server)' }] }
|
|
249
|
+
}
|
|
250
|
+
const active = server.rootDocId
|
|
251
|
+
const annotated = spaces.map(s => ({ ...s, active: s.doc_id === active }))
|
|
252
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotated, null, 2) }] }
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
mcp.tool(
|
|
257
|
+
'switch_space',
|
|
258
|
+
'Switch the active space. All subsequent tree operations will target the new space. Use list_spaces to discover available doc_ids.',
|
|
259
|
+
{ docId: z.string().describe('The doc_id of the space to switch to (from list_spaces).') },
|
|
260
|
+
async ({ docId }) => {
|
|
261
|
+
await server.switchSpace(docId)
|
|
262
|
+
const space = server.spaces.find(s => s.doc_id === docId)
|
|
263
|
+
const name = space?.name ?? docId
|
|
264
|
+
return { content: [{ type: 'text', text: `Switched to space "${name}" (${docId})` }] }
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
mcp.tool(
|
|
269
|
+
'duplicate_document',
|
|
270
|
+
'Shallow-clone a document with " (copy)" suffix. Returns new document ID.',
|
|
271
|
+
{
|
|
272
|
+
id: z.string().describe('Document ID to duplicate.'),
|
|
273
|
+
},
|
|
274
|
+
async ({ id }) => {
|
|
275
|
+
const treeMap = server.getTreeMap()
|
|
276
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
277
|
+
|
|
278
|
+
const entry = treeMap.get(id)
|
|
279
|
+
if (!entry) return { content: [{ type: 'text', text: `Document ${id} not found` }] }
|
|
280
|
+
|
|
281
|
+
const newId = crypto.randomUUID()
|
|
282
|
+
treeMap.set(newId, {
|
|
283
|
+
...entry,
|
|
284
|
+
label: (entry.label || 'Untitled') + ' (copy)',
|
|
285
|
+
order: Date.now(),
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
content: [{
|
|
290
|
+
type: 'text',
|
|
291
|
+
text: JSON.stringify({ id: newId, label: entry.label + ' (copy)' }, null, 2),
|
|
292
|
+
}],
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait for a provider's `synced` event with a timeout.
|
|
3
|
+
*/
|
|
4
|
+
export function waitForSync(
|
|
5
|
+
provider: { on(event: string, cb: () => void): void; off(event: string, cb: () => void): void },
|
|
6
|
+
timeoutMs = 15000
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
return new Promise<void>((resolve, reject) => {
|
|
9
|
+
const timer = setTimeout(() => {
|
|
10
|
+
provider.off('synced', handler)
|
|
11
|
+
reject(new Error(`Sync timed out after ${timeoutMs}ms`))
|
|
12
|
+
}, timeoutMs)
|
|
13
|
+
|
|
14
|
+
function handler() {
|
|
15
|
+
clearTimeout(timer)
|
|
16
|
+
resolve()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
provider.on('synced', handler)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps a promise with a timeout.
|
|
25
|
+
*/
|
|
26
|
+
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message?: string): Promise<T> {
|
|
27
|
+
return new Promise<T>((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(
|
|
29
|
+
() => reject(new Error(message ?? `Operation timed out after ${timeoutMs}ms`)),
|
|
30
|
+
timeoutMs
|
|
31
|
+
)
|
|
32
|
+
promise.then(
|
|
33
|
+
(val) => { clearTimeout(timer); resolve(val) },
|
|
34
|
+
(err) => { clearTimeout(timer); reject(err) }
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
}
|