@abraca/mcp 1.9.1 → 2.4.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 +12076 -12641
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +12068 -12635
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/package.json +12 -8
- package/src/converters/markdownToYjs.ts +13 -932
- package/src/converters/page-types.ts +10 -0
- package/src/converters/yjsToMarkdown.ts +12 -355
- package/src/crypto.ts +5 -4
- package/src/index.ts +29 -2
- package/src/resources/server-info.ts +1 -1
- package/src/schema/loader.ts +139 -0
- package/src/schema/validator.ts +31 -0
- package/src/server.ts +75 -108
- package/src/tools/channel.ts +9 -9
- package/src/tools/meta.ts +23 -2
- package/src/tools/tree.ts +73 -8
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
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
|
|
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('"
|
|
465
|
+
if (!payload.includes('"messages:new_message"')) return
|
|
472
466
|
|
|
473
467
|
try {
|
|
474
|
-
const
|
|
475
|
-
if (
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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 ${
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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
|
-
|
|
532
|
+
channel_doc_id: channelDocId,
|
|
551
533
|
sender: data.sender_name ?? 'Unknown',
|
|
552
534
|
sender_id: data.sender_id ?? '',
|
|
553
|
-
doc_id:
|
|
535
|
+
doc_id: channelDocId,
|
|
554
536
|
},
|
|
555
537
|
},
|
|
556
538
|
})
|
|
557
|
-
console.error(`[abracadabra-mcp] Chat message from ${data.sender_name ?? 'unknown'} on ${
|
|
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: '
|
|
679
|
-
|
|
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
|
-
}
|
package/src/tools/channel.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 ({
|
|
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
|
|
93
|
+
// the actual messages:send. The clear also flushes toolHistory + turnId.
|
|
94
94
|
server.setAutoStatus(null)
|
|
95
|
-
server.sendTypingIndicator(
|
|
95
|
+
server.sendTypingIndicator(channel_doc_id)
|
|
96
96
|
|
|
97
97
|
rootProvider.sendStateless(JSON.stringify({
|
|
98
|
-
type: '
|
|
99
|
-
|
|
98
|
+
type: 'messages:send',
|
|
99
|
+
channel_doc_id,
|
|
100
100
|
content: normalized,
|
|
101
|
-
|
|
101
|
+
mentions: [],
|
|
102
102
|
}))
|
|
103
103
|
|
|
104
|
-
return { content: [{ type: 'text' as const, text: `Sent to ${
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
387
|
-
{ docId: z.string().describe('The
|
|
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.
|
|
391
|
-
const name = space?.
|
|
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
|
)
|