@abraca/mcp 2.10.0 → 2.11.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
@@ -43,6 +43,8 @@ declare class AbracadabraMCPServer {
43
43
  /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
44
44
  private _toolHistory;
45
45
  private static readonly TOOL_HISTORY_MAX;
46
+ /** De-dupes concurrent connection heals across parallel tool calls. */
47
+ private _reconnecting;
46
48
  private _inboxStarted;
47
49
  private _inboxDocId;
48
50
  private _inboxDoc;
@@ -102,6 +104,19 @@ declare class AbracadabraMCPServer {
102
104
  * child-content provider cache survives — content docs are keyed by global
103
105
  * guid, independent of which space is active.
104
106
  */
107
+ /**
108
+ * Heal the active connection before a tool op so a dropped/idle WebSocket or
109
+ * an expired JWT doesn't surface as a 15s sync timeout ("MCP not responding").
110
+ * Refreshes the token, lets the SDK's own auto-reconnect finish if it's mid-
111
+ * flight, and rebuilds the space connection from scratch if the socket is
112
+ * truly dead. De-duped so parallel tool calls heal once. Best-effort: never
113
+ * throws — a failed heal falls through to the normal (possibly erroring) op.
114
+ */
115
+ /** Whether a provider's shared WebSocket is currently connected. Isolated in
116
+ * its own scope so repeated checks don't trip TS control-flow narrowing on
117
+ * the live `connectionStatus` getter. */
118
+ private _wsConnected;
119
+ ensureConnected(): Promise<void>;
105
120
  ensureSpaceActive(targetId: string | null | undefined): Promise<boolean>;
106
121
  /** Get the root doc-tree Y.Map of the active space. */
107
122
  getTreeMap(): Y.Map<any> | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "2.10.0",
3
+ "version": "2.11.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.10.0"
39
+ "@abraca/convert": "2.11.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
@@ -15,6 +15,7 @@ import {
15
15
  Kind,
16
16
  recordFromYAny,
17
17
  SERVER_ROOT_ID,
18
+ WebSocketStatus,
18
19
  } from "@abraca/dabra";
19
20
  import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
21
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -99,6 +100,8 @@ export class AbracadabraMCPServer {
99
100
  // `messages:new_message` stateless broadcast only reaches subscribers of the
100
101
  // channel/DM doc, and the agent never subscribes to DM docs. So we observe
101
102
  // the inbox's `entries` Y.Array and dispatch from there.
103
+ /** De-dupes concurrent connection heals across parallel tool calls. */
104
+ private _reconnecting: Promise<void> | null = null;
102
105
  private _inboxStarted = false;
103
106
  private _inboxDocId: string | null = null;
104
107
  private _inboxDoc: Y.Doc | null = null;
@@ -335,9 +338,88 @@ export class AbracadabraMCPServer {
335
338
  * child-content provider cache survives — content docs are keyed by global
336
339
  * guid, independent of which space is active.
337
340
  */
341
+ /**
342
+ * Heal the active connection before a tool op so a dropped/idle WebSocket or
343
+ * an expired JWT doesn't surface as a 15s sync timeout ("MCP not responding").
344
+ * Refreshes the token, lets the SDK's own auto-reconnect finish if it's mid-
345
+ * flight, and rebuilds the space connection from scratch if the socket is
346
+ * truly dead. De-duped so parallel tool calls heal once. Best-effort: never
347
+ * throws — a failed heal falls through to the normal (possibly erroring) op.
348
+ */
349
+ /** Whether a provider's shared WebSocket is currently connected. Isolated in
350
+ * its own scope so repeated checks don't trip TS control-flow narrowing on
351
+ * the live `connectionStatus` getter. */
352
+ private _wsConnected(provider: AbracadabraProvider): boolean {
353
+ return provider.connectionStatus === WebSocketStatus.Connected;
354
+ }
355
+
356
+ async ensureConnected(): Promise<void> {
357
+ if (this._reconnecting) return this._reconnecting;
358
+ this._reconnecting = (async () => {
359
+ 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).
362
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
363
+ try {
364
+ await this.client.loginWithKey(this._userId, this._signFn);
365
+ } catch (e) {
366
+ console.error("[abracadabra-mcp] Re-auth during heal failed:", e);
367
+ }
368
+ }
369
+
370
+ 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/Disconnected — give 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);
411
+ }
412
+ } finally {
413
+ this._reconnecting = null;
414
+ }
415
+ })();
416
+ return this._reconnecting;
417
+ }
418
+
338
419
  async ensureSpaceActive(
339
420
  targetId: string | null | undefined,
340
421
  ): Promise<boolean> {
422
+ await this.ensureConnected();
341
423
  if (!targetId || targetId === this._rootDocId) return true;
342
424
 
343
425
  // A top-level Space id — activate it directly (handles the common
@@ -411,11 +493,25 @@ export class AbracadabraMCPServer {
411
493
  * Caches providers and waits for sync before returning.
412
494
  */
413
495
  async getChildProvider(docId: string): Promise<AbracadabraProvider> {
496
+ // Heal a dropped socket / stale JWT first; this may rebuild the active
497
+ // provider (and clear the child cache), so run it before the cache check.
498
+ await this.ensureConnected();
499
+
414
500
  const cached = this.childCache.get(docId);
415
- if (cached) {
501
+ if (cached && cached.provider.connectionStatus !== WebSocketStatus.Disconnected) {
416
502
  cached.lastAccessed = Date.now();
417
503
  return cached.provider;
418
504
  }
505
+ if (cached) {
506
+ // Cached provider rode a socket that's since dropped — discard and reload
507
+ // so we never hand back a dead provider that times out on every op.
508
+ try {
509
+ cached.provider.destroy();
510
+ } catch {
511
+ /* already gone */
512
+ }
513
+ this.childCache.delete(docId);
514
+ }
419
515
 
420
516
  const activeProvider = this._activeConnection?.provider;
421
517
  if (!activeProvider) {