@abraca/mcp 2.8.0 → 2.9.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/index.d.ts CHANGED
@@ -78,6 +78,31 @@ declare class AbracadabraMCPServer {
78
78
  * Clears the child provider cache (children belong to the previous space).
79
79
  */
80
80
  switchSpace(docId: string): Promise<void>;
81
+ /**
82
+ * Make the space that OWNS `targetId` the active connection before a tree
83
+ * read/write, so the op targets the right space's `doc-tree` map.
84
+ *
85
+ * Without this, every tree tool used `getTreeMap()` (the single active
86
+ * space) and treated `rootId`/`parentId` as a mere filter — so reading a
87
+ * non-active space returned stale/empty trees, and *writing* to one
88
+ * silently orphaned the entry into the active space's map (a real
89
+ * data-loss path: docs built "in Ideas" while Development was active
90
+ * landed in Development under a non-node parent and never reached Ideas).
91
+ *
92
+ * Resolution order:
93
+ * - falsy or already the active root → no-op.
94
+ * - a known Space id → connect/activate it (cheap; `_spaceConnections`
95
+ * caches the provider so re-activation is free).
96
+ * - present in the active space's tree → no-op (a normal child doc).
97
+ * - present in an already-connected *other* space's tree → activate that.
98
+ * - otherwise unresolved → returns `false`; callers decide (reads report
99
+ * not-found/empty, writes refuse rather than orphan).
100
+ *
101
+ * Switching uses {@link _connectToSpace} (not {@link switchSpace}) so the
102
+ * child-content provider cache survives — content docs are keyed by global
103
+ * guid, independent of which space is active.
104
+ */
105
+ ensureSpaceActive(targetId: string | null | undefined): Promise<boolean>;
81
106
  /** Get the root doc-tree Y.Map of the active space. */
82
107
  getTreeMap(): Y.Map<any> | null;
83
108
  /**
@@ -198,6 +223,17 @@ declare class AbracadabraMCPServer {
198
223
  target?: string;
199
224
  detail?: unknown;
200
225
  } | null): void;
226
+ /**
227
+ * Attach expandable `detail` (arguments + result summary) to the most recent
228
+ * tool-history entry for `toolName`, then re-broadcast `toolHistory`.
229
+ *
230
+ * Called by the central tool wrapper in index.ts *after* a tool resolves, so
231
+ * the dashboard's tool cards can expand to show what the tool was called with
232
+ * and what it returned — the same affordance built-in Claude tools (bash /
233
+ * read / edit) already get via the hook-bridge. Tools that never emitted a
234
+ * pill (no `setActiveToolCall`) have no entry to attach to, so this no-ops.
235
+ */
236
+ recordToolDetail(toolName: string, detail: unknown): void;
201
237
  /**
202
238
  * Send a typing indicator to a chat channel. Pass the channel doc id
203
239
  * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "y-protocols": "^1.0.6",
37
37
  "yjs": "^13.6.8",
38
38
  "zod": "^4.3.6",
39
- "@abraca/convert": "2.8.0"
39
+ "@abraca/convert": "2.9.0"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import { registerTreeResource } from "./resources/tree-resource.ts";
33
33
  import { loadSchemaBundle, SchemaBundleLoadError } from "./schema/loader.ts";
34
34
  import type { SchemaBundleValidator } from "./schema/validator.ts";
35
35
  import { AbracadabraMCPServer, type TriggerMode } from "./server.ts";
36
+ import { wrapToolsWithDetail } from "./tool-detail.ts";
36
37
  import { registerAwarenessTools } from "./tools/awareness.ts";
37
38
  import { registerChannelTools } from "./tools/channel.ts";
38
39
  import { registerContentTools } from "./tools/content.ts";
@@ -149,6 +150,15 @@ Read the resource at abracadabra://agent-guide for the complete guide covering p
149
150
  },
150
151
  );
151
152
 
153
+ // Wrap mcp.tool ONCE so every tool invocation records an expandable detail
154
+ // (the arguments it was called with + the text it returned) onto its
155
+ // tool-history entry. This is what lets the cou-sh chat cards expand to show
156
+ // "which doc / which tree / what it found" — the same way built-in Claude
157
+ // tools (bash / read / edit) already expand via the hook-bridge. The pill
158
+ // itself is still emitted by each tool's own setActiveToolCall; we only
159
+ // enrich it after the handler resolves. Must run before the register* calls.
160
+ wrapToolsWithDetail(mcp, server);
161
+
152
162
  // Register tools
153
163
  registerTreeTools(mcp, server, schemaValidator);
154
164
  registerContentTools(mcp, server);
package/src/server.ts CHANGED
@@ -311,6 +311,58 @@ export class AbracadabraMCPServer {
311
311
  console.error(`[abracadabra-mcp] Switched active space to ${docId}`);
312
312
  }
313
313
 
314
+ /**
315
+ * Make the space that OWNS `targetId` the active connection before a tree
316
+ * read/write, so the op targets the right space's `doc-tree` map.
317
+ *
318
+ * Without this, every tree tool used `getTreeMap()` (the single active
319
+ * space) and treated `rootId`/`parentId` as a mere filter — so reading a
320
+ * non-active space returned stale/empty trees, and *writing* to one
321
+ * silently orphaned the entry into the active space's map (a real
322
+ * data-loss path: docs built "in Ideas" while Development was active
323
+ * landed in Development under a non-node parent and never reached Ideas).
324
+ *
325
+ * Resolution order:
326
+ * - falsy or already the active root → no-op.
327
+ * - a known Space id → connect/activate it (cheap; `_spaceConnections`
328
+ * caches the provider so re-activation is free).
329
+ * - present in the active space's tree → no-op (a normal child doc).
330
+ * - present in an already-connected *other* space's tree → activate that.
331
+ * - otherwise unresolved → returns `false`; callers decide (reads report
332
+ * not-found/empty, writes refuse rather than orphan).
333
+ *
334
+ * Switching uses {@link _connectToSpace} (not {@link switchSpace}) so the
335
+ * child-content provider cache survives — content docs are keyed by global
336
+ * guid, independent of which space is active.
337
+ */
338
+ async ensureSpaceActive(
339
+ targetId: string | null | undefined,
340
+ ): Promise<boolean> {
341
+ if (!targetId || targetId === this._rootDocId) return true;
342
+
343
+ // A top-level Space id — activate it directly (handles the common
344
+ // `list_spaces` → operate-on-space-id flow that triggered the bug).
345
+ if (this._spaces.some((s) => s.id === targetId)) {
346
+ await this._connectToSpace(targetId);
347
+ return true;
348
+ }
349
+
350
+ // A normal doc already in the active space's tree — nothing to do.
351
+ if (this.getTreeMap()?.has(targetId)) return true;
352
+
353
+ // A doc in another space we've already connected to — activate it.
354
+ for (const [spaceId, conn] of this._spaceConnections) {
355
+ if (spaceId === this._rootDocId) continue;
356
+ if (conn.doc.getMap("doc-tree").has(targetId)) {
357
+ await this._connectToSpace(spaceId);
358
+ return true;
359
+ }
360
+ }
361
+
362
+ // Unresolved: not a space, not in any connected tree. Caller guards.
363
+ return false;
364
+ }
365
+
314
366
  /** Get the root doc-tree Y.Map of the active space. */
315
367
  getTreeMap(): Y.Map<any> | null {
316
368
  return this._activeConnection?.doc.getMap("doc-tree") ?? null;
@@ -1122,6 +1174,33 @@ export class AbracadabraMCPServer {
1122
1174
  }
1123
1175
  }
1124
1176
 
1177
+ /**
1178
+ * Attach expandable `detail` (arguments + result summary) to the most recent
1179
+ * tool-history entry for `toolName`, then re-broadcast `toolHistory`.
1180
+ *
1181
+ * Called by the central tool wrapper in index.ts *after* a tool resolves, so
1182
+ * the dashboard's tool cards can expand to show what the tool was called with
1183
+ * and what it returned — the same affordance built-in Claude tools (bash /
1184
+ * read / edit) already get via the hook-bridge. Tools that never emitted a
1185
+ * pill (no `setActiveToolCall`) have no entry to attach to, so this no-ops.
1186
+ */
1187
+ recordToolDetail(toolName: string, detail: unknown): void {
1188
+ const provider = this._activeConnection?.provider;
1189
+ if (!provider) return;
1190
+ // The matching pill was pushed at the start of this tool's handler, so the
1191
+ // last detail-less entry for this name is this invocation's entry.
1192
+ for (let i = this._toolHistory.length - 1; i >= 0; i--) {
1193
+ const h = this._toolHistory[i];
1194
+ if (h.tool === toolName && h.detail == null) {
1195
+ h.detail = detail;
1196
+ provider.awareness.setLocalStateField("toolHistory", [
1197
+ ...this._toolHistory,
1198
+ ]);
1199
+ return;
1200
+ }
1201
+ }
1202
+ }
1203
+
1125
1204
  /**
1126
1205
  * Send a typing indicator to a chat channel. Pass the channel doc id
1127
1206
  * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
@@ -0,0 +1,147 @@
1
+ /**
2
+ * tool-detail — central enrichment of abracadabra MCP tool cards.
3
+ *
4
+ * Built-in Claude tools (Bash/Read/Edit/…) flow through the hook-bridge, which
5
+ * attaches an expandable `detail` (command text, file path, diff) that the
6
+ * cou-sh chat renders as a click-to-expand block. abracadabra's own MCP tools
7
+ * are explicitly skipped by the hook-bridge and only ever emitted a bare
8
+ * name+target pill — so their cards had nothing to expand ("no idea what docs
9
+ * it sees or what the tree was").
10
+ *
11
+ * `wrapToolsWithDetail` patches `McpServer.tool` ONCE, before any tool is
12
+ * registered, so every tool invocation records an expandable detail onto its
13
+ * tool-history entry (via `server.recordToolDetail`). The detail leads with the
14
+ * human-readable title + id of any document the call referenced (resolved from
15
+ * the live tree), then the remaining arguments, then the text the tool returned
16
+ * (e.g. the full document tree for `get_document_tree`). The pill itself is
17
+ * still emitted by each tool's own `setActiveToolCall`; this only fills in the
18
+ * expandable detail afterwards.
19
+ */
20
+ import { toPlain } from "@abraca/dabra";
21
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import type { AbracadabraMCPServer } from "./server.ts";
23
+
24
+ /** Per-field cap (chars) — keeps the awareness payload bounded. Mirrors the
25
+ * hook-bridge's DETAIL_MAX so built-in and MCP tool cards behave alike. */
26
+ const DETAIL_MAX = 4000;
27
+
28
+ /** Argument keys that hold a document id, in the order we surface them. */
29
+ const DOC_ID_KEYS = [
30
+ "docId",
31
+ "id",
32
+ "rootId",
33
+ "parentId",
34
+ "newParentId",
35
+ "uploadId",
36
+ ] as const;
37
+
38
+ function cap(s: string): string {
39
+ return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… (truncated)` : s;
40
+ }
41
+
42
+ function safeJson(value: unknown): string {
43
+ try {
44
+ return JSON.stringify(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
49
+
50
+ /** Pull the joined text content out of a tool result, if any. */
51
+ function resultText(result: unknown): string {
52
+ const content = (result as { content?: unknown })?.content;
53
+ if (!Array.isArray(content)) return "";
54
+ return content
55
+ .filter((c) => c && (c as { type?: string }).type === "text")
56
+ .map((c) => (c as { text?: string }).text ?? "")
57
+ .join("\n")
58
+ .trim();
59
+ }
60
+
61
+ /**
62
+ * Build the expandable detail shown when a tool card is clicked:
63
+ * 1. one `id — "Title"` line per document the args referenced (title resolved
64
+ * from the live tree when available),
65
+ * 2. the remaining arguments as compact JSON,
66
+ * 3. the text the tool returned (document tree, content, confirmation, …).
67
+ * Rendered by cou-sh's ChatActionCard as a `kind: "text"` block.
68
+ */
69
+ export function buildToolDetail(
70
+ toolArgs: unknown,
71
+ result: unknown,
72
+ resolveLabel?: (id: string) => string | undefined,
73
+ ): { kind: "text"; text: string } {
74
+ const lines: string[] = [];
75
+ const rest: Record<string, unknown> =
76
+ toolArgs && typeof toolArgs === "object"
77
+ ? { ...(toolArgs as Record<string, unknown>) }
78
+ : {};
79
+
80
+ // Headline: each referenced doc as "id — title" (title when resolvable).
81
+ for (const key of DOC_ID_KEYS) {
82
+ const v = rest[key];
83
+ if (typeof v === "string" && v.length > 0) {
84
+ const label = resolveLabel?.(v);
85
+ lines.push(label ? `${key}: ${v} — "${label}"` : `${key}: ${v}`);
86
+ delete rest[key];
87
+ }
88
+ }
89
+
90
+ // Remaining arguments (depth, type, label, meta, content, query, …).
91
+ const restStr = Object.keys(rest).length > 0 ? safeJson(rest) : "";
92
+ if (restStr && restStr !== "{}") lines.push(`args: ${restStr}`);
93
+ if (lines.length === 0) lines.push("args: (no arguments)");
94
+
95
+ const text = resultText(result);
96
+ if (text) {
97
+ lines.push("");
98
+ lines.push(text);
99
+ }
100
+ return { kind: "text", text: cap(lines.join("\n")) };
101
+ }
102
+
103
+ /**
104
+ * Monkey-patch `mcp.tool` so each registered tool's handler is wrapped to record
105
+ * arguments + result detail after it resolves. Call once at startup, before the
106
+ * register* functions run. Errors in detail-recording are swallowed — they must
107
+ * never break the underlying tool.
108
+ */
109
+ export function wrapToolsWithDetail(
110
+ mcp: McpServer,
111
+ server: AbracadabraMCPServer,
112
+ ): void {
113
+ // Resolve a document id to its current label from the live tree, if loaded.
114
+ const resolveLabel = (id: string): string | undefined => {
115
+ try {
116
+ const raw = server.getTreeMap()?.get(id);
117
+ if (!raw) return undefined;
118
+ const label = (toPlain(raw) as { label?: unknown })?.label;
119
+ return typeof label === "string" && label.length > 0 ? label : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ };
124
+
125
+ const original = mcp.tool.bind(mcp) as (...args: unknown[]) => unknown;
126
+ (mcp as { tool: unknown }).tool = (...args: unknown[]) => {
127
+ const name = args[0];
128
+ const cbIndex = args.length - 1;
129
+ const cb = args[cbIndex];
130
+ if (typeof name === "string" && typeof cb === "function") {
131
+ const handler = cb as (...a: unknown[]) => unknown;
132
+ args[cbIndex] = async (...handlerArgs: unknown[]) => {
133
+ const result = await handler(...handlerArgs);
134
+ try {
135
+ server.recordToolDetail(
136
+ name,
137
+ buildToolDetail(handlerArgs[0], result, resolveLabel),
138
+ );
139
+ } catch {
140
+ /* detail recording must never break a tool */
141
+ }
142
+ return result;
143
+ };
144
+ }
145
+ return original(...args);
146
+ };
147
+ }
@@ -21,6 +21,7 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
21
21
  server.setAutoStatus('reading', docId)
22
22
  server.setActiveToolCall({ name: 'read_document', target: docId })
23
23
 
24
+ await server.ensureSpaceActive(docId)
24
25
  const provider = await server.getChildProvider(docId)
25
26
  const fragment = provider.document.getXmlFragment('default')
26
27
 
@@ -90,6 +91,7 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
90
91
  server.setAutoStatus('writing', docId)
91
92
  server.setActiveToolCall({ name: 'write_document', target: docId })
92
93
 
94
+ await server.ensureSpaceActive(docId)
93
95
  const writeMode = mode ?? 'replace'
94
96
  const provider = await server.getChildProvider(docId)
95
97
  const doc = provider.document
@@ -127,9 +129,21 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
127
129
  })
128
130
  }
129
131
 
130
- // Write new content
132
+ // Write new content.
133
+ //
134
+ // ROOT-CAUSE FIX for the "card title silently becomes Untitled" bug:
135
+ // when the markdown carries neither a frontmatter `title:` nor a
136
+ // leading `# H1`, populateYDocFromMarkdown seeds the body
137
+ // `documentHeader` with `fallbackTitle`. Passing the literal
138
+ // 'Untitled' baked the placeholder string into the header — which a
139
+ // client's title-sync (cou-sh) then read back and propagated into
140
+ // the tree `label`, destroying the real title. Seed from the doc's
141
+ // ACTUAL label instead so the header agrees with the tree from the
142
+ // start (header === label → title-sync is a no-op). Only fall back
143
+ // to 'Untitled' when the doc genuinely has no label yet.
131
144
  const contentToWrite = body || markdown
132
- const fallbackTitle = title || 'Untitled'
145
+ const existingLabel = server.getDocSummary(docId)?.label
146
+ const fallbackTitle = title || existingLabel || 'Untitled'
133
147
  populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle)
134
148
 
135
149
  // Auto-update presence: mark this doc as focused and place cursor at end
package/src/tools/meta.ts CHANGED
@@ -21,6 +21,7 @@ export function registerMetaTools(
21
21
  async ({ docId }) => {
22
22
  server.setAutoStatus('reading', docId)
23
23
  server.setActiveToolCall({ name: 'get_metadata', target: docId })
24
+ await server.ensureSpaceActive(docId)
24
25
  const treeMap = server.getTreeMap()
25
26
  if (!treeMap) {
26
27
  return { content: [{ type: 'text', text: 'Not connected' }] }
@@ -55,6 +56,7 @@ export function registerMetaTools(
55
56
  async ({ docId, meta }) => {
56
57
  server.setAutoStatus('writing', docId)
57
58
  server.setActiveToolCall({ name: 'update_metadata', target: docId })
59
+ await server.ensureSpaceActive(docId)
58
60
  const treeMap = server.getTreeMap()
59
61
  if (!treeMap) {
60
62
  return { content: [{ type: 'text', text: 'Not connected' }] }