@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/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: login, discover spaces/root doc, connect provider. */
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.2",
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 (required) — Server URL (e.g. http://localhost:1234)
7
- * ABRA_USERNAME (required) Service account username
8
- * ABRA_PASSWORD (required) Service account password
9
- * ABRA_AGENT_NAME Display name (default: "AI Assistant")
10
- * ABRA_AGENT_COLOR HSL color for presence (default: "hsl(270, 80%, 60%)")
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 || !username || !password) {
31
- console.error('Missing required environment variables: ABRA_URL, ABRA_USERNAME, ABRA_PASSWORD')
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: `Events from the abracadabra channel arrive as <channel source="abracadabra" ...>. They may be:
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) with the appropriate context.`,
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: login, discover spaces/root doc, connect provider. */
92
+ /** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
92
93
  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}`)
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 1b: Get user profile for publicKey identity
99
+ // Step 2: Authenticate via challenge-response (register on first run)
101
100
  try {
102
- const me = await this.client.getMe()
103
- this._userId = me.publicKey ?? me.id
104
- console.error(`[abracadabra-mcp] User ID (pubkey): ${this._userId}`)
105
- } catch {
106
- console.error('[abracadabra-mcp] Could not fetch user profile, proceeding without userId')
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 2: Discover server info
121
+ // Step 3: Discover server info
110
122
  this._serverInfo = await this.client.serverInfo()
111
123
 
112
- // Step 3: Discover spaces (optional extension)
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 4: Connect to initial space
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 5: Start eviction timer
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
- 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
- }))
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 ?? null
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. Useful for understanding the document structure.',
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 ?? null
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", "desktop", "mindmap", "graph". Omit to inherit parent view.'),
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: parentId ?? null,
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 ?? null,
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", "desktop", "mindmap", "graph").'),
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()