@abraca/mcp 1.0.2 → 1.0.10
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 +1124 -42
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +1126 -42
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/package.json +2 -1
- package/src/crypto.ts +68 -0
- package/src/index.ts +40 -13
- package/src/server.ts +31 -19
- package/src/tools/tree.ts +92 -17
package/dist/index.d.ts
CHANGED
|
@@ -8709,10 +8709,10 @@ type RegisteredPrompt = {
|
|
|
8709
8709
|
//#region packages/mcp/src/server.d.ts
|
|
8710
8710
|
interface MCPServerConfig {
|
|
8711
8711
|
url: string;
|
|
8712
|
-
username: string;
|
|
8713
|
-
password: string;
|
|
8714
8712
|
agentName?: string;
|
|
8715
8713
|
agentColor?: string;
|
|
8714
|
+
inviteCode?: string;
|
|
8715
|
+
keyFile?: string;
|
|
8716
8716
|
}
|
|
8717
8717
|
declare class AbracadabraMCPServer {
|
|
8718
8718
|
readonly config: MCPServerConfig;
|
|
@@ -8737,7 +8737,7 @@ declare class AbracadabraMCPServer {
|
|
|
8737
8737
|
get rootDocument(): Y.Doc | null;
|
|
8738
8738
|
get rootYProvider(): AbracadabraProvider | null;
|
|
8739
8739
|
get userId(): string | null;
|
|
8740
|
-
/** Start the server:
|
|
8740
|
+
/** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
|
|
8741
8741
|
connect(): Promise<void>;
|
|
8742
8742
|
/** Connect to a space's root doc and cache it. Sets it as the active connection. */
|
|
8743
8743
|
private _connectToSpace;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abraca/mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
32
|
+
"@noble/hashes": "^1.8.0",
|
|
32
33
|
"zod": "^3.25.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 key generation, persistence, and challenge signing for MCP agent auth.
|
|
3
|
+
*/
|
|
4
|
+
import * as ed from '@noble/ed25519'
|
|
5
|
+
import { sha512 } from '@noble/hashes/sha2'
|
|
6
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
7
|
+
|
|
8
|
+
// @noble/ed25519 v2+ requires explicit hash configuration
|
|
9
|
+
ed.etc.sha512Sync = (...msgs) => sha512(ed.etc.concatBytes(...msgs))
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join, dirname } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const DEFAULT_KEY_PATH = join(homedir(), '.abracadabra', 'agent.key')
|
|
15
|
+
|
|
16
|
+
function toBase64url(bytes: Uint8Array): string {
|
|
17
|
+
return Buffer.from(bytes).toString('base64url')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fromBase64url(b64: string): Uint8Array {
|
|
21
|
+
return new Uint8Array(Buffer.from(b64, 'base64url'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AgentKeypair {
|
|
25
|
+
privateKey: Uint8Array
|
|
26
|
+
publicKeyB64: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load an existing Ed25519 keypair from disk, or generate and persist a new one.
|
|
31
|
+
* The file stores the raw 32-byte private key seed.
|
|
32
|
+
*/
|
|
33
|
+
export async function loadOrCreateKeypair(keyPath?: string): Promise<AgentKeypair> {
|
|
34
|
+
const path = keyPath || DEFAULT_KEY_PATH
|
|
35
|
+
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
const seed = await readFile(path)
|
|
38
|
+
if (seed.length !== 32) {
|
|
39
|
+
throw new Error(`Invalid key file at ${path}: expected 32 bytes, got ${seed.length}`)
|
|
40
|
+
}
|
|
41
|
+
const privateKey = new Uint8Array(seed)
|
|
42
|
+
const publicKey = ed.getPublicKey(privateKey)
|
|
43
|
+
return { privateKey, publicKeyB64: toBase64url(publicKey) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Generate new keypair
|
|
47
|
+
const privateKey = ed.utils.randomPrivateKey()
|
|
48
|
+
const publicKey = ed.getPublicKey(privateKey)
|
|
49
|
+
|
|
50
|
+
// Ensure directory exists and write seed with restricted permissions
|
|
51
|
+
const dir = dirname(path)
|
|
52
|
+
if (!existsSync(dir)) {
|
|
53
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
54
|
+
}
|
|
55
|
+
await writeFile(path, Buffer.from(privateKey), { mode: 0o600 })
|
|
56
|
+
|
|
57
|
+
console.error(`[abracadabra-mcp] Generated new agent keypair at ${path}`)
|
|
58
|
+
console.error(`[abracadabra-mcp] Public key: ${toBase64url(publicKey)}`)
|
|
59
|
+
|
|
60
|
+
return { privateKey, publicKeyB64: toBase64url(publicKey) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Sign a base64url challenge with the agent's private key; returns base64url signature. */
|
|
64
|
+
export function signChallenge(challengeB64: string, privateKey: Uint8Array): string {
|
|
65
|
+
const challenge = fromBase64url(challengeB64)
|
|
66
|
+
const sig = ed.sign(challenge, privateKey)
|
|
67
|
+
return toBase64url(sig)
|
|
68
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* Abracadabra MCP Server — entry point.
|
|
4
4
|
*
|
|
5
5
|
* Environment variables:
|
|
6
|
-
* ABRA_URL
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* ABRA_URL (required) — Server URL (e.g. http://localhost:1234)
|
|
7
|
+
* ABRA_AGENT_NAME — Display name (default: "AI Assistant")
|
|
8
|
+
* ABRA_AGENT_COLOR — HSL color for presence (default: "hsl(270, 80%, 60%)")
|
|
9
|
+
* ABRA_INVITE_CODE — Invite code for first-run registration (grants role)
|
|
10
|
+
* ABRA_KEY_FILE — Path to Ed25519 key file (default: ~/.abracadabra/agent.key)
|
|
11
11
|
*/
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
@@ -24,21 +24,19 @@ import { registerServerInfoResource } from './resources/server-info.ts'
|
|
|
24
24
|
|
|
25
25
|
async function main() {
|
|
26
26
|
const url = process.env.ABRA_URL
|
|
27
|
-
const username = process.env.ABRA_USERNAME
|
|
28
|
-
const password = process.env.ABRA_PASSWORD
|
|
29
27
|
|
|
30
|
-
if (!url
|
|
31
|
-
console.error('Missing required environment
|
|
28
|
+
if (!url) {
|
|
29
|
+
console.error('Missing required environment variable: ABRA_URL')
|
|
32
30
|
process.exit(1)
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
// Create the Abracadabra connection manager
|
|
36
34
|
const server = new AbracadabraMCPServer({
|
|
37
35
|
url,
|
|
38
|
-
username,
|
|
39
|
-
password,
|
|
40
36
|
agentName: process.env.ABRA_AGENT_NAME,
|
|
41
37
|
agentColor: process.env.ABRA_AGENT_COLOR,
|
|
38
|
+
inviteCode: process.env.ABRA_INVITE_CODE,
|
|
39
|
+
keyFile: process.env.ABRA_KEY_FILE,
|
|
42
40
|
})
|
|
43
41
|
|
|
44
42
|
// Create MCP server with channel capability
|
|
@@ -46,10 +44,39 @@ async function main() {
|
|
|
46
44
|
{ name: 'abracadabra', version: '1.0.0' },
|
|
47
45
|
{
|
|
48
46
|
capabilities: { experimental: { 'claude/channel': {} } },
|
|
49
|
-
instructions: `
|
|
47
|
+
instructions: `Abracadabra is a CRDT collaboration platform where everything is a document in a tree.
|
|
48
|
+
|
|
49
|
+
## Quick Start Workflow
|
|
50
|
+
1. list_spaces → note the active space's doc_id (this is the hub/root document)
|
|
51
|
+
2. get_document_tree(rootId: hubDocId) → see the FULL hierarchy before doing anything
|
|
52
|
+
3. find_document(query) → search for a document by name if you know what you're looking for
|
|
53
|
+
4. read_document / write_document / create_document → operate on specific documents
|
|
54
|
+
|
|
55
|
+
## Key Concepts
|
|
56
|
+
- Documents form a tree. A kanban board's columns are child documents; cards are grandchildren.
|
|
57
|
+
- A document's label IS its display name everywhere. Children ARE the content (not just the body text).
|
|
58
|
+
- Page types (doc, kanban, table, calendar, timeline, outline, etc.) are views over the SAME tree — switching types preserves data.
|
|
59
|
+
- An empty markdown body does NOT mean empty content — always check the children array.
|
|
60
|
+
|
|
61
|
+
## Finding Documents
|
|
62
|
+
- list_documents only shows ONE level of children. If you don't find what you need, use find_document to search the entire tree by name, or get_document_tree to see the full hierarchy.
|
|
63
|
+
- NEVER conclude a document doesn't exist after only checking root-level documents.
|
|
64
|
+
|
|
65
|
+
## Rules
|
|
66
|
+
- Always call list_spaces first to get the hub doc ID before creating documents.
|
|
67
|
+
- Always call get_document_tree to understand existing structure before creating documents.
|
|
68
|
+
- Use Lucide icon names in kebab-case (e.g. "star", "code-2") for meta.icon — NEVER emoji.
|
|
69
|
+
- Never rename or write content to the hub document itself.
|
|
70
|
+
- Use universal meta keys (color, icon, dateStart) — never prefix with page type names.
|
|
71
|
+
|
|
72
|
+
## Channel Events
|
|
73
|
+
Events from the abracadabra channel arrive as <channel source="abracadabra" ...>. They may be:
|
|
50
74
|
- ai:task events from human users (includes sender, doc_id context)
|
|
51
75
|
- chat messages from the platform chat system (includes channel, sender, sender_id)
|
|
52
|
-
When you receive a channel event, read it, do the work using your tools, and reply using the reply tool (for document responses) or send_chat_message (for chat responses)
|
|
76
|
+
When you receive a channel event, read it, do the work using your tools, and reply using the reply tool (for document responses) or send_chat_message (for chat responses).
|
|
77
|
+
|
|
78
|
+
## Full Reference
|
|
79
|
+
Read the resource at abracadabra://agent-guide for the complete guide covering page type schemas, metadata reference, awareness/presence, content structure, and detailed examples.`,
|
|
53
80
|
},
|
|
54
81
|
)
|
|
55
82
|
|
package/src/server.ts
CHANGED
|
@@ -11,13 +11,14 @@ import type { ServerInfo, SpaceMeta } from '@abraca/dabra'
|
|
|
11
11
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
12
12
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
13
13
|
import { waitForSync } from './utils.ts'
|
|
14
|
+
import { loadOrCreateKeypair, signChallenge } from './crypto.ts'
|
|
14
15
|
|
|
15
16
|
export interface MCPServerConfig {
|
|
16
17
|
url: string
|
|
17
|
-
username: string
|
|
18
|
-
password: string
|
|
19
18
|
agentName?: string
|
|
20
19
|
agentColor?: string
|
|
20
|
+
inviteCode?: string
|
|
21
|
+
keyFile?: string
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface SpaceConnection {
|
|
@@ -88,28 +89,39 @@ export class AbracadabraMCPServer {
|
|
|
88
89
|
return this._userId
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/** Start the server:
|
|
92
|
+
/** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
|
|
92
93
|
async connect(): Promise<void> {
|
|
93
|
-
// Step 1:
|
|
94
|
-
await this.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
})
|
|
98
|
-
console.error(`[abracadabra-mcp] Logged in as ${this.config.username}`)
|
|
94
|
+
// Step 1: Load or generate Ed25519 keypair
|
|
95
|
+
const keypair = await loadOrCreateKeypair(this.config.keyFile)
|
|
96
|
+
this._userId = keypair.publicKeyB64
|
|
97
|
+
const signFn = (challenge: string) => Promise.resolve(signChallenge(challenge, keypair.privateKey))
|
|
99
98
|
|
|
100
|
-
// Step
|
|
99
|
+
// Step 2: Authenticate via challenge-response (register on first run)
|
|
101
100
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
await this.client.loginWithKey(keypair.publicKeyB64, signFn)
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
// Key not registered — auto-register and retry
|
|
104
|
+
const status = err?.status ?? err?.response?.status
|
|
105
|
+
if (status === 404 || status === 422) {
|
|
106
|
+
console.error('[abracadabra-mcp] Key not registered, creating new account...')
|
|
107
|
+
await this.client.registerWithKey({
|
|
108
|
+
publicKey: keypair.publicKeyB64,
|
|
109
|
+
username: this.agentName.replace(/\s+/g, '-').toLowerCase(),
|
|
110
|
+
displayName: this.agentName,
|
|
111
|
+
deviceName: 'MCP Agent',
|
|
112
|
+
inviteCode: this.config.inviteCode,
|
|
113
|
+
})
|
|
114
|
+
await this.client.loginWithKey(keypair.publicKeyB64, signFn)
|
|
115
|
+
} else {
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
107
118
|
}
|
|
119
|
+
console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (${keypair.publicKeyB64.slice(0, 12)}...)`)
|
|
108
120
|
|
|
109
|
-
// Step
|
|
121
|
+
// Step 3: Discover server info
|
|
110
122
|
this._serverInfo = await this.client.serverInfo()
|
|
111
123
|
|
|
112
|
-
// Step
|
|
124
|
+
// Step 4: Discover spaces (optional extension)
|
|
113
125
|
let initialDocId: string | null = this._serverInfo.index_doc_id ?? null
|
|
114
126
|
try {
|
|
115
127
|
this._spaces = await this.client.listSpaces()
|
|
@@ -132,11 +144,11 @@ export class AbracadabraMCPServer {
|
|
|
132
144
|
this._rootDocId = initialDocId
|
|
133
145
|
console.error(`[abracadabra-mcp] Active space doc: ${initialDocId}`)
|
|
134
146
|
|
|
135
|
-
// Step
|
|
147
|
+
// Step 5: Connect to initial space
|
|
136
148
|
await this._connectToSpace(initialDocId)
|
|
137
149
|
console.error('[abracadabra-mcp] Space doc synced')
|
|
138
150
|
|
|
139
|
-
// Step
|
|
151
|
+
// Step 6: Start eviction timer
|
|
140
152
|
this.evictionTimer = setInterval(() => this.evictIdle(), 60_000)
|
|
141
153
|
}
|
|
142
154
|
|
package/src/tools/tree.ts
CHANGED
|
@@ -7,6 +7,16 @@ import { z } from 'zod'
|
|
|
7
7
|
import type { AbracadabraMCPServer } from '../server.ts'
|
|
8
8
|
import type { TreeEntry, PageMeta } from '../converters/types.ts'
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a document ID so the hub/root doc ID is treated as the tree root (null).
|
|
12
|
+
* This lets callers pass the hub doc_id from list_spaces as parentId/rootId
|
|
13
|
+
* and get the expected root-level results instead of an empty set.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeRootId(id: string | null | undefined, server: AbracadabraMCPServer): string | null {
|
|
16
|
+
if (id == null) return null
|
|
17
|
+
return id === server.rootDocId ? null : id
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
function readEntries(treeMap: Y.Map<any>): TreeEntry[] {
|
|
11
21
|
const entries: TreeEntry[] = []
|
|
12
22
|
treeMap.forEach((value: any, id: string) => {
|
|
@@ -32,7 +42,10 @@ function childrenOf(entries: TreeEntry[], parentId: string | null): TreeEntry[]
|
|
|
32
42
|
|
|
33
43
|
function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
|
|
34
44
|
const result: TreeEntry[] = []
|
|
45
|
+
const visited = new Set<string>()
|
|
35
46
|
function collect(pid: string) {
|
|
47
|
+
if (visited.has(pid)) return
|
|
48
|
+
visited.add(pid)
|
|
36
49
|
for (const child of childrenOf(entries, pid)) {
|
|
37
50
|
result.push(child)
|
|
38
51
|
collect(child.id)
|
|
@@ -42,29 +55,33 @@ function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
|
|
|
42
55
|
return result
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0): any[] {
|
|
58
|
+
function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0, visited = new Set<string>()): any[] {
|
|
46
59
|
if (maxDepth >= 0 && currentDepth >= maxDepth) return []
|
|
47
60
|
const children = childrenOf(entries, rootId)
|
|
48
|
-
return children.map(entry =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
return children.filter(e => !visited.has(e.id)).map(entry => {
|
|
62
|
+
const next = new Set(visited)
|
|
63
|
+
next.add(entry.id)
|
|
64
|
+
return {
|
|
65
|
+
id: entry.id,
|
|
66
|
+
label: entry.label,
|
|
67
|
+
type: entry.type,
|
|
68
|
+
meta: entry.meta,
|
|
69
|
+
order: entry.order,
|
|
70
|
+
children: buildTree(entries, entry.id, maxDepth, currentDepth + 1, next),
|
|
71
|
+
}
|
|
72
|
+
})
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer) {
|
|
59
76
|
mcp.tool(
|
|
60
77
|
'list_documents',
|
|
61
|
-
'List direct children of a document (defaults to root). Returns id, label, type, meta, order.',
|
|
78
|
+
'List direct children of a document (defaults to root). Returns id, label, type, meta, order. NOTE: Only returns ONE level. Use find_document to search by name across the full tree, or get_document_tree to see the complete hierarchy.',
|
|
62
79
|
{ parentId: z.string().optional().describe('Parent document ID. Omit for root-level documents.') },
|
|
63
80
|
async ({ parentId }) => {
|
|
64
81
|
const treeMap = server.getTreeMap()
|
|
65
82
|
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
66
83
|
|
|
67
|
-
const targetId = parentId
|
|
84
|
+
const targetId = normalizeRootId(parentId, server)
|
|
68
85
|
const entries = readEntries(treeMap)
|
|
69
86
|
const children = childrenOf(entries, targetId)
|
|
70
87
|
|
|
@@ -79,7 +96,7 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
79
96
|
|
|
80
97
|
mcp.tool(
|
|
81
98
|
'get_document_tree',
|
|
82
|
-
'Get the full document tree as nested JSON.
|
|
99
|
+
'Get the full document tree as nested JSON. Call this FIRST when exploring a space or looking for documents — it shows the complete hierarchy including all nested documents. Use rootId to scope to a subtree.',
|
|
83
100
|
{
|
|
84
101
|
rootId: z.string().optional().describe('Root document ID to start from. Omit for the entire tree.'),
|
|
85
102
|
depth: z.number().optional().describe('Maximum depth to traverse. Default 3. Use -1 for unlimited.'),
|
|
@@ -88,7 +105,7 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
88
105
|
const treeMap = server.getTreeMap()
|
|
89
106
|
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
90
107
|
|
|
91
|
-
const targetId = rootId
|
|
108
|
+
const targetId = normalizeRootId(rootId, server)
|
|
92
109
|
const maxDepth = depth ?? 3
|
|
93
110
|
const entries = readEntries(treeMap)
|
|
94
111
|
const tree = buildTree(entries, targetId, maxDepth)
|
|
@@ -102,13 +119,70 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
102
119
|
}
|
|
103
120
|
)
|
|
104
121
|
|
|
122
|
+
mcp.tool(
|
|
123
|
+
'find_document',
|
|
124
|
+
'Search for documents by name across the entire tree. Returns matching documents with their full ancestor path. Use this when you\'re looking for a specific document and don\'t know where it is in the hierarchy.',
|
|
125
|
+
{
|
|
126
|
+
query: z.string().describe('Search query — matched case-insensitively against document labels (substring match).'),
|
|
127
|
+
rootId: z.string().optional().describe('Restrict search to descendants of this document. Omit to search the entire tree.'),
|
|
128
|
+
},
|
|
129
|
+
async ({ query, rootId }) => {
|
|
130
|
+
const treeMap = server.getTreeMap()
|
|
131
|
+
if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
132
|
+
|
|
133
|
+
const entries = readEntries(treeMap)
|
|
134
|
+
const lowerQuery = query.toLowerCase()
|
|
135
|
+
|
|
136
|
+
// If rootId specified, restrict to descendants only
|
|
137
|
+
const normalizedRoot = normalizeRootId(rootId, server)
|
|
138
|
+
const searchSet = normalizedRoot ? descendantsOf(entries, normalizedRoot) : entries
|
|
139
|
+
|
|
140
|
+
// Find matches
|
|
141
|
+
const matches = searchSet.filter(e =>
|
|
142
|
+
e.label.toLowerCase().includes(lowerQuery)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
// Build ancestor path for each match
|
|
146
|
+
const byId = new Map(entries.map(e => [e.id, e]))
|
|
147
|
+
const results = matches.map(entry => {
|
|
148
|
+
const path: string[] = []
|
|
149
|
+
let current = entry.parentId
|
|
150
|
+
const visited = new Set<string>()
|
|
151
|
+
while (current && !visited.has(current)) {
|
|
152
|
+
visited.add(current)
|
|
153
|
+
const parent = byId.get(current)
|
|
154
|
+
if (!parent) break
|
|
155
|
+
path.unshift(parent.label)
|
|
156
|
+
current = parent.parentId
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
id: entry.id,
|
|
160
|
+
label: entry.label,
|
|
161
|
+
type: entry.type,
|
|
162
|
+
meta: entry.meta,
|
|
163
|
+
path,
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
if (results.length === 0) {
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: 'text', text: `No documents found matching "${query}". Try get_document_tree to see the full hierarchy.` }],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
105
179
|
mcp.tool(
|
|
106
180
|
'create_document',
|
|
107
181
|
'Create a new document in the tree. Returns the new document ID.',
|
|
108
182
|
{
|
|
109
183
|
parentId: z.string().optional().describe('Parent document ID. Omit for top-level pages. Use a document ID for nested/child pages.'),
|
|
110
184
|
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", "
|
|
185
|
+
type: z.string().optional().describe('Page type: "doc", "kanban", "calendar", "table", "outline", "gallery", "slides", "timeline", "whiteboard", "map", "dashboard", "mindmap", "graph". Omit to inherit parent view.'),
|
|
112
186
|
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
187
|
},
|
|
114
188
|
async ({ parentId, label, type, meta }) => {
|
|
@@ -117,11 +191,12 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
117
191
|
if (!treeMap || !rootDoc) return { content: [{ type: 'text', text: 'Not connected' }] }
|
|
118
192
|
|
|
119
193
|
const id = crypto.randomUUID()
|
|
194
|
+
const normalizedParent = normalizeRootId(parentId, server)
|
|
120
195
|
const now = Date.now()
|
|
121
196
|
rootDoc.transact(() => {
|
|
122
197
|
treeMap.set(id, {
|
|
123
198
|
label,
|
|
124
|
-
parentId:
|
|
199
|
+
parentId: normalizedParent,
|
|
125
200
|
order: now,
|
|
126
201
|
type,
|
|
127
202
|
meta: meta as PageMeta,
|
|
@@ -175,7 +250,7 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
175
250
|
|
|
176
251
|
treeMap.set(id, {
|
|
177
252
|
...entry,
|
|
178
|
-
parentId: newParentId
|
|
253
|
+
parentId: normalizeRootId(newParentId, server),
|
|
179
254
|
order: order ?? Date.now(),
|
|
180
255
|
updatedAt: Date.now(),
|
|
181
256
|
})
|
|
@@ -224,7 +299,7 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
224
299
|
'Change the page type view of a document (data is preserved).',
|
|
225
300
|
{
|
|
226
301
|
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", "
|
|
302
|
+
type: z.string().describe('New page type (e.g. "doc", "kanban", "table", "calendar", "outline", "gallery", "slides", "timeline", "whiteboard", "map", "dashboard", "mindmap", "graph").'),
|
|
228
303
|
},
|
|
229
304
|
async ({ id, type }) => {
|
|
230
305
|
const treeMap = server.getTreeMap()
|