@mysten-incubation/memwal-mcp 0.0.8 → 0.0.10-dev.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/bridge.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { clearCreds, credsPath } from "./auth.js";
2
- import { ensureCompatibleRelayer } from "./compatibility.js";
2
+ import { TOOL_DEFINITIONS } from "./auth-required.js";
3
+ import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
4
+ import { PROACTIVE_INSTRUCTIONS } from "./instructions.js";
3
5
  import { loginFlow } from "./login.js";
4
6
  import { log, note } from "./logger.js";
7
+ import { MEMWAL_MCP_VERSION } from "./version.js";
5
8
  /** Memory tools that take a `namespace` argument. `memwal_remember`,
6
9
  * `memwal_recall`, and `memwal_analyze` treat it as optional; `memwal_restore`
7
10
  * requires it (its upstream schema still lists `namespace` as required, so
@@ -9,6 +12,7 @@ import { log, note } from "./logger.js";
9
12
  * agent calls it without). */
10
13
  const NAMESPACE_TOOLS = new Set([
11
14
  "memwal_remember",
15
+ "memwal_remember_bulk",
12
16
  "memwal_recall",
13
17
  "memwal_analyze",
14
18
  "memwal_restore",
@@ -65,6 +69,57 @@ const LOCAL_TOOL_DEFINITIONS = [
65
69
  },
66
70
  },
67
71
  ];
72
+ /** Protocol versions this local `initialize` responder can speak. We echo the
73
+ * client's requested version when it's one of these, else fall back to our
74
+ * baseline — the same negotiation shape a real MCP server does. */
75
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
76
+ const FALLBACK_PROTOCOL_VERSION = "2024-11-05";
77
+ /** Build the `initialize` result we answer LOCALLY and instantly, before the
78
+ * relayer session is up. Echoes the client's requested protocolVersion when we
79
+ * support it (otherwise the baseline) instead of hard-coding one and ignoring
80
+ * the request. `tools.listChanged: true` is a deliberate difference from
81
+ * auth-required mode: the bridge serves a static `tools/list` at cold start and
82
+ * then emits `notifications/tools/list_changed` once the background relayer
83
+ * connect completes, so the client re-lists and picks up the real upstream tool
84
+ * set. Advertising `listChanged: false` (as auth-required does, since it never
85
+ * refreshes) would let a client ignore that notification. */
86
+ function buildLocalInitializeResult(params) {
87
+ const requested = params?.protocolVersion;
88
+ const protocolVersion = typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.has(requested)
89
+ ? requested
90
+ : FALLBACK_PROTOCOL_VERSION;
91
+ return {
92
+ protocolVersion,
93
+ capabilities: { tools: { listChanged: true } },
94
+ serverInfo: { name: "memwal", version: MEMWAL_MCP_VERSION },
95
+ // The relayer sets `instructions` too, but that reply never reaches the
96
+ // client: this local answer wins and the upstream initialize reply is
97
+ // suppressed. Omitting it here silently strips the proactive contract
98
+ // from every stdio client, which is the WALM-324 regression itself.
99
+ instructions: PROACTIVE_INSTRUCTIONS,
100
+ };
101
+ }
102
+ /** Names of the tools we serve locally, so we can de-dup them out of the
103
+ * imported memory-tool list (which already carries its own `memwal_login`
104
+ * entry) before appending our canonical definitions. */
105
+ const LOCAL_TOOL_NAMES = new Set(LOCAL_TOOL_DEFINITIONS.map((t) => t.name));
106
+ /** The `tools/list` we serve LOCALLY at cold start: the memory tools (from the
107
+ * same source as auth-required mode) plus the locally-handled login/logout
108
+ * tools. We strip any locally-served name from the imported list first —
109
+ * `TOOL_DEFINITIONS` bundles its own `memwal_login`, and concatenating
110
+ * `LOCAL_TOOL_DEFINITIONS` blindly would advertise `memwal_login` twice. This
111
+ * yields the SAME shape as the post-connect spliced list (upstream memory tools
112
+ * + login + logout, each once), so the static→refreshed transition doesn't
113
+ * change the tool set out from under the client. OAuth-scoped sessions may
114
+ * over-advertise write tools until `tools/list_changed` refreshes from the
115
+ * relayer. Refreshed via
116
+ * `tools/list_changed` once the relayer session is up. */
117
+ const LOCAL_TOOLS_LIST = {
118
+ tools: [
119
+ ...TOOL_DEFINITIONS.filter((t) => !LOCAL_TOOL_NAMES.has(t.name)),
120
+ ...LOCAL_TOOL_DEFINITIONS,
121
+ ],
122
+ };
68
123
  const LOGIN_BG_TIMEOUT_MS = 5 * 60_000;
69
124
  const URL_READY_TIMEOUT_MS = 5_000;
70
125
  /** Maximum silence we tolerate on the SSE stream before assuming the
@@ -91,19 +146,61 @@ function mcpAuthHeaders(creds) {
91
146
  };
92
147
  }
93
148
  async function openSseStream(relayerUrl, creds) {
94
- await ensureCompatibleRelayer(relayerUrl);
149
+ const connectTimeoutMs = resolveConnectTimeoutMs();
150
+ // One shared budget for the WHOLE attempt: the compatibility check (GET
151
+ // /version + /health fallback) and the SSE connect below both honour this
152
+ // single deadline, so an attempt is bounded by connectTimeoutMs in total
153
+ // rather than each step getting its own (which could sum to 2–3×).
154
+ const budgetSignal = AbortSignal.timeout(connectTimeoutMs);
155
+ await ensureCompatibleRelayer(relayerUrl, budgetSignal);
95
156
  const url = `${relayerUrl.replace(/\/+$/, "")}/api/mcp/sse`;
96
157
  const controller = new AbortController();
97
- const resp = await fetch(url, {
98
- method: "GET",
99
- headers: {
100
- ...mcpAuthHeaders(creds),
101
- accept: "text/event-stream",
102
- "cache-control": "no-cache",
103
- },
104
- signal: controller.signal,
105
- });
158
+ // Bound the INITIAL connect (headers + the wait-for-`endpoint`-event loop
159
+ // below) on the SAME shared budget as the compat check above, so a hung
160
+ // relayer aborts well before the MCP client's ~30s timeout and one attempt
161
+ // never exceeds connectTimeoutMs total. This is distinct from the idle
162
+ // watchdog, which only bounds silence AFTER the stream is up. When the
163
+ // budget fires we abort `controller` (so the in-flight fetch/read unwinds);
164
+ // we detach the listener the instant the endpoint resolves — past that
165
+ // point the idle watchdog owns liveness and this must never fire, or it
166
+ // would tear down a healthy stream.
167
+ let connectTimedOut = false;
168
+ const onBudgetExpired = () => {
169
+ if (!controller.signal.aborted) {
170
+ connectTimedOut = true;
171
+ log.warn("bridge.connect_timeout", { url, timeoutMs: connectTimeoutMs });
172
+ controller.abort();
173
+ }
174
+ };
175
+ // If the compat check already burned the whole budget, `budgetSignal` is
176
+ // already aborted — fire synchronously so we don't even attempt the SSE GET.
177
+ if (budgetSignal.aborted)
178
+ onBudgetExpired();
179
+ else
180
+ budgetSignal.addEventListener("abort", onBudgetExpired, { once: true });
181
+ const clearConnectTimer = () => budgetSignal.removeEventListener("abort", onBudgetExpired);
182
+ let resp;
183
+ try {
184
+ resp = await fetch(url, {
185
+ method: "GET",
186
+ headers: {
187
+ ...mcpAuthHeaders(creds),
188
+ accept: "text/event-stream",
189
+ "cache-control": "no-cache",
190
+ },
191
+ signal: controller.signal,
192
+ });
193
+ }
194
+ catch (err) {
195
+ clearConnectTimer();
196
+ if (connectTimedOut) {
197
+ throw new Error(`Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` +
198
+ `(${url}). The relayer may be slow, cold-starting, or unreachable.`);
199
+ }
200
+ throw err;
201
+ }
106
202
  if (resp.status === 401) {
203
+ clearConnectTimer();
107
204
  controller.abort();
108
205
  log.warn("bridge.unauthorized", { url });
109
206
  // DO NOT wipe creds here. A 401 from the relayer is *evidence* of
@@ -121,12 +218,14 @@ async function openSseStream(relayerUrl, creds) {
121
218
  "Run `memwal-mcp login` if you need to rotate the key.");
122
219
  }
123
220
  if (!resp.ok || !resp.body) {
221
+ clearConnectTimer();
124
222
  const body = resp.body ? await resp.text() : "";
125
223
  controller.abort();
126
224
  throw new Error(`Walrus Memory relayer SSE handshake failed: HTTP ${resp.status} ${body.slice(0, 200)}`);
127
225
  }
128
226
  const ct = resp.headers.get("content-type") ?? "";
129
227
  if (!ct.includes("event-stream")) {
228
+ clearConnectTimer();
130
229
  controller.abort();
131
230
  throw new Error(`Walrus Memory relayer returned unexpected content-type "${ct}" for SSE endpoint`);
132
231
  }
@@ -237,11 +336,21 @@ async function openSseStream(relayerUrl, creds) {
237
336
  // Wait for the `endpoint` event (or first message) before returning.
238
337
  while (!endpointResolved) {
239
338
  if (streamEnded) {
339
+ clearConnectTimer();
240
340
  controller.abort();
341
+ if (connectTimedOut) {
342
+ throw new Error(`Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` +
343
+ `(${url}) waiting for the endpoint event. The relayer may be slow, ` +
344
+ `cold-starting, or unreachable.`);
345
+ }
241
346
  throw new Error(`Walrus Memory relayer SSE handshake ended before endpoint event${streamError ? `: ${streamError}` : ""}`);
242
347
  }
243
348
  await new Promise((r) => (queueResolver = r));
244
349
  }
350
+ // Endpoint resolved — the stream is up. Hand liveness over to the idle
351
+ // watchdog and disarm the connect timer so it can never abort a healthy
352
+ // stream.
353
+ clearConnectTimer();
245
354
  const iter = {
246
355
  async next() {
247
356
  while (events.length === 0) {
@@ -419,14 +528,110 @@ pendingLines = []) {
419
528
  delegate: creds.delegateAddress,
420
529
  });
421
530
  // Live handle to the current SSE stream — replaced whenever we reconnect.
422
- let sse = await openSseStream(creds.relayerUrl, creds);
423
- note(`Connected. Bridging stdio MCP ${creds.relayerUrl}`);
424
- log.info("bridge.connected", { relayer: creds.relayerUrl });
531
+ // Starts null: we answer `initialize` / `tools/list` LOCALLY and wire stdin
532
+ // BEFORE the relayer session exists, so the MCP client's handshake never
533
+ // waits on a (possibly slow / cold) relayer round-trip. The connect runs in
534
+ // the background; anything that must reach the relayer (`tools/call`) is
535
+ // buffered in `pendingForward` until `sse` is live, then flushed.
536
+ let sse = null;
425
537
  let stdinClosed = false;
426
538
  let reconnectAttempt = 0;
427
539
  let reconnectPromise = null;
428
540
  let credentialGeneration = 0;
429
541
  let activeCredentialGeneration = 0;
542
+ /** Callbacks to run once, the moment stdin closes — used to wake anything
543
+ * parked on a timer (e.g. the connect-retry backoff) so shutdown is prompt
544
+ * instead of waiting out the timer. `markStdinClosed` is the single writer
545
+ * of `stdinClosed`; call it instead of assigning the flag directly. */
546
+ const stdinCloseListeners = new Set();
547
+ /** Register a shutdown callback. Returns an unregister fn so a caller that
548
+ * only cares about shutdown *while it's parked* (e.g. one backoff sleep) can
549
+ * detach when it wakes normally — otherwise the set would grow one stale
550
+ * closure per retry for the whole session. Fires immediately if already
551
+ * closed (unregister is then a no-op). */
552
+ function onStdinClose(fn) {
553
+ if (stdinClosed) {
554
+ fn();
555
+ return () => { };
556
+ }
557
+ stdinCloseListeners.add(fn);
558
+ return () => stdinCloseListeners.delete(fn);
559
+ }
560
+ function markStdinClosed() {
561
+ if (stdinClosed)
562
+ return;
563
+ stdinClosed = true;
564
+ for (const fn of stdinCloseListeners) {
565
+ try {
566
+ fn();
567
+ }
568
+ catch {
569
+ /* listener failure must not block shutdown */
570
+ }
571
+ }
572
+ stdinCloseListeners.clear();
573
+ }
574
+ /** Requests that arrived before the relayer session came up. Held here and
575
+ * flushed in order once `sse` is live. `tools/call` (and any other request
576
+ * that must reach the relayer) lands here; `tools/list` and
577
+ * `memwal_login|logout` are answered locally and never buffered. `initialize`
578
+ * IS buffered (to forward upstream for capability negotiation) but is never
579
+ * failed back — `failRequest` skips `method === "initialize"`. */
580
+ const pendingForward = [];
581
+ /** True while `flushPendingForward` is draining the buffer after the first
582
+ * connect. New stdin requests that arrive mid-drain must keep buffering
583
+ * rather than posting directly, or they'd overtake still-queued items and
584
+ * break arrival order. */
585
+ let flushing = false;
586
+ /** Resolves the first time the SSE stream is up (or when stdin closes before
587
+ * that ever happens). `serverPump` waits on this before reading from
588
+ * `sse.iter`; after it resolves, `sse` is either a live handle or null
589
+ * (stdin closed) — the pump loop guards on both. Idempotent. */
590
+ let signalFirstConnect = () => { };
591
+ let firstConnectSignaled = false;
592
+ const firstConnect = new Promise((r) => {
593
+ signalFirstConnect = () => {
594
+ if (firstConnectSignaled)
595
+ return;
596
+ firstConnectSignaled = true;
597
+ r();
598
+ };
599
+ });
600
+ /** Expected-suppression COUNT per id, for requests we answered locally but
601
+ * still forwarded upstream (currently just `initialize`, so the relayer
602
+ * session negotiates capabilities). The upstream reply must be dropped in
603
+ * the pump — the client already has our local reply, and a second response
604
+ * for the same id corrupts its JSON-RPC state.
605
+ *
606
+ * A count (not a bare Set) so suppression is EXACT and self-limiting: we
607
+ * expect exactly one upstream reply per forward, so we increment on each
608
+ * forward (initial + every reconnect replay) and decrement on each dropped
609
+ * reply, removing the id at zero. Once the initialize replies are all
610
+ * consumed, the id stops suppressing — so a client that later REUSES the
611
+ * initialize id for a real request gets that request's genuine reply
612
+ * (result OR error) through, instead of it being swallowed forever. */
613
+ const suppressUpstreamReplies = new Map();
614
+ /** IDs we've already answered with a shutdown "unavailable" envelope
615
+ * (`failRequest`). If a late upstream reply for one of these still arrives —
616
+ * e.g. a flush-404 reconnect re-posted the request onto a live session that
617
+ * answers just as we were closing out at shutdown — the pump must DROP it,
618
+ * or the client would get two responses for one id. */
619
+ const closedOutIds = new Set();
620
+ const expectSuppressedReply = (id) => {
621
+ suppressUpstreamReplies.set(id, (suppressUpstreamReplies.get(id) ?? 0) + 1);
622
+ };
623
+ /** Consume one expected suppression for `id`. Returns true if the reply
624
+ * should be dropped (an outstanding local-answer suppression existed). */
625
+ const consumeSuppressedReply = (id) => {
626
+ const n = suppressUpstreamReplies.get(id);
627
+ if (!n)
628
+ return false;
629
+ if (n <= 1)
630
+ suppressUpstreamReplies.delete(id);
631
+ else
632
+ suppressUpstreamReplies.set(id, n - 1);
633
+ return true;
634
+ };
430
635
  // In-flight requests pending a response. We replay them after a forced
431
636
  // reconnect so a server-side session swap doesn't strand a tool call
432
637
  // forever waiting for a reply that will never come. Notifications
@@ -437,17 +642,23 @@ pendingLines = []) {
437
642
  * locally-served `memwal_login` + `memwal_logout` tools so the MCP
438
643
  * client surfaces them in its tool palette. */
439
644
  const pendingListIds = new Set();
645
+ /** Reopen the SSE stream and replay outstanding `inFlight` requests against
646
+ * the fresh session. All callers await the SAME reconnect via
647
+ * `reconnectPromise` — returning immediately while one is active would let
648
+ * the server pump spin on the aborted stream and let client messages race
649
+ * the stale POST URL. Any reconnect replays the WHOLE `inFlight` map, so
650
+ * callers must treat every id-bearing request as reconnect-owned and never
651
+ * re-post it themselves. `immediate` skips the backoff (used right after a
652
+ * login credential swap). Credential-generation checks discard a session
653
+ * whose key rotated mid-handshake. */
440
654
  async function reconnect(reason, immediate = false) {
441
655
  if (stdinClosed)
442
656
  return;
443
- // All callers await the same reconnect. Returning immediately while a
444
- // reconnect is active lets the server pump spin on the aborted stream
445
- // and lets client messages race the stale POST URL.
446
657
  if (reconnectPromise)
447
658
  return reconnectPromise;
448
659
  reconnectPromise = (async () => {
449
660
  try {
450
- sse.abort();
661
+ sse?.abort();
451
662
  }
452
663
  catch {
453
664
  /* already dead */
@@ -461,8 +672,22 @@ pendingLines = []) {
461
672
  backoffMs: backoff,
462
673
  attempt: reconnectAttempt,
463
674
  });
464
- if (backoff > 0)
465
- await new Promise((r) => setTimeout(r, backoff));
675
+ // Sleep, but wake immediately on stdin close so shutdown isn't held
676
+ // up for the whole backoff; unref'd so the timer never keeps the
677
+ // event loop alive on its own (mirrors the connect-retry backoff).
678
+ if (backoff > 0) {
679
+ await new Promise((resolve) => {
680
+ const timer = setTimeout(() => {
681
+ unregister();
682
+ resolve();
683
+ }, backoff);
684
+ timer.unref?.();
685
+ const unregister = onStdinClose(() => {
686
+ clearTimeout(timer);
687
+ resolve();
688
+ });
689
+ });
690
+ }
466
691
  try {
467
692
  while (!stdinClosed) {
468
693
  const openingGeneration = credentialGeneration;
@@ -493,6 +718,19 @@ pendingLines = []) {
493
718
  // start arriving on the new session.
494
719
  for (const [id, msg] of Array.from(inFlight.entries())) {
495
720
  try {
721
+ // A replayed `initialize` produces a fresh upstream
722
+ // reply on the NEW session that must also be dropped.
723
+ // REPLACE (not stack) any pending suppression for this
724
+ // id: the old session was aborted, so its initialize
725
+ // reply will never arrive to consume its own arm.
726
+ // Re-arming without clearing would leave that orphaned
727
+ // arm forever, and a later reused id would have its
728
+ // real reply wrongly dropped. Reset to exactly one —
729
+ // the single reply the new session will send.
730
+ if (msg.method === "initialize" && msg.id != null) {
731
+ suppressUpstreamReplies.delete(msg.id);
732
+ expectSuppressedReply(msg.id);
733
+ }
496
734
  const status = await postMessage(sse.postUrl, msg, openingCreds);
497
735
  log.info("bridge.replayed", { id, status });
498
736
  }
@@ -508,6 +746,17 @@ pendingLines = []) {
508
746
  // it passed the first generation check.
509
747
  if (openingGeneration !== credentialGeneration) {
510
748
  candidate.abort();
749
+ // The replay above armed one initialize suppression for
750
+ // THIS (now-discarded) candidate; its reply will never
751
+ // arrive to consume it. Clear those arms so the count
752
+ // doesn't leak if the loop exits before another replay
753
+ // re-arms (a leaked arm would swallow a later reused-id
754
+ // reply). A surviving candidate re-arms fresh next pass.
755
+ for (const [, msg] of inFlight) {
756
+ if (msg.method === "initialize" && msg.id != null) {
757
+ suppressUpstreamReplies.delete(msg.id);
758
+ }
759
+ }
511
760
  continue;
512
761
  }
513
762
  break;
@@ -535,23 +784,59 @@ pendingLines = []) {
535
784
  // operations belong in the newly-selected account.
536
785
  if (accountChanged) {
537
786
  try {
538
- sse.abort();
787
+ sse?.abort();
539
788
  }
540
789
  catch {
541
790
  /* already dead */
542
791
  }
543
- for (const [id] of Array.from(inFlight.entries())) {
792
+ // Purge EVERY structure that holds an account-A request. `inFlight`
793
+ // (tracked requests) AND `pendingForward` (cold-start / mid-flush
794
+ // buffered requests) — the latter is unique to the cold-start path
795
+ // and would otherwise be flushed to account B's session (a
796
+ // cross-account replay) since the flush posts with the current
797
+ // `creds`. For each, reply once with a retryable error and stop
798
+ // tracking; never write a second reply for a locally-answered
799
+ // `initialize`. Keep its one-shot suppress arm so a queued
800
+ // upstream initialize reply is consumed. Do not put initialize in
801
+ // closedOutIds — a later reused id must still get a real reply.
802
+ const purge = (msg) => {
803
+ if (msg.id == null)
804
+ return; // notification — nothing to reply to
805
+ pendingListIds.delete(msg.id);
806
+ if (msg.method === "initialize") {
807
+ return;
808
+ }
809
+ suppressUpstreamReplies.delete(msg.id);
810
+ // A request can be in BOTH inFlight and pendingForward (cold-start
811
+ // dual-tracking), so guard against answering the same id twice.
812
+ if (closedOutIds.has(msg.id))
813
+ return;
814
+ // Record the id so a late reply for it (e.g. one already
815
+ // in-flight on the aborted session, or a racing replay) is
816
+ // dropped by the pump rather than becoming a second response.
817
+ closedOutIds.add(msg.id);
544
818
  writeStdoutMessage({
545
819
  jsonrpc: "2.0",
546
- id,
820
+ id: msg.id,
547
821
  error: {
548
822
  code: -32001,
549
823
  message: "Walrus Memory account changed during login; retry this request for the new account",
550
824
  },
551
825
  });
552
- pendingListIds.delete(id);
553
- }
826
+ };
827
+ for (const [, msg] of Array.from(inFlight.entries()))
828
+ purge(msg);
554
829
  inFlight.clear();
830
+ for (const msg of pendingForward.splice(0, pendingForward.length))
831
+ purge(msg);
832
+ }
833
+ else {
834
+ // Same account: reconnect() owns inFlight. Drop id-bearing
835
+ // pendingForward so a login mid-flush cannot POST the same
836
+ // remember/recall again after replay.
837
+ const leftover = pendingForward.filter((m) => m.id == null);
838
+ pendingForward.length = 0;
839
+ pendingForward.push(...leftover);
555
840
  }
556
841
  creds = nextCreds;
557
842
  credentialGeneration += 1;
@@ -571,12 +856,55 @@ pendingLines = []) {
571
856
  // Server → client: stream SSE messages to stdout. Loop forever, restart
572
857
  // pump on stream end (which means SSE got cut → we already reconnected).
573
858
  const serverPump = (async () => {
859
+ // Nothing to pump until the first relayer session is up. `firstConnect`
860
+ // resolves only on a SUCCESSFUL connect (the background connector
861
+ // retries failures with backoff), unless stdin closed first — in which
862
+ // case `sse` stays null and we exit the loop immediately.
863
+ await firstConnect;
574
864
  while (!stdinClosed) {
575
865
  try {
866
+ // Snapshot the current stream. `sse` is non-null here: set before
867
+ // signalFirstConnect(), and reconnect() only ever replaces it with
868
+ // another live handle. Reading through a local keeps us on one
869
+ // stream for the duration of this drain; a reconnect swaps `sse`
870
+ // and we pick up the new handle on the next outer iteration.
871
+ // Cast: TS control-flow narrows `sse` to `null` in the outer
872
+ // scope because every non-null assignment happens inside a
873
+ // sibling closure (connectInBackground / reconnect) that TS
874
+ // analyzes independently. At runtime `sse` is a live handle here.
875
+ const stream = sse;
876
+ if (!stream)
877
+ break; // stdin closed before we ever connected
576
878
  while (true) {
577
- const { value, done } = await sse.iter.next();
879
+ const { value, done } = await stream.iter.next();
578
880
  if (done)
579
881
  break;
882
+ // Drop the upstream reply to a request we already answered
883
+ // locally (e.g. `initialize`). Writing it would be a second
884
+ // response for the same id. We consume exactly ONE expected
885
+ // suppression per id (see suppressUpstreamReplies), so once
886
+ // the initialize reply(s) are drained the id stops
887
+ // suppressing — a client that later reuses that id for a real
888
+ // request still gets THAT request's reply (result or error).
889
+ if (value &&
890
+ value.id !== undefined &&
891
+ value.id !== null &&
892
+ (value.result !== undefined || value.error !== undefined) &&
893
+ consumeSuppressedReply(value.id)) {
894
+ inFlight.delete(value.id);
895
+ continue;
896
+ }
897
+ // Drop a late reply for an id we already closed out at
898
+ // shutdown — writing it would be a second response for that
899
+ // id (see closedOutIds / failRequest).
900
+ if (value &&
901
+ value.id !== undefined &&
902
+ value.id !== null &&
903
+ (value.result !== undefined || value.error !== undefined) &&
904
+ closedOutIds.has(value.id)) {
905
+ inFlight.delete(value.id);
906
+ continue;
907
+ }
580
908
  // Clear in-flight tracking once the response lands.
581
909
  if (value &&
582
910
  (value.result !== undefined || value.error !== undefined) &&
@@ -596,7 +924,12 @@ pendingLines = []) {
596
924
  pendingListIds.delete(value.id);
597
925
  const result = value.result;
598
926
  if (Array.isArray(result.tools)) {
599
- result.tools = [...result.tools, ...LOCAL_TOOL_DEFINITIONS];
927
+ // Strip any locally-served name from the upstream set
928
+ // before appending ours, so a relayer that ever
929
+ // advertises login/logout itself can't produce a
930
+ // duplicate tool name. Mirrors LOCAL_TOOLS_LIST.
931
+ const upstream = result.tools.filter((t) => !LOCAL_TOOL_NAMES.has(t.name ?? ""));
932
+ result.tools = [...upstream, ...LOCAL_TOOL_DEFINITIONS];
600
933
  }
601
934
  }
602
935
  writeStdoutMessage(value);
@@ -609,7 +942,13 @@ pendingLines = []) {
609
942
  }
610
943
  if (stdinClosed)
611
944
  break;
612
- // Stream ended unexpectedly reconnect and resume.
945
+ // Stream ended. If a reconnect is ALREADY in progress (e.g. the
946
+ // flush hit a 404), await THAT one rather than hammering reconnect()
947
+ // — otherwise this loop would spin on the dead stream's immediate
948
+ // `done`. reconnect() itself returns the shared reconnectPromise when
949
+ // one is active, so awaiting it here is enough; on the next
950
+ // iteration `sse` has been swapped to the fresh session and we
951
+ // resume reading. If no reconnect is in progress, this starts one.
613
952
  await reconnect("server-pump-eof");
614
953
  }
615
954
  })();
@@ -620,6 +959,34 @@ pendingLines = []) {
620
959
  void (async () => {
621
960
  try {
622
961
  const msg = JSON.parse(line);
962
+ // Answer `initialize` LOCALLY and instantly so the MCP client's
963
+ // handshake never waits on the relayer connect (the cold-start
964
+ // bug). We STILL forward it upstream (below) so the relayer
965
+ // session negotiates capabilities — but suppress that upstream
966
+ // reply, since the client already has this one.
967
+ if (msg.method === "initialize" && msg.id != null) {
968
+ writeStdoutMessage({
969
+ jsonrpc: "2.0",
970
+ id: msg.id,
971
+ result: buildLocalInitializeResult(msg.params),
972
+ });
973
+ // Expect exactly one upstream reply to drop for this forward.
974
+ expectSuppressedReply(msg.id);
975
+ // Fall through: forward/buffer the initialize upstream too.
976
+ }
977
+ // Answer `tools/list` LOCALLY at cold start (before the relayer
978
+ // session exists) so tool discovery unblocks immediately. Once
979
+ // connected we emit `notifications/tools/list_changed` and the
980
+ // client re-lists — that re-list is forwarded upstream and gets
981
+ // the real tool set spliced (handled further down + in the pump).
982
+ if (msg.method === "tools/list" && msg.id != null && sse === null) {
983
+ writeStdoutMessage({
984
+ jsonrpc: "2.0",
985
+ id: msg.id,
986
+ result: LOCAL_TOOLS_LIST,
987
+ });
988
+ return;
989
+ }
623
990
  // Local interception: `memwal_login` and `memwal_logout`
624
991
  // are handled here, never sent to the relayer. The user
625
992
  // can call them any time to re-auth or sign out without
@@ -668,6 +1035,19 @@ pendingLines = []) {
668
1035
  msg.id !== null) {
669
1036
  inFlight.set(msg.id, msg);
670
1037
  }
1038
+ // Relayer session not up yet, OR the post-connect flush is still
1039
+ // draining — buffer so this request stays behind everything that
1040
+ // arrived before it (posting directly here would let it overtake
1041
+ // a still-queued buffered item). The flush (or the next connect)
1042
+ // forwards it in order. Dropping it would strand the request.
1043
+ if (sse === null || flushing) {
1044
+ pendingForward.push(msg);
1045
+ log.info("bridge.buffered_pre_connect", {
1046
+ method: msg.method,
1047
+ id: msg.id ?? null,
1048
+ });
1049
+ return;
1050
+ }
671
1051
  // A successful background login swaps credentials and SSE
672
1052
  // sessions asynchronously. Wait for that swap before sending a
673
1053
  // new request so it cannot race the stale session URL/key.
@@ -689,21 +1069,239 @@ pendingLines = []) {
689
1069
  }
690
1070
  })();
691
1071
  };
1072
+ /** Flush everything buffered before the session came up, in arrival order,
1073
+ * then announce the real tool set. Called once, right after the first
1074
+ * successful connect. `flushing` keeps concurrently-arriving stdin requests
1075
+ * buffering (rather than posting directly and overtaking the queue); we
1076
+ * drain until the buffer is empty so those late arrivals are forwarded too. */
1077
+ async function flushPendingForward() {
1078
+ flushing = true;
1079
+ try {
1080
+ if (pendingForward.length > 0) {
1081
+ log.info("bridge.flushing_pre_connect", { count: pendingForward.length });
1082
+ }
1083
+ while (pendingForward.length > 0) {
1084
+ if (stdinClosed) {
1085
+ // Shutting down mid-flush: don't post to a torn-down session.
1086
+ // Everything still buffered (plus what we've already shifted
1087
+ // into inFlight but not delivered) is closed out below.
1088
+ break;
1089
+ }
1090
+ if (!sse)
1091
+ break; // lost the session; reconnect replays inFlight
1092
+ const msg = pendingForward.shift();
1093
+ try {
1094
+ const status = await postMessage(sse.postUrl, msg, creds);
1095
+ if (status === 404) {
1096
+ // Stale session right after connect. EVERY id-bearing
1097
+ // request is in `inFlight`, and ANY reconnect — this
1098
+ // flush's own, or a concurrent `server-pump-eof` one that
1099
+ // shares the same `reconnectPromise` — replays the whole
1100
+ // `inFlight` map against the fresh session. So id-bearing
1101
+ // items are owned by reconnect, period; re-posting them
1102
+ // from the flush would duplicate them (double write +
1103
+ // two replies for one id). We therefore drop ALL
1104
+ // id-bearing items from the queue after reconnect and
1105
+ // keep only id-less notifications (never in `inFlight`,
1106
+ // so no reconnect carries them) to re-drain. `await
1107
+ // reconnect()` resolves the shared reconnectPromise, so
1108
+ // `inFlight` has been fully replayed by the time we
1109
+ // decide what's left to send — no matter which caller
1110
+ // owns the reconnect.
1111
+ log.warn("bridge.session_stale", { sessionUrl: sse.postUrl });
1112
+ if (msg.id == null)
1113
+ pendingForward.unshift(msg);
1114
+ await reconnect("post-404");
1115
+ const notifications = pendingForward.filter((m) => m.id == null);
1116
+ pendingForward.length = 0;
1117
+ pendingForward.push(...notifications);
1118
+ continue;
1119
+ }
1120
+ }
1121
+ catch (err) {
1122
+ log.error("bridge.flush_failed", {
1123
+ id: msg.id ?? null,
1124
+ err: err instanceof Error ? err.message : String(err),
1125
+ });
1126
+ }
1127
+ }
1128
+ }
1129
+ finally {
1130
+ flushing = false;
1131
+ }
1132
+ // If stdin closed while we were draining, close out anything still open
1133
+ // (buffered + already-in-inFlight-but-undelivered) so those calls get an
1134
+ // error envelope instead of hanging until the client's own timeout.
1135
+ if (stdinClosed) {
1136
+ failPendingForward("connection lost during shutdown");
1137
+ failInFlightRequests("connection lost during shutdown");
1138
+ return;
1139
+ }
1140
+ // The client discovered tools from our static `tools/list`. Now that the
1141
+ // real relayer session is up, tell it to re-list so it picks up the
1142
+ // authoritative upstream set (spliced with login/logout in the pump).
1143
+ writeStdoutMessage({
1144
+ jsonrpc: "2.0",
1145
+ method: "notifications/tools/list_changed",
1146
+ });
1147
+ }
1148
+ /** Write the "relayer unavailable" reply for one open request, stop tracking
1149
+ * it, and never double-answer a locally-answered request. Shared by the
1150
+ * buffered (`failPendingForward`) and in-flight (`failInFlightRequests`)
1151
+ * close-outs. Skips:
1152
+ * - notifications (no id → nothing to reply to; also unforwardable now).
1153
+ * - `initialize` (we already answered it locally; a second response for
1154
+ * that id would corrupt the client's JSON-RPC state — just untrack).
1155
+ * Only `tools/call` shaped requests get the tool-result error envelope; any
1156
+ * other id-bearing request gets a JSON-RPC error object (the correct shape
1157
+ * for a non-tool request). */
1158
+ function failRequest(msg, reason) {
1159
+ if (msg.id == null)
1160
+ return; // notification — nothing to answer
1161
+ if (msg.method === "initialize") {
1162
+ // Locally answered already. Never write a second reply for this id.
1163
+ // Keep any suppress arm so a late upstream initialize result is
1164
+ // consumed; do not closedOut the id (clients may reuse it later).
1165
+ inFlight.delete(msg.id);
1166
+ return;
1167
+ }
1168
+ inFlight.delete(msg.id);
1169
+ // Remember we answered this id, so a late genuine reply (e.g. from a
1170
+ // flush-404 reconnect that re-posted onto a live session) is dropped by
1171
+ // the pump instead of becoming a second response for the same id.
1172
+ closedOutIds.add(msg.id);
1173
+ if (msg.method === "tools/call") {
1174
+ writeStdoutMessage({
1175
+ jsonrpc: "2.0",
1176
+ id: msg.id,
1177
+ result: {
1178
+ content: [
1179
+ {
1180
+ type: "text",
1181
+ text: `❌ Walrus Memory relayer unavailable: ${reason}. The memory tool could not run. Please retry shortly.`,
1182
+ },
1183
+ ],
1184
+ isError: true,
1185
+ },
1186
+ });
1187
+ }
1188
+ else {
1189
+ writeStdoutMessage({
1190
+ jsonrpc: "2.0",
1191
+ id: msg.id,
1192
+ error: {
1193
+ code: -32000,
1194
+ message: `Walrus Memory relayer unavailable: ${reason}`,
1195
+ },
1196
+ });
1197
+ }
1198
+ }
1199
+ function failPendingForward(reason) {
1200
+ const queued = pendingForward.splice(0, pendingForward.length);
1201
+ for (const msg of queued)
1202
+ failRequest(msg, reason);
1203
+ }
1204
+ /** Close out requests that reached `inFlight` but were never delivered a
1205
+ * reply — the shutdown counterpart of `failPendingForward`. Used when stdin
1206
+ * closes mid-flush: items already shifted out of `pendingForward` and posted
1207
+ * to a torn-down session would otherwise hang, since no upstream reply is
1208
+ * coming. Idempotent w.r.t. ids already closed out (delete-then-skip). */
1209
+ function failInFlightRequests(reason) {
1210
+ for (const msg of Array.from(inFlight.values()))
1211
+ failRequest(msg, reason);
1212
+ }
1213
+ // Kick off the relayer connect in the BACKGROUND — do NOT await it before
1214
+ // wiring stdin below. This is the whole fix: `initialize` / `tools/list` are
1215
+ // answered locally the moment they arrive, while the (possibly slow / cold)
1216
+ // relayer round-trip proceeds off the handshake's critical path.
1217
+ //
1218
+ // Retry with backoff so a cold-starting relayer eventually connects. We do
1219
+ // NOT fail buffered requests between attempts: a request that the next
1220
+ // attempt would serve must not get a spurious "unavailable" error (that
1221
+ // would also drop the auth-required hot-handoff request). Buffered tool
1222
+ // calls stay queued and are flushed on the first SUCCESS; if they never
1223
+ // connect, the client's own per-tool timeout fires (graceful) — and on
1224
+ // shutdown `failPendingForward` closes out anything still open. `initialize`
1225
+ // is answered locally, so it never blocks and is only forwarded, not failed.
1226
+ // First connect stays on `openSseStream` + `flushPendingForward` so a
1227
+ // flush-time 404 still goes through the existing reconnect/replay path.
1228
+ // It must NOT publish if login already owns `sse`, or if the handshake
1229
+ // finished after `credentialGeneration` moved — that was the double-flush.
1230
+ const connectInBackground = (async () => {
1231
+ let attempt = 0;
1232
+ while (!stdinClosed) {
1233
+ if (reconnectPromise) {
1234
+ await reconnectPromise;
1235
+ continue;
1236
+ }
1237
+ if (sse) {
1238
+ signalFirstConnect();
1239
+ const notifications = pendingForward.filter((m) => m.id == null);
1240
+ pendingForward.length = 0;
1241
+ pendingForward.push(...notifications);
1242
+ await flushPendingForward();
1243
+ return;
1244
+ }
1245
+ const openingGeneration = credentialGeneration;
1246
+ try {
1247
+ const candidate = await openSseStream(creds.relayerUrl, creds);
1248
+ if (stdinClosed) {
1249
+ candidate.abort();
1250
+ break;
1251
+ }
1252
+ if (openingGeneration !== credentialGeneration || sse) {
1253
+ candidate.abort();
1254
+ continue;
1255
+ }
1256
+ sse = candidate;
1257
+ note(`Connected. Bridging stdio MCP ↔ ${creds.relayerUrl}`);
1258
+ log.info("bridge.connected", { relayer: creds.relayerUrl });
1259
+ signalFirstConnect();
1260
+ await flushPendingForward();
1261
+ return;
1262
+ }
1263
+ catch (err) {
1264
+ const reason = err instanceof Error ? err.message : String(err);
1265
+ attempt += 1;
1266
+ log.error("bridge.initial_connect_failed", { err: reason, attempt });
1267
+ if (stdinClosed)
1268
+ break;
1269
+ const backoff = Math.min(15_000, 500 * Math.pow(2, attempt - 1));
1270
+ await new Promise((resolve) => {
1271
+ const timer = setTimeout(() => {
1272
+ unregister();
1273
+ resolve();
1274
+ }, backoff);
1275
+ timer.unref?.();
1276
+ const unregister = onStdinClose(() => {
1277
+ clearTimeout(timer);
1278
+ resolve();
1279
+ });
1280
+ });
1281
+ }
1282
+ }
1283
+ signalFirstConnect();
1284
+ failPendingForward("connection not established before shutdown");
1285
+ })();
692
1286
  // Replay anything the auth-required server handed off (the tool call that
693
- // triggered the hot-handoff, plus anything buffered behind it) now that the
694
- // SSE stream is connected. Without this the triggering request is dropped in
695
- // the mode switch and the user has to retry / restart.
1287
+ // triggered the hot-handoff, plus anything buffered behind it). These run
1288
+ // through handleClientLine, which buffers them into pendingForward until the
1289
+ // background connect lands so the triggering request is served for real
1290
+ // instead of being dropped in the mode switch.
696
1291
  if (pendingLines.length > 0) {
697
1292
  log.info("bridge.replaying_handoff", { count: pendingLines.length });
698
1293
  for (const line of pendingLines)
699
1294
  handleClientLine(line);
700
1295
  }
701
1296
  const clientPump = readStdinLines(handleClientLine).then(() => {
702
- stdinClosed = true;
703
- sse.abort();
1297
+ markStdinClosed();
1298
+ sse?.abort();
704
1299
  });
705
1300
  await Promise.race([serverPump, clientPump]);
706
- sse.abort();
1301
+ markStdinClosed();
1302
+ const finalStream = sse;
1303
+ finalStream?.abort();
1304
+ await connectInBackground.catch(() => { });
707
1305
  log.info("bridge.closed", {});
708
1306
  }
709
1307
  //# sourceMappingURL=bridge.js.map