@abraca/mcp 2.15.0 → 2.17.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 +35 -22
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +36 -23
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/package.json +2 -2
- package/src/server.ts +66 -42
- package/src/tools/content.ts +8 -1
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.
|
|
3
|
+
"version": "2.17.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.
|
|
39
|
+
"@abraca/convert": "2.17.0"
|
|
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
|
|
361
|
-
//
|
|
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 (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 {
|
package/src/tools/content.ts
CHANGED
|
@@ -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)
|