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