@abraca/mcp 2.16.0 → 2.17.1

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
@@ -31,6 +31,7 @@ declare class AbracadabraMCPServer {
31
31
  private _spaceConnections;
32
32
  private childCache;
33
33
  private evictionTimer;
34
+ private heartbeatTimer;
34
35
  private _mcpServerRef;
35
36
  private _serverRef;
36
37
  private _handledTaskIds;
@@ -117,6 +118,15 @@ declare class AbracadabraMCPServer {
117
118
  * the live `connectionStatus` getter. */
118
119
  private _wsConnected;
119
120
  ensureConnected(): Promise<void>;
121
+ /**
122
+ * Bring one provider's socket back to a synced state WITHOUT swapping its
123
+ * Y.Doc. First let the SDK's own auto-reconnect finish if it's mid-flight;
124
+ * if the socket is still dead, force a reconnect on the same doc. Because the
125
+ * document is never replaced, every observer attached to it (root awareness,
126
+ * tree reads, the inbox `entries` array, the stateless chat listener) keeps
127
+ * firing — no re-attach needed. Best-effort: logs but never throws.
128
+ */
129
+ private _healProvider;
120
130
  ensureSpaceActive(targetId: string | null | undefined): Promise<boolean>;
121
131
  /** Get the root doc-tree Y.Map of the active space. */
122
132
  getTreeMap(): Y.Map<any> | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "2.16.0",
3
+ "version": "2.17.1",
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.16.0"
39
+ "@abraca/convert": "2.17.1"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
package/src/server.ts CHANGED
@@ -67,6 +67,7 @@ export class AbracadabraMCPServer {
67
67
  private _spaceConnections = new Map<string, SpaceConnection>();
68
68
  private childCache = new Map<string, CachedProvider>();
69
69
  private evictionTimer: ReturnType<typeof setInterval> | null = null;
70
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
70
71
  private _mcpServerRef: McpServer | null = null;
71
72
  private _serverRef: Server | null = null;
72
73
  private _handledTaskIds = new Set<string>();
@@ -243,6 +244,15 @@ export class AbracadabraMCPServer {
243
244
 
244
245
  // Step 6: Start eviction timer
245
246
  this.evictionTimer = setInterval(() => this.evictIdle(), 60_000);
247
+
248
+ // Step 7: Connection heartbeat. An idle MCP (no tool calls for a while)
249
+ // would otherwise let an expired JWT drop the socket with nothing to heal
250
+ // it — silently killing chat/awareness + inbox dispatch. `ensureConnected`
251
+ // is a no-op while healthy (just refreshes a near-expiry token) and
252
+ // reconnects on the SAME docs (space + inbox) if dead, so observers survive.
253
+ this.heartbeatTimer = setInterval(() => {
254
+ void this.ensureConnected();
255
+ }, 30_000);
246
256
  }
247
257
 
248
258
  /** Connect to a space's root doc and cache it. Sets it as the active connection. */
@@ -357,8 +367,10 @@ export class AbracadabraMCPServer {
357
367
  if (this._reconnecting) return this._reconnecting;
358
368
  this._reconnecting = (async () => {
359
369
  try {
360
- // 1) Refresh the JWT proactively so a reconnect doesn't retry with a
361
- // stale token (which the server rejects, stopping the WS retry loop).
370
+ // 1) Refresh the JWT proactively so any reconnect re-authenticates
371
+ // with a fresh token (a stale one is rejected, which stops the WS
372
+ // retry loop). The provider's `token` callback reads `client.token`,
373
+ // so refreshing it here is enough for the forced reconnect below.
362
374
  if (!this.client.isTokenValid() && this._signFn && this._userId) {
363
375
  try {
364
376
  await this.client.loginWithKey(this._userId, this._signFn);
@@ -367,47 +379,16 @@ export class AbracadabraMCPServer {
367
379
  }
368
380
  }
369
381
 
382
+ // 2) Heal the active space provider (root doc tree + chat/awareness).
370
383
  const conn = this._activeConnection;
371
- if (!conn) return; // cold start — connect() handles it
372
- if (this._wsConnected(conn.provider)) return;
373
-
374
- // 2) Socket is Connecting/Disconnectedgive the SDK's own reconnect a
375
- // bounded window to come back before we tear anything down.
376
- try {
377
- await waitForSync(conn.provider, 6000);
378
- } catch {
379
- /* fell through to rebuild */
380
- }
381
- // connectionStatus is a live getter — re-check via the helper (fresh
382
- // scope) since the WS may have come back during the wait.
383
- if (this._wsConnected(conn.provider)) return;
384
-
385
- // 3) Still dead → rebuild the active space from scratch. A fresh
386
- // provider re-auths and reconnects cleanly; cached child providers
387
- // rode the old (now-destroyed) socket, so drop them too.
388
- console.error(
389
- "[abracadabra-mcp] Active connection dead — rebuilding space provider…",
390
- );
391
- const docId = conn.docId;
392
- this._spaceConnections.delete(docId);
393
- try {
394
- conn.provider.destroy();
395
- } catch {
396
- /* already gone */
397
- }
398
- for (const [, cached] of this.childCache) {
399
- try {
400
- cached.provider.destroy();
401
- } catch {
402
- /* already gone */
403
- }
404
- }
405
- this.childCache.clear();
406
- try {
407
- await this._connectToSpace(docId);
408
- console.error("[abracadabra-mcp] Space provider rebuilt + synced");
409
- } catch (e) {
410
- console.error("[abracadabra-mcp] Connection rebuild failed:", e);
384
+ if (conn) await this._healProvider(conn.provider, "space");
385
+
386
+ // 3) Heal the dedicated inbox provider. It rides its OWN socket, so the
387
+ // space-provider heal above doesn't cover it a drop + expired JWT
388
+ // could otherwise permanently stop its retry and silently kill DM /
389
+ // mention dispatch. Same failure mode, same same-doc fix.
390
+ if (this._inboxProvider) {
391
+ await this._healProvider(this._inboxProvider, "inbox");
411
392
  }
412
393
  } finally {
413
394
  this._reconnecting = null;
@@ -416,6 +397,45 @@ export class AbracadabraMCPServer {
416
397
  return this._reconnecting;
417
398
  }
418
399
 
400
+ /**
401
+ * Bring one provider's socket back to a synced state WITHOUT swapping its
402
+ * Y.Doc. First let the SDK's own auto-reconnect finish if it's mid-flight;
403
+ * if the socket is still dead, force a reconnect on the same doc. Because the
404
+ * document is never replaced, every observer attached to it (root awareness,
405
+ * tree reads, the inbox `entries` array, the stateless chat listener) keeps
406
+ * firing — no re-attach needed. Best-effort: logs but never throws.
407
+ */
408
+ private async _healProvider(
409
+ provider: AbracadabraProvider,
410
+ label: string,
411
+ ): Promise<void> {
412
+ if (this._wsConnected(provider)) return;
413
+
414
+ // Give the SDK's own reconnect a bounded window before forcing one.
415
+ try {
416
+ await waitForSync(provider, 6000);
417
+ } catch {
418
+ /* fell through to forced reconnect */
419
+ }
420
+ // connectionStatus is a live getter — re-check via the helper (fresh
421
+ // scope) since the WS may have come back during the wait.
422
+ if (this._wsConnected(provider)) return;
423
+
424
+ console.error(
425
+ `[abracadabra-mcp] ${label} socket dead — forcing reconnect on the same doc…`,
426
+ );
427
+ provider.reconnect();
428
+ try {
429
+ await waitForSync(provider, 10000);
430
+ console.error(`[abracadabra-mcp] ${label} reconnected + re-synced`);
431
+ } catch (e) {
432
+ console.error(
433
+ `[abracadabra-mcp] ${label} reconnect did not re-sync in time:`,
434
+ e,
435
+ );
436
+ }
437
+ }
438
+
419
439
  async ensureSpaceActive(
420
440
  targetId: string | null | undefined,
421
441
  ): Promise<boolean> {
@@ -1328,6 +1348,10 @@ export class AbracadabraMCPServer {
1328
1348
  clearInterval(this.evictionTimer);
1329
1349
  this.evictionTimer = null;
1330
1350
  }
1351
+ if (this.heartbeatTimer) {
1352
+ clearInterval(this.heartbeatTimer);
1353
+ this.heartbeatTimer = null;
1354
+ }
1331
1355
 
1332
1356
  for (const dispose of this._inboxDisposers) {
1333
1357
  try {
@@ -7,7 +7,7 @@ import { z } from 'zod'
7
7
  import type { AbracadabraMCPServer } from '../server.ts'
8
8
  import { populateYDocFromMarkdown, parseFrontmatter } from '../converters/markdownToYjs.ts'
9
9
  import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
10
- import { toPlain, patchEntry } from '@abraca/dabra'
10
+ import { toPlain, patchEntry, reconcileDocCover } from '@abraca/dabra'
11
11
 
12
12
  export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServer) {
13
13
  mcp.tool(
@@ -146,6 +146,13 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
146
146
  const fallbackTitle = title || existingLabel || 'Untitled'
147
147
  populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle)
148
148
 
149
+ // Keep the persisted cover metadata in sync with the media blocks now
150
+ // in the body. Without this, removing an image via write_document left
151
+ // an orphaned cover on the kanban/gallery card (image gone from the
152
+ // body, coverUploadId still pointing at the upload).
153
+ const coverTree = server.getTreeMap()
154
+ if (coverTree) reconcileDocCover(coverTree, docId, fragment)
155
+
149
156
  // Auto-update presence: mark this doc as focused and place cursor at end
150
157
  server.setFocusedDoc(docId)
151
158
  server.setDocCursor(docId, fragment.length)