@mysten-incubation/memwal-mcp 0.0.10 → 0.0.11-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -2,7 +2,7 @@ import { clearCreds, credsPath } from "./auth.js";
2
2
  import { TOOL_DEFINITIONS } from "./auth-required.js";
3
3
  import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
4
4
  import { PROACTIVE_INSTRUCTIONS } from "./instructions.js";
5
- import { loginFlow } from "./login.js";
5
+ import { startOrReuseLoginFlow } from "./login.js";
6
6
  import { log, note } from "./logger.js";
7
7
  import { MEMWAL_MCP_VERSION } from "./version.js";
8
8
  /** Memory tools that take a `namespace` argument. `memwal_remember`,
@@ -61,7 +61,7 @@ const LOCAL_TOOL_DEFINITIONS = [
61
61
  },
62
62
  {
63
63
  name: "memwal_logout",
64
- description: "Remove the saved Walrus Memory credentials from this machine (~/.memwal/credentials.json). The on-chain delegate key registration is NOT revoked — visit the Walrus Memory dashboard to remove it from your account if needed.",
64
+ description: "Sign out of Walrus Memory: removes the saved credentials from this machine (~/.memwal/credentials.json) AND closes this connection's memory session, so memory tools stop working until you call memwal_login again. The on-chain delegate key registration is NOT revoked — visit the Walrus Memory dashboard to remove it from your account if needed.",
65
65
  inputSchema: {
66
66
  type: "object",
67
67
  properties: {},
@@ -103,6 +103,16 @@ function buildLocalInitializeResult(params) {
103
103
  * imported memory-tool list (which already carries its own `memwal_login`
104
104
  * entry) before appending our canonical definitions. */
105
105
  const LOCAL_TOOL_NAMES = new Set(LOCAL_TOOL_DEFINITIONS.map((t) => t.name));
106
+ /** Reply for every memory tool call once `memwal_logout` has torn the session
107
+ * down, and for anything still in flight at that moment. Names the way back in
108
+ * so the client isn't left guessing why the tools stopped working. */
109
+ const SIGNED_OUT_TEXT = "❌ Signed out of Walrus Memory. Memory tools are unavailable on this connection until you call `memwal_login` again.";
110
+ /** `failRequest` options for every signed-out refusal, so a request refused at
111
+ * logout time and one refused on arrival afterwards read identically. */
112
+ const SIGNED_OUT_FAILURE = {
113
+ toolText: SIGNED_OUT_TEXT,
114
+ errorMessage: SIGNED_OUT_TEXT,
115
+ };
106
116
  /** The `tools/list` we serve LOCALLY at cold start: the memory tools (from the
107
117
  * same source as auth-required mode) plus the locally-handled login/logout
108
118
  * tools. We strip any locally-served name from the imported list first —
@@ -225,7 +235,9 @@ async function openSseStream(relayerUrl, creds) {
225
235
  }
226
236
  if (resp.status === 401) {
227
237
  clearConnectTimer();
228
- controller.abort();
238
+ if (resp.body) {
239
+ await resp.text().catch(() => "");
240
+ }
229
241
  log.warn("bridge.unauthorized", { url });
230
242
  // DO NOT wipe creds here. A 401 from the relayer is *evidence* of
231
243
  // a problem but not *proof* the saved seed is the cause. Possible
@@ -241,16 +253,24 @@ async function openSseStream(relayerUrl, creds) {
241
253
  `credentials at ${credsPath()} were NOT modified. ` +
242
254
  "Run `memwal-mcp login` if you need to rotate the key.");
243
255
  }
256
+ if (resp.status === 429) {
257
+ clearConnectTimer();
258
+ const retryAfter = resp.headers.get("retry-after");
259
+ const body = resp.body ? await resp.text() : "";
260
+ throw new Error(`Walrus Memory relayer SSE handshake rate-limited (HTTP 429` +
261
+ `${retryAfter ? `, retry after ${retryAfter}s` : ""}). ${body.slice(0, 200)}`.trim());
262
+ }
244
263
  if (!resp.ok || !resp.body) {
245
264
  clearConnectTimer();
246
265
  const body = resp.body ? await resp.text() : "";
247
- controller.abort();
248
266
  throw new Error(`Walrus Memory relayer SSE handshake failed: HTTP ${resp.status} ${body.slice(0, 200)}`);
249
267
  }
250
268
  const ct = resp.headers.get("content-type") ?? "";
251
269
  if (!ct.includes("event-stream")) {
252
270
  clearConnectTimer();
253
- controller.abort();
271
+ if (resp.body) {
272
+ await resp.text().catch(() => "");
273
+ }
254
274
  throw new Error(`Walrus Memory relayer returned unexpected content-type "${ct}" for SSE endpoint`);
255
275
  }
256
276
  const reader = resp.body.getReader();
@@ -442,32 +462,23 @@ function writeStdoutMessage(msg) {
442
462
  * switch wallets, or refresh). Returns a click-able URL near-instantly;
443
463
  * listener stays alive in the background until callback or timeout. */
444
464
  async function handleLocalLogin(config, onCredentials) {
445
- const urlReady = new Promise((resolve) => {
446
- loginFlow({
447
- relayerUrl: config.relayerUrl,
448
- webUrl: config.webUrl,
449
- label: config.label,
450
- timeoutMs: LOGIN_BG_TIMEOUT_MS,
451
- openBrowser: false,
452
- onUrl: (url) => resolve(url),
453
- })
454
- .then(async (creds) => {
455
- await onCredentials(creds);
456
- log.info("memwal_login.bridge.success", {
457
- accountId: creds.accountId,
458
- delegateAddress: creds.delegateAddress,
459
- });
460
- })
461
- .catch((err) => {
462
- log.warn("memwal_login.bridge.failed", {
463
- msg: err instanceof Error ? err.message : String(err),
464
- });
465
+ const session = startOrReuseLoginFlow({
466
+ relayerUrl: config.relayerUrl,
467
+ webUrl: config.webUrl,
468
+ label: config.label,
469
+ timeoutMs: LOGIN_BG_TIMEOUT_MS,
470
+ openBrowser: false,
471
+ }, async (creds) => {
472
+ await onCredentials(creds);
473
+ log.info("memwal_login.bridge.success", {
474
+ accountId: creds.accountId,
475
+ delegateAddress: creds.delegateAddress,
465
476
  });
466
477
  });
467
478
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Listener never started")), URL_READY_TIMEOUT_MS).unref?.());
468
479
  let url;
469
480
  try {
470
- url = await Promise.race([urlReady, timeoutPromise]);
481
+ url = await Promise.race([session.url, timeoutPromise]);
471
482
  }
472
483
  catch (err) {
473
484
  return {
@@ -504,12 +515,35 @@ async function handleLocalLogin(config, onCredentials) {
504
515
  * on-chain delegate key — that requires a separate dashboard action. */
505
516
  function handleLocalLogout() {
506
517
  try {
507
- clearCreds();
508
- log.info("memwal_logout.bridge.success", { credsPath: credsPath() });
518
+ const cleared = clearCreds();
519
+ log.info("memwal_logout.bridge.success", {
520
+ removedPath: cleared.removedPath ?? null,
521
+ fallbackPath: cleared.fallbackPath ?? null,
522
+ });
523
+ if (!cleared.removedPath) {
524
+ return {
525
+ isError: false,
526
+ // The bridge only runs with credentials loaded, so a missing
527
+ // file here still means a live in-memory session to tear down —
528
+ // exactly the GH #616 case. Say so rather than implying nothing
529
+ // happened.
530
+ text: `✅ Already signed out. No credentials at \`${credsPath()}\`, and this ` +
531
+ `connection's memory session has been closed — memory tools will refuse ` +
532
+ `to run until you sign in again.`,
533
+ };
534
+ }
509
535
  return {
510
536
  isError: false,
511
537
  text: [
512
- `✅ Signed out. Credentials removed from \`${credsPath()}\`.`,
538
+ `✅ Signed out. Credentials removed from \`${cleared.removedPath}\`, and this connection's memory session has been closed — memory tools will refuse to run until you sign in again.`,
539
+ ...(cleared.fallbackPath
540
+ ? [
541
+ ``,
542
+ `**Still signed in elsewhere:** \`${cleared.fallbackPath}\` remains and is ` +
543
+ `what the next run loads, under a possibly different account. Remove ` +
544
+ `that file too to sign out everywhere.`,
545
+ ]
546
+ : []),
513
547
  ``,
514
548
  `**Note:** the on-chain delegate key for this client is still registered on your Walrus Memory account. To fully revoke access, visit the Walrus Memory dashboard and remove the matching public key from the "Delegate Keys" section.`,
515
549
  ``,
@@ -538,18 +572,18 @@ function handleLocalLogout() {
538
572
  * file directly. They appear in `tools/list` by splicing them into the
539
573
  * relayer's response on the way back to the client.
540
574
  */
541
- export async function runBridge(creds, config,
575
+ export async function runBridge(initialCreds, config,
542
576
  /** Requests the auth-required server already read off stdin before it
543
577
  * detected fresh credentials and handed control here (e.g. the
544
578
  * `memwal_recall` that triggered the hot-handoff). Replayed once the SSE
545
579
  * stream is up so they're served for real instead of being lost in the
546
580
  * mode switch — this is what removes the historical "second restart". */
547
581
  pendingLines = []) {
548
- note(`Connecting to ${creds.relayerUrl}...`);
582
+ note(`Connecting to ${initialCreds.relayerUrl}...`);
549
583
  log.info("bridge.connecting", {
550
- relayer: creds.relayerUrl,
551
- accountId: creds.accountId,
552
- delegate: creds.delegateAddress,
584
+ relayer: initialCreds.relayerUrl,
585
+ accountId: initialCreds.accountId,
586
+ delegate: initialCreds.delegateAddress,
553
587
  });
554
588
  // Live handle to the current SSE stream — replaced whenever we reconnect.
555
589
  // Starts null: we answer `initialize` / `tools/list` LOCALLY and wire stdin
@@ -558,9 +592,44 @@ pendingLines = []) {
558
592
  // the background; anything that must reach the relayer (`tools/call`) is
559
593
  // buffered in `pendingForward` until `sse` is live, then flushed.
560
594
  let sse = null;
595
+ /** The delegate key this bridge is currently authorized to act with.
596
+ * Nulled by `invalidateSession()` so signing out drops the key itself
597
+ * rather than only setting a flag that every forwarding path has to
598
+ * remember to check — after logout there is simply nothing left to sign
599
+ * with. `adoptCredentials` republishes it on the next login. */
600
+ let creds = initialCreds;
561
601
  let stdinClosed = false;
602
+ /** Set by `memwal_logout`. Unlike `stdinClosed` the process stays up and
603
+ * the client keeps talking to us — `memwal_login` must still work — but the
604
+ * relayer session is torn down and must never be re-established with the
605
+ * credentials the user just deleted. Every connect/reconnect path therefore
606
+ * checks this alongside `stdinClosed`; `adoptCredentials` clears it when a
607
+ * new login lands. */
608
+ let loggedOut = false;
609
+ /** Resolves once a post-logout `memwal_login` has published a fresh session
610
+ * (or stdin closed). The server pump parks on this instead of exiting, so
611
+ * signing back in resumes streaming without the user restarting their MCP
612
+ * client. Recreated on every logout; null while signed in. */
613
+ let logoutPark = null;
614
+ let releaseLogoutPark = null;
562
615
  let reconnectAttempt = 0;
563
616
  let reconnectPromise = null;
617
+ let firstConnectDone = false;
618
+ /** Bumped when the live SSE session is aborted or replaced so queued
619
+ * POSTs captured against a stale URL are skipped (reconnect replays). */
620
+ let sessionEpoch = 0;
621
+ /** One in-flight POST per SSE session — overlapping POSTs drop the stream. */
622
+ let postChain = Promise.resolve();
623
+ function enqueuePost(fn) {
624
+ const run = postChain.then(fn, fn);
625
+ postChain = run.then(() => undefined, () => undefined);
626
+ return run;
627
+ }
628
+ function postIfCurrent(epoch, postUrl, msg, postCreds) {
629
+ if (epoch !== sessionEpoch)
630
+ return Promise.resolve(0);
631
+ return postMessage(postUrl, msg, postCreds);
632
+ }
564
633
  let credentialGeneration = 0;
565
634
  let activeCredentialGeneration = 0;
566
635
  /** Callbacks to run once, the moment stdin closes — used to wake anything
@@ -585,6 +654,10 @@ pendingLines = []) {
585
654
  if (stdinClosed)
586
655
  return;
587
656
  stdinClosed = true;
657
+ // Wake a pump parked on logout, or shutdown would block on a login
658
+ // that is never coming.
659
+ releaseLogoutPark?.();
660
+ releaseLogoutPark = null;
588
661
  for (const fn of stdinCloseListeners) {
589
662
  try {
590
663
  fn();
@@ -679,11 +752,12 @@ pendingLines = []) {
679
752
  * login credential swap). Credential-generation checks discard a session
680
753
  * whose key rotated mid-handshake. */
681
754
  async function reconnect(reason, immediate = false) {
682
- if (stdinClosed)
755
+ if (stdinClosed || loggedOut)
683
756
  return;
684
757
  if (reconnectPromise)
685
758
  return reconnectPromise;
686
759
  reconnectPromise = (async () => {
760
+ sessionEpoch += 1;
687
761
  try {
688
762
  sse?.abort();
689
763
  }
@@ -716,10 +790,23 @@ pendingLines = []) {
716
790
  });
717
791
  }
718
792
  try {
719
- while (!stdinClosed) {
793
+ while (!stdinClosed && !loggedOut) {
720
794
  const openingGeneration = credentialGeneration;
721
795
  const openingCreds = creds;
796
+ // Signed out between the guard above and here: the key is
797
+ // gone, so there is nothing to authorize a new session
798
+ // with. Belt-and-braces against `loggedOut` alone.
799
+ if (!openingCreds)
800
+ break;
722
801
  const candidate = await openSseStream(openingCreds.relayerUrl, openingCreds);
802
+ // Logout can also land mid-handshake. Same reasoning as the
803
+ // stale-credentials case below, except there is no new key
804
+ // to reconnect with — drop the session and stop.
805
+ if (loggedOut) {
806
+ candidate.abort();
807
+ log.info("bridge.reconnect_discarded_signed_out", {});
808
+ break;
809
+ }
723
810
  // Login can finish while an older handshake is awaiting its
724
811
  // endpoint event. Never publish that stale session: its GET
725
812
  // used the old key, while subsequent POSTs would use the new
@@ -732,7 +819,9 @@ pendingLines = []) {
732
819
  });
733
820
  continue;
734
821
  }
822
+ sessionEpoch += 1;
735
823
  sse = candidate;
824
+ firstConnectDone = true;
736
825
  activeCredentialGeneration = openingGeneration;
737
826
  reconnectAttempt = 0;
738
827
  log.info("bridge.reconnected", {
@@ -751,6 +840,16 @@ pendingLines = []) {
751
840
  // and the SSE pump may delete entries concurrently as replies
752
841
  // start arriving on the new session.
753
842
  for (const [id, entry] of Array.from(inFlight.entries())) {
843
+ // Replay awaits a POST per entry, so a logout can land
844
+ // partway through this loop. The snapshot and
845
+ // `openingCreds` both predate it, so without this the
846
+ // remaining entries would still go out under the key the
847
+ // user just deleted — `invalidateSession` clearing
848
+ // `inFlight` cannot stop a snapshot already taken.
849
+ if (loggedOut || openingGeneration !== credentialGeneration) {
850
+ log.info("bridge.replay_halted_signed_out", { id });
851
+ break;
852
+ }
754
853
  const msg = entry.msg;
755
854
  try {
756
855
  // A replayed `initialize` produces a fresh upstream
@@ -766,7 +865,9 @@ pendingLines = []) {
766
865
  suppressUpstreamReplies.delete(msg.id);
767
866
  expectSuppressedReply(msg.id);
768
867
  }
769
- const status = await postMessage(sse.postUrl, msg, openingCreds);
868
+ const epoch = sessionEpoch;
869
+ const postUrl = sse.postUrl;
870
+ const status = await enqueuePost(() => postIfCurrent(epoch, postUrl, msg, openingCreds));
770
871
  log.info("bridge.replayed", { id, status });
771
872
  }
772
873
  catch (err) {
@@ -812,7 +913,10 @@ pendingLines = []) {
812
913
  }
813
914
  }
814
915
  async function adoptCredentials(nextCreds) {
815
- const previousAccountId = creds.accountId;
916
+ // `null` after a logout — treated as an account change, which is the
917
+ // safe direction: it purges rather than replays. (`invalidateSession`
918
+ // already emptied both queues, so the purge is a no-op there.)
919
+ const previousAccountId = creds?.accountId ?? null;
816
920
  const accountChanged = previousAccountId !== nextCreds.accountId;
817
921
  // Never replay an operation authorized for account A against account B.
818
922
  // Return explicit retryable errors instead; the caller can decide which
@@ -876,6 +980,10 @@ pendingLines = []) {
876
980
  creds = nextCreds;
877
981
  credentialGeneration += 1;
878
982
  reconnectAttempt = 0;
983
+ // Lift the logout halt BEFORE reconnecting — reconnect() refuses to run
984
+ // while it is set. The parked pump is released further down, once the
985
+ // new session actually exists.
986
+ loggedOut = false;
879
987
  log.info("bridge.credentials_updated", {
880
988
  previousAccountId,
881
989
  accountId: creds.accountId,
@@ -887,6 +995,56 @@ pendingLines = []) {
887
995
  if (activeCredentialGeneration !== credentialGeneration) {
888
996
  await reconnect("login-credentials-generation-mismatch", true);
889
997
  }
998
+ // Session is live again: wake a pump parked by a previous logout.
999
+ releaseLogoutPark?.();
1000
+ releaseLogoutPark = null;
1001
+ logoutPark = null;
1002
+ }
1003
+ /**
1004
+ * Tear the relayer session down after a successful `memwal_logout`.
1005
+ *
1006
+ * Deleting the credentials file is not revocation on its own: the bridge
1007
+ * holds the delegate key in memory and owns a live SSE session, so without
1008
+ * this every later memory tool call would still be forwarded and executed
1009
+ * under the key the user just removed (GH #616).
1010
+ *
1011
+ * `loggedOut` is set FIRST so the abort below cannot race the pump into
1012
+ * `reconnect("server-pump-eof")` and immediately re-authorize a new session
1013
+ * with those same in-memory credentials.
1014
+ */
1015
+ function invalidateSession() {
1016
+ if (loggedOut)
1017
+ return;
1018
+ loggedOut = true;
1019
+ logoutPark = new Promise((resolve) => {
1020
+ releaseLogoutPark = resolve;
1021
+ });
1022
+ try {
1023
+ sse?.abort();
1024
+ }
1025
+ catch {
1026
+ /* already dead */
1027
+ }
1028
+ sse = null;
1029
+ // Drop the delegate key itself, not just the flag. Revocation that
1030
+ // rests only on `loggedOut` is one missed check away from forwarding
1031
+ // under the key the user deleted; with `creds` null there is nothing
1032
+ // left to sign with and every forwarding path fails closed instead.
1033
+ creds = null;
1034
+ // Bump the generation so any handshake or replay that captured the old
1035
+ // key before this point discards its work on its next check, exactly as
1036
+ // it would for a mid-flight key rotation.
1037
+ credentialGeneration += 1;
1038
+ // Answer everything still outstanding rather than stranding it: these
1039
+ // were authorized under the old key and must not be replayed later.
1040
+ for (const [, entry] of Array.from(inFlight.entries())) {
1041
+ failRequest(entry.msg, "signed out", SIGNED_OUT_FAILURE);
1042
+ }
1043
+ inFlight.clear();
1044
+ for (const msg of pendingForward.splice(0, pendingForward.length)) {
1045
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1046
+ }
1047
+ log.info("bridge.session_invalidated", { reason: "logout" });
890
1048
  }
891
1049
  // Server → client: stream SSE messages to stdout. Loop forever, restart
892
1050
  // pump on stream end (which means SSE got cut → we already reconnected).
@@ -908,8 +1066,23 @@ pendingLines = []) {
908
1066
  // sibling closure (connectInBackground / reconnect) that TS
909
1067
  // analyzes independently. At runtime `sse` is a live handle here.
910
1068
  const stream = sse;
911
- if (!stream)
1069
+ if (!stream) {
1070
+ // Signed out: park rather than exit. Exiting would end the
1071
+ // pump for good, so a later `memwal_login` would reconnect a
1072
+ // session with nothing draining it — the client would hang
1073
+ // instead of recovering. `logoutPark` resolves once the new
1074
+ // session is published (or stdin closes).
1075
+ // Cast for the same reason as `stream` above: every
1076
+ // assignment to `logoutPark` happens in a sibling closure,
1077
+ // so TS narrows it to `null` here. No `!stdinClosed` guard:
1078
+ // the `while` above already established it and nothing is
1079
+ // awaited in between, so it cannot have changed.
1080
+ if (loggedOut) {
1081
+ await logoutPark;
1082
+ continue;
1083
+ }
912
1084
  break; // stdin closed before we ever connected
1085
+ }
913
1086
  while (true) {
914
1087
  const { value, done } = await stream.iter.next();
915
1088
  if (done)
@@ -975,6 +1148,10 @@ pendingLines = []) {
975
1148
  err: err instanceof Error ? err.message : String(err),
976
1149
  });
977
1150
  }
1151
+ // Deliberately NOT short-circuited on `loggedOut`: breaking here
1152
+ // would end the pump for good and strand a later re-login. Fall
1153
+ // through instead — `reconnect()` no-ops while signed out, and the
1154
+ // next iteration parks on `logoutPark` at the top of the loop.
978
1155
  if (stdinClosed)
979
1156
  break;
980
1157
  // Stream ended. If a reconnect is ALREADY in progress (e.g. the
@@ -1005,6 +1182,12 @@ pendingLines = []) {
1005
1182
  id: msg.id,
1006
1183
  result: buildLocalInitializeResult(msg.params),
1007
1184
  });
1185
+ // Signed out: the local reply is the whole answer. We will
1186
+ // not forward upstream, so do not arm a suppression that no
1187
+ // reply can ever consume — a leaked arm would swallow the
1188
+ // real reply if the client later reuses this id.
1189
+ if (loggedOut)
1190
+ return;
1008
1191
  // Expect exactly one upstream reply to drop for this forward.
1009
1192
  expectSuppressedReply(msg.id);
1010
1193
  // Fall through: forward/buffer the initialize upstream too.
@@ -1053,6 +1236,12 @@ pendingLines = []) {
1053
1236
  }
1054
1237
  if (params.name === "memwal_logout") {
1055
1238
  const result = handleLocalLogout();
1239
+ // Tear the session down before replying, so by the time
1240
+ // the client is told it is signed out that is actually
1241
+ // true. Only on success: if the credentials file could
1242
+ // not be removed the user is still signed in.
1243
+ if (!result.isError)
1244
+ invalidateSession();
1056
1245
  writeStdoutMessage({
1057
1246
  jsonrpc: "2.0",
1058
1247
  id: msg.id,
@@ -1064,6 +1253,22 @@ pendingLines = []) {
1064
1253
  return;
1065
1254
  }
1066
1255
  }
1256
+ // Signed out: refuse EVERY remaining request locally, not just
1257
+ // memory tool calls. `login`/`logout` returned above, and
1258
+ // `initialize`/`tools/list` are answered locally further up, so
1259
+ // anything still here would need the delegate key the user
1260
+ // deleted. Falling through instead would park it in
1261
+ // `pendingForward` — `sse` is null and `reconnect()` no-ops
1262
+ // while signed out — where it would either hang the client until
1263
+ // a login that may never come, or be flushed afterwards under a
1264
+ // NEW key the client never authorized it against. `failRequest`
1265
+ // picks the right shape per method: tool-result text for
1266
+ // `tools/call`, a JSON-RPC error for `ping` and friends, and
1267
+ // nothing at all for notifications.
1268
+ if (loggedOut) {
1269
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1270
+ return;
1271
+ }
1067
1272
  // Fill in the configured default namespace for memory tool
1068
1273
  // calls that didn't pass one. Mutates msg in place so the
1069
1274
  // forwarded — and any replayed-on-reconnect — copy carries it.
@@ -1086,7 +1291,7 @@ pendingLines = []) {
1086
1291
  // arrived before it (posting directly here would let it overtake
1087
1292
  // a still-queued buffered item). The flush (or the next connect)
1088
1293
  // forwards it in order. Dropping it would strand the request.
1089
- if (sse === null || flushing) {
1294
+ if (flushing || (sse === null && !firstConnectDone)) {
1090
1295
  pendingForward.push(msg);
1091
1296
  log.info("bridge.buffered_pre_connect", {
1092
1297
  method: msg.method,
@@ -1094,6 +1299,12 @@ pendingLines = []) {
1094
1299
  });
1095
1300
  return;
1096
1301
  }
1302
+ // After the first connect, do not buffer: nothing flushes
1303
+ // pendingForward once connectInBackground has returned.
1304
+ if (sse === null) {
1305
+ await reconnect("sse-missing");
1306
+ return;
1307
+ }
1097
1308
  // A successful background login swaps credentials and SSE
1098
1309
  // sessions asynchronously. Wait for that swap before sending a
1099
1310
  // new request so it cannot race the stale session URL/key.
@@ -1102,7 +1313,20 @@ pendingLines = []) {
1102
1313
  if (activeCredentialGeneration !== credentialGeneration) {
1103
1314
  await reconnect("post-credential-generation-mismatch", true);
1104
1315
  }
1105
- const status = await postMessage(sse.postUrl, msg, creds);
1316
+ // A logout can land while the two awaits above are parked. The
1317
+ // key is gone by then, so answer locally instead of posting.
1318
+ if (loggedOut || !creds) {
1319
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1320
+ return;
1321
+ }
1322
+ if (!sse) {
1323
+ await reconnect("sse-missing");
1324
+ return;
1325
+ }
1326
+ const epoch = sessionEpoch;
1327
+ const postUrl = sse.postUrl;
1328
+ const postCreds = creds;
1329
+ const status = await enqueuePost(() => postIfCurrent(epoch, postUrl, msg, postCreds));
1106
1330
  if (status === 404) {
1107
1331
  log.warn("bridge.session_stale", { sessionUrl: sse.postUrl });
1108
1332
  // reconnect() itself replays in-flight against the fresh
@@ -1133,11 +1357,16 @@ pendingLines = []) {
1133
1357
  // into inFlight but not delivered) is closed out below.
1134
1358
  break;
1135
1359
  }
1136
- if (!sse)
1360
+ // `invalidateSession` nulls both; either one means this queue
1361
+ // must not be drained onto the relayer.
1362
+ if (!sse || !creds)
1137
1363
  break; // lost the session; reconnect replays inFlight
1138
1364
  const msg = pendingForward.shift();
1139
1365
  try {
1140
- const status = await postMessage(sse.postUrl, msg, creds);
1366
+ const epoch = sessionEpoch;
1367
+ const postUrl = sse.postUrl;
1368
+ const postCreds = creds;
1369
+ const status = await enqueuePost(() => postIfCurrent(epoch, postUrl, msg, postCreds));
1141
1370
  if (status === 404) {
1142
1371
  // Stale session right after connect. EVERY id-bearing
1143
1372
  // request is in `inFlight`, and ANY reconnect — this
@@ -1203,6 +1432,9 @@ pendingLines = []) {
1203
1432
  * Only `tools/call` shaped requests get the tool-result error envelope; any
1204
1433
  * other id-bearing request gets a JSON-RPC error object (the correct shape
1205
1434
  * for a non-tool request). */
1435
+ /** `opts` overrides the default "relayer unavailable" wording for callers
1436
+ * whose failure is not an outage — logout, for one, where blaming the
1437
+ * relayer would be actively misleading. */
1206
1438
  function failRequest(msg, reason, opts = {}) {
1207
1439
  if (msg.id == null)
1208
1440
  return; // notification — nothing to answer
@@ -1304,7 +1536,7 @@ pendingLines = []) {
1304
1536
  // finished after `credentialGeneration` moved — that was the double-flush.
1305
1537
  const connectInBackground = (async () => {
1306
1538
  let attempt = 0;
1307
- while (!stdinClosed) {
1539
+ while (!stdinClosed && !loggedOut) {
1308
1540
  if (reconnectPromise) {
1309
1541
  await reconnectPromise;
1310
1542
  continue;
@@ -1324,11 +1556,21 @@ pendingLines = []) {
1324
1556
  candidate.abort();
1325
1557
  break;
1326
1558
  }
1559
+ // Signed out while this handshake was in flight. The loop guard
1560
+ // above only runs between iterations, so without this the
1561
+ // session would be published — an open, authenticated stream
1562
+ // holding the delegate key the user just deleted.
1563
+ if (loggedOut) {
1564
+ candidate.abort();
1565
+ break;
1566
+ }
1327
1567
  if (openingGeneration !== credentialGeneration || sse) {
1328
1568
  candidate.abort();
1329
1569
  continue;
1330
1570
  }
1571
+ sessionEpoch += 1;
1331
1572
  sse = candidate;
1573
+ firstConnectDone = true;
1332
1574
  note(`Connected. Bridging stdio MCP ↔ ${creds.relayerUrl}`);
1333
1575
  log.info("bridge.connected", { relayer: creds.relayerUrl });
1334
1576
  signalFirstConnect();