@abraca/mcp 1.8.1 → 2.3.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/server.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * AbracadabraMCPServer — manages connection lifecycle, provider cache, and cleanup.
3
3
  *
4
- * Space-aware: on connect, discovers spaces via the spaces extension.
5
- * If available, defaults to the hub space; otherwise falls back to index_doc_id.
6
- * Use switchSpace(docId) to change the active space.
4
+ * Space-aware: on connect, discovers Spaces (top-level docs with kind="space")
5
+ * by listing children of the server root. The first Space becomes the active
6
+ * one; use switchSpace(docId) to change.
7
7
  */
8
8
  import * as Y from 'yjs'
9
- import { AbracadabraProvider, AbracadabraClient } from '@abraca/dabra'
10
- import type { ServerInfo, SpaceMeta, DocumentMeta } from '@abraca/dabra'
9
+ import { AbracadabraProvider, AbracadabraClient, Kind, SERVER_ROOT_ID } from '@abraca/dabra'
10
+ import type { ServerInfo, DocumentMeta } 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'
@@ -52,7 +52,7 @@ export class AbracadabraMCPServer {
52
52
  readonly client: AbracadabraClient
53
53
  private _serverInfo: ServerInfo | null = null
54
54
  private _rootDocId: string | null = null
55
- private _spaces: SpaceMeta[] = []
55
+ private _spaces: DocumentMeta[] = []
56
56
  private _activeConnection: SpaceConnection | null = null
57
57
  private _spaceConnections = new Map<string, SpaceConnection>()
58
58
  private childCache = new Map<string, CachedProvider>()
@@ -103,7 +103,11 @@ export class AbracadabraMCPServer {
103
103
  return this._rootDocId
104
104
  }
105
105
 
106
- get spaces(): SpaceMeta[] {
106
+ /**
107
+ * Spaces visible to the caller — direct children of the server root with
108
+ * `kind === "space"`. Populated by {@link connect}.
109
+ */
110
+ get spaces(): DocumentMeta[] {
107
111
  return this._spaces
108
112
  }
109
113
 
@@ -131,9 +135,15 @@ export class AbracadabraMCPServer {
131
135
  try {
132
136
  await this.client.loginWithKey(keypair.publicKeyB64, signFn)
133
137
  } catch (err: any) {
134
- // Key not registered — auto-register and retry
138
+ // Key not registered — auto-register and retry.
139
+ // Servers signal this with 404/422, or 401 + "public key not registered" / "user not found".
135
140
  const status = err?.status ?? err?.response?.status
136
- if (status === 404 || status === 422) {
141
+ const msg = String(err?.message ?? '').toLowerCase()
142
+ const notRegistered =
143
+ status === 404 ||
144
+ status === 422 ||
145
+ (status === 401 && /not registered|user not found|no such user/.test(msg))
146
+ if (notRegistered) {
137
147
  console.error('[abracadabra-mcp] Key not registered, creating new account...')
138
148
  await this.client.registerWithKey({
139
149
  publicKey: keypair.publicKeyB64,
@@ -152,39 +162,23 @@ export class AbracadabraMCPServer {
152
162
  // Step 3: Discover server info
153
163
  this._serverInfo = await this.client.serverInfo()
154
164
 
155
- // Step 4: Discover root documents (try new endpoint first, fall back to /spaces)
156
- let initialDocId: string | null = this._serverInfo.index_doc_id ?? null
157
- try {
158
- const roots = await this.client.listRootDocuments()
159
- // Map to SpaceMeta shape for backwards compat with list_spaces tool
160
- this._spaces = roots.map(docToSpaceMeta)
161
- const hub = roots.find(d => d.is_hub)
162
- if (hub) {
163
- initialDocId = hub.id
164
- console.error(`[abracadabra-mcp] Root documents discovered. Hub: ${hub.label ?? hub.id} (${hub.id})`)
165
- } else if (roots.length > 0) {
166
- initialDocId = roots[0].id
167
- console.error(`[abracadabra-mcp] No hub, using first root doc: ${roots[0].label ?? roots[0].id} (${roots[0].id})`)
168
- }
169
- } catch {
170
- // Fall back to legacy /spaces endpoint for older servers
171
- try {
172
- this._spaces = await this.client.listSpaces()
173
- const hub = this._spaces.find(s => s.is_hub)
174
- if (hub) {
175
- initialDocId = hub.doc_id
176
- console.error(`[abracadabra-mcp] Spaces extension active. Hub space: ${hub.name} (${hub.doc_id})`)
177
- } else if (this._spaces.length > 0) {
178
- initialDocId = this._spaces[0].doc_id
179
- console.error(`[abracadabra-mcp] Spaces active but no hub, using first space: ${this._spaces[0].name} (${this._spaces[0].doc_id})`)
180
- }
181
- } catch {
182
- console.error('[abracadabra-mcp] Neither /docs?root=true nor /spaces available, using index_doc_id')
183
- }
165
+ // Step 4: Discover Spaces top-level docs (children of the server root)
166
+ // tagged with kind="space". The first Space is the default landing doc;
167
+ // any other top-level doc serves as a fallback if no Spaces exist.
168
+ const roots = await this.client.listChildren()
169
+ this._spaces = roots.filter(d => d.kind === Kind.Space)
170
+ const first = this._spaces[0] ?? roots[0]
171
+ const initialDocId = first?.id ?? null
172
+ if (first) {
173
+ console.error(
174
+ `[abracadabra-mcp] Active space: ${first.label ?? first.id} (${first.id})`,
175
+ )
184
176
  }
185
177
 
186
178
  if (!initialDocId) {
187
- throw new Error('No entry point found: server has neither spaces nor index_doc_id configured.')
179
+ throw new Error(
180
+ `No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
181
+ )
188
182
  }
189
183
 
190
184
  this._rootDocId = initialDocId
@@ -465,77 +459,65 @@ export class AbracadabraMCPServer {
465
459
  }
466
460
  }
467
461
 
468
- /** Handle incoming stateless chat messages and dispatch as channel notifications. */
462
+ /** Handle incoming `messages:new_message` broadcasts and dispatch as channel notifications. */
469
463
  private async _handleStatelessChat(payload: string): Promise<void> {
470
464
  if (!this._serverRef) return
471
- if (!payload.includes('"chat:message"')) return
465
+ if (!payload.includes('"messages:new_message"')) return
472
466
 
473
467
  try {
474
- const data = JSON.parse(payload)
475
- if (data.type !== 'chat:message') return
468
+ const env = JSON.parse(payload)
469
+ if (env.type !== 'messages:new_message') return
470
+ const data = env.record
471
+ if (!data) return
472
+ // Only react to actual messages — skip edit/tombstone records.
473
+ if (data.record_kind && data.record_kind !== 'message') return
476
474
  // Skip own messages
477
475
  if (data.sender_id && data.sender_id === this._userId) return
478
476
 
479
- const channel = data.channel as string | undefined
480
- const docId = channel?.startsWith('group:') ? channel.slice(6) : ''
481
- const isDM = channel?.startsWith('dm:') ?? false
482
- const isGroup = channel?.startsWith('group:') ?? false
483
-
484
- // Only process DMs where the agent is a participant
485
- if (isDM) {
486
- const parts = channel!.split(':')
487
- if (parts.length === 3 && parts[1] !== this._userId && parts[2] !== this._userId) {
488
- console.error(
489
- `[abracadabra-mcp] Dropping DM: agent _userId=${this._userId} not in channel parts=[${parts[1]}, ${parts[2]}] — channel='${channel}'. ` +
490
- `The dashboard's awareness likely points at a stale Claude identity.`,
491
- )
492
- return
493
- }
494
- }
477
+ const channelDocId = data.channel_doc_id as string | undefined
478
+ if (!channelDocId) return
479
+
480
+ // Look up the channel kind (channel vs dm) via the doc tree, since the
481
+ // new model has no synthetic channel string. Caller-side filtering of
482
+ // DMs (only dispatch when the agent is a participant) relies on the
483
+ // permissions on the dm doc — server enforces; nothing to check here.
484
+ const isDM = false // Without a kind lookup, treat all as group for trigger semantics.
485
+ const isGroup = !isDM
495
486
 
496
487
  // ── Trigger mode gate ─────────────────────────────────────────────
497
- // DMs always dispatch (implicit summon). Groups obey the trigger mode.
498
488
  const mode = this.triggerMode
499
489
  const content = typeof data.content === 'string' ? data.content : ''
500
490
  let dispatchContent = content
501
491
 
502
492
  if (isGroup) {
503
493
  if (mode === 'task') {
504
- // Chat never triggers in task-only mode
505
494
  return
506
495
  }
507
496
  if (mode === 'mention' || mode === 'mention+task') {
508
497
  const aliases = this.mentionAliases
509
498
  if (!containsMention(content, aliases)) {
510
- console.error(`[abracadabra-mcp] skipped message on ${channel} — no @mention for ${aliases.join('|')}`)
499
+ console.error(`[abracadabra-mcp] skipped message on ${channelDocId} — no @mention for ${aliases.join('|')}`)
511
500
  return
512
501
  }
513
- // Strip the mention so Claude sees a clean prompt
514
502
  dispatchContent = stripMention(content, aliases) || content
515
503
  }
516
504
  // mode === 'all' falls through unchanged
517
505
  }
518
506
  // ──────────────────────────────────────────────────────────────────
519
507
 
520
- // Auto-mark channel as read so sender sees a read receipt
521
- if (channel) {
522
- const rootProvider = this._activeConnection?.provider
523
- if (rootProvider) {
524
- rootProvider.sendStateless(JSON.stringify({
525
- type: 'chat:mark_read',
526
- channel,
527
- timestamp: Math.floor(Date.now() / 1000),
528
- }))
529
- }
530
- // Remember the channel so setAutoStatus can scope statusContext to it.
531
- // NOTE: no typing indicator here — "thinking" status + tool pills are
532
- // the correct signal while Claude is working; typing is reserved for
533
- // the actual send_chat_message burst below.
534
- this._lastChatChannel = channel
508
+ // Auto-mark this message position read.
509
+ const rootProvider = this._activeConnection?.provider
510
+ if (rootProvider && data.id && data.period_id) {
511
+ rootProvider.sendStateless(JSON.stringify({
512
+ type: 'messages:mark_read',
513
+ channel_doc_id: channelDocId,
514
+ period_id: data.period_id,
515
+ message_id: data.id,
516
+ ts: Date.now(),
517
+ }))
535
518
  }
519
+ this._lastChatChannel = channelDocId
536
520
 
537
- // Mint a fresh turn id so the dashboard ties its incantation + tool
538
- // trace to THIS turn, and set status to "thinking".
539
521
  this._beginTurn()
540
522
  this.setAutoStatus('thinking')
541
523
 
@@ -543,18 +525,18 @@ export class AbracadabraMCPServer {
543
525
  method: 'notifications/claude/channel',
544
526
  params: {
545
527
  content: dispatchContent,
546
- instructions: `You MUST use send_chat_message with channel="${channel ?? ''}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.`,
528
+ instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.`,
547
529
  meta: {
548
530
  source: 'abracadabra',
549
531
  type: 'chat_message',
550
- channel: channel ?? '',
532
+ channel_doc_id: channelDocId,
551
533
  sender: data.sender_name ?? 'Unknown',
552
534
  sender_id: data.sender_id ?? '',
553
- doc_id: docId,
535
+ doc_id: channelDocId,
554
536
  },
555
537
  },
556
538
  })
557
- console.error(`[abracadabra-mcp] Chat message from ${data.sender_name ?? 'unknown'} on ${channel}`)
539
+ console.error(`[abracadabra-mcp] Chat message from ${data.sender_name ?? 'unknown'} on ${channelDocId}`)
558
540
  } catch {
559
541
  // Ignore non-chat or malformed payloads
560
542
  }
@@ -669,15 +651,20 @@ export class AbracadabraMCPServer {
669
651
  }
670
652
 
671
653
  /**
672
- * Send a typing indicator to a chat channel.
654
+ * Send a typing indicator to a chat channel. Pass the channel doc id
655
+ * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
673
656
  */
674
657
  sendTypingIndicator(channel: string): void {
675
658
  const rootProvider = this._activeConnection?.provider
676
659
  if (!rootProvider) return
660
+ const channelDocId = channel.startsWith('group:')
661
+ ? channel.slice(6)
662
+ : channel.startsWith('dm:')
663
+ ? channel
664
+ : channel
677
665
  rootProvider.sendStateless(JSON.stringify({
678
- type: 'chat:typing',
679
- channel,
680
- sender_name: this.agentName,
666
+ type: 'messages:typing',
667
+ channel_doc_id: channelDocId,
681
668
  }))
682
669
  }
683
670
 
@@ -714,23 +701,3 @@ export class AbracadabraMCPServer {
714
701
  console.error('[abracadabra-mcp] Shutdown complete')
715
702
  }
716
703
  }
717
-
718
- /** Map a DocumentMeta (from /docs?root=true) to the SpaceMeta shape for compat. */
719
- function docToSpaceMeta(doc: DocumentMeta): SpaceMeta {
720
- const publicAccess = doc.public_access
721
- let visibility: SpaceMeta['visibility'] = 'private'
722
- if (publicAccess && publicAccess !== 'none') visibility = 'public'
723
-
724
- return {
725
- id: doc.id,
726
- doc_id: doc.id,
727
- name: doc.label ?? doc.id,
728
- description: doc.description ?? null,
729
- visibility,
730
- is_hub: doc.is_hub ?? false,
731
- owner_id: doc.owner_id ?? null,
732
- created_at: 0,
733
- updated_at: doc.updated_at ?? 0,
734
- public_access: publicAccess ?? null,
735
- }
736
- }
@@ -65,12 +65,12 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
65
65
 
66
66
  mcp.tool(
67
67
  'send_chat_message',
68
- 'Send a chat message visible in the dashboard chat UI. For document group chat use channel "group:<docId>". For DM use "dm:<key1>:<key2>" (keys sorted alphabetically). Use list_connected_users to find user publicKeys for DM channels.',
68
+ 'Send a chat message visible in the dashboard chat UI. Pass channel_doc_id the UUID of a doc with kind "channel" (group chat under a Space) or "dm" (direct message). Use list_connected_users + the doc tree to discover channels.',
69
69
  {
70
- channel: z.string().describe('Channel ID, e.g. "group:<docId>" or "dm:<key1>:<key2>"'),
70
+ channel_doc_id: z.string().describe('Channel doc UUID (kind = "channel" or "dm")'),
71
71
  text: z.string().describe('Message text'),
72
72
  },
73
- async ({ channel, text }) => {
73
+ async ({ channel_doc_id, text }) => {
74
74
  try {
75
75
  const rootProvider = server.rootYProvider
76
76
  if (!rootProvider) {
@@ -90,18 +90,18 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
90
90
  // Order matters: clear status + tool pill FIRST so the dashboard's
91
91
  // typing-indicator filter (which hides typing while an activeToolCall
92
92
  // exists) doesn't swallow the burst. Then emit the typing frame, then
93
- // the actual chat:send. The clear also flushes toolHistory + turnId.
93
+ // the actual messages:send. The clear also flushes toolHistory + turnId.
94
94
  server.setAutoStatus(null)
95
- server.sendTypingIndicator(channel)
95
+ server.sendTypingIndicator(channel_doc_id)
96
96
 
97
97
  rootProvider.sendStateless(JSON.stringify({
98
- type: 'chat:send',
99
- channel,
98
+ type: 'messages:send',
99
+ channel_doc_id,
100
100
  content: normalized,
101
- sender_name: server.agentName,
101
+ mentions: [],
102
102
  }))
103
103
 
104
- return { content: [{ type: 'text' as const, text: `Sent to ${channel}` }] }
104
+ return { content: [{ type: 'text' as const, text: `Sent to ${channel_doc_id}` }] }
105
105
  } catch (error: any) {
106
106
  return {
107
107
  content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
package/src/tools/meta.ts CHANGED
@@ -4,8 +4,13 @@
4
4
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { z } from 'zod'
6
6
  import type { AbracadabraMCPServer } from '../server.ts'
7
+ import type { SchemaBundleValidator } from '../schema/validator.ts'
7
8
 
8
- export function registerMetaTools(mcp: McpServer, server: AbracadabraMCPServer) {
9
+ export function registerMetaTools(
10
+ mcp: McpServer,
11
+ server: AbracadabraMCPServer,
12
+ validator: SchemaBundleValidator | null = null,
13
+ ) {
9
14
  mcp.tool(
10
15
  'get_metadata',
11
16
  'Read the metadata (PageMeta) of a document from the tree.',
@@ -59,9 +64,25 @@ export function registerMetaTools(mcp: McpServer, server: AbracadabraMCPServer)
59
64
  return { content: [{ type: 'text', text: `Document ${docId} not found` }] }
60
65
  }
61
66
 
67
+ const mergedMeta = { ...(entry.meta ?? {}), ...meta }
68
+
69
+ if (validator && entry.type) {
70
+ const result = validator.validateMeta(entry.type, mergedMeta)
71
+ if (!result.ok) {
72
+ const detail = result.errors.map((e) => `${e.path} ${e.message}`).join('; ')
73
+ return {
74
+ content: [{
75
+ type: 'text',
76
+ text: `Metadata validation failed for type "${entry.type}": ${detail}`,
77
+ }],
78
+ isError: true,
79
+ }
80
+ }
81
+ }
82
+
62
83
  treeMap.set(docId, {
63
84
  ...entry,
64
- meta: { ...(entry.meta ?? {}), ...meta },
85
+ meta: mergedMeta,
65
86
  updatedAt: Date.now(),
66
87
  })
67
88
 
package/src/tools/tree.ts CHANGED
@@ -7,6 +7,7 @@ import { z } from 'zod'
7
7
  import type { AbracadabraMCPServer } from '../server.ts'
8
8
  import type { TreeEntry, PageMeta } from '../converters/types.ts'
9
9
  import { PAGE_TYPES, TYPE_ALIASES, resolvePageType } from '../converters/page-types.ts'
10
+ import type { SchemaBundleValidator } from '../schema/validator.ts'
10
11
 
11
12
  /**
12
13
  * Normalize a document ID so the hub/root doc ID is treated as the tree root (null).
@@ -80,7 +81,11 @@ function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number
80
81
  })
81
82
  }
82
83
 
83
- export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer) {
84
+ export function registerTreeTools(
85
+ mcp: McpServer,
86
+ server: AbracadabraMCPServer,
87
+ validator: SchemaBundleValidator | null = null,
88
+ ) {
84
89
  mcp.tool(
85
90
  'list_documents',
86
91
  '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.',
@@ -196,6 +201,52 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
196
201
  }
197
202
  )
198
203
 
204
+ mcp.tool(
205
+ 'query_documents',
206
+ 'Find documents by structural predicate across every space the user can read (POST /docs/query, v1). v1 filters on type (kanban, doc, calendar, etc.), parent doc, and label substring. Meta-field predicates (priority >= 3, dateStart in range) arrive in v2. Use this instead of find_document when you want to scope by page-type rather than label, or list every doc of a given kind across the server.',
207
+ {
208
+ type: z.string().optional().describe('Match documents.kind exactly. Examples: "kanban", "doc", "calendar", "table", "gallery". Omit to range across kinds.'),
209
+ parentId: z.string().optional().describe('Narrow to direct children of this doc.'),
210
+ labelContains: z.string().optional().describe('Case-insensitive substring match on the document label.'),
211
+ limit: z.number().optional().describe('Maximum results returned. Default 50; hard cap 500 server-side.'),
212
+ },
213
+ async ({ type, parentId, labelContains, limit }) => {
214
+ server.setAutoStatus('searching')
215
+ server.setActiveToolCall({ name: 'query_documents', target: type ?? labelContains ?? parentId ?? 'query' })
216
+
217
+ if (type == null && parentId == null && labelContains == null) {
218
+ return {
219
+ content: [{
220
+ type: 'text',
221
+ text: 'At least one of `type`, `parentId`, or `labelContains` is required — the server refuses to scan the full document set. (Use list_documents for root-level listing, or find_document for full-tree label search.)',
222
+ }],
223
+ }
224
+ }
225
+
226
+ try {
227
+ const docs = await server.client.queryDocs({ type, parentId, labelContains, limit })
228
+ if (docs.length === 0) {
229
+ return {
230
+ content: [{
231
+ type: 'text',
232
+ text: `No documents matched (type=${type ?? '*'}, parentId=${parentId ?? '*'}, labelContains=${labelContains ?? '*'}). Try widening the predicate.`,
233
+ }],
234
+ }
235
+ }
236
+ return {
237
+ content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }],
238
+ }
239
+ } catch (err) {
240
+ return {
241
+ content: [{
242
+ type: 'text',
243
+ text: `query_documents failed: ${err instanceof Error ? err.message : String(err)}`,
244
+ }],
245
+ }
246
+ }
247
+ }
248
+ )
249
+
199
250
  mcp.tool(
200
251
  'create_document',
201
252
  'Create a new document in the tree. Returns the new document ID.',
@@ -215,6 +266,20 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
215
266
  return { content: [{ type: 'text', text: 'Not connected' }] }
216
267
  }
217
268
 
269
+ if (validator && type && meta) {
270
+ const result = validator.validateMeta(type, meta)
271
+ if (!result.ok) {
272
+ const detail = result.errors.map((e) => `${e.path} ${e.message}`).join('; ')
273
+ return {
274
+ content: [{
275
+ type: 'text',
276
+ text: `Metadata validation failed for type "${type}": ${detail}`,
277
+ }],
278
+ isError: true,
279
+ }
280
+ }
281
+ }
282
+
218
283
  const id = crypto.randomUUID()
219
284
  const normalizedParent = normalizeRootId(parentId, server)
220
285
  const now = Date.now()
@@ -368,27 +433,27 @@ export function registerTreeTools(mcp: McpServer, server: AbracadabraMCPServer)
368
433
 
369
434
  mcp.tool(
370
435
  'list_spaces',
371
- 'List all spaces available on the server. Returns id, name, doc_id, is_hub, and visibility. Use switch_space to navigate to a space.',
436
+ '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.',
372
437
  {},
373
438
  async () => {
374
439
  const spaces = server.spaces
375
440
  if (!spaces.length) {
376
- return { content: [{ type: 'text', text: 'No spaces available (spaces extension not loaded on this server)' }] }
441
+ return { content: [{ type: 'text', text: 'No spaces available create one to get started.' }] }
377
442
  }
378
443
  const active = server.rootDocId
379
- const annotated = spaces.map(s => ({ ...s, active: s.doc_id === active }))
444
+ const annotated = spaces.map(s => ({ ...s, active: s.id === active }))
380
445
  return { content: [{ type: 'text', text: JSON.stringify(annotated, null, 2) }] }
381
446
  }
382
447
  )
383
448
 
384
449
  mcp.tool(
385
450
  'switch_space',
386
- 'Switch the active space. All subsequent tree operations will target the new space. Use list_spaces to discover available doc_ids.',
387
- { docId: z.string().describe('The doc_id of the space to switch to (from list_spaces).') },
451
+ 'Switch the active Space. All subsequent tree operations will target the new Space. Use list_spaces to discover available ids.',
452
+ { docId: z.string().describe('The id of the Space to switch to (from list_spaces).') },
388
453
  async ({ docId }) => {
389
454
  await server.switchSpace(docId)
390
- const space = server.spaces.find(s => s.doc_id === docId)
391
- const name = space?.name ?? docId
455
+ const space = server.spaces.find(s => s.id === docId)
456
+ const name = space?.label ?? docId
392
457
  return { content: [{ type: 'text', text: `Switched to space "${name}" (${docId})` }] }
393
458
  }
394
459
  )