@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/auth-required.d.ts.map +1 -1
- package/dist/auth-required.js +24 -35
- package/dist/auth-required.js.map +1 -1
- package/dist/auth.d.ts +80 -4
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +164 -18
- package/dist/auth.js.map +1 -1
- package/dist/bin/memwal-mcp.js +3 -1
- package/dist/bin/memwal-mcp.js.map +1 -1
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +286 -44
- package/dist/bridge.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -6
- package/dist/index.js.map +1 -1
- package/dist/login.d.ts +12 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +75 -6
- package/dist/login.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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([
|
|
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", {
|
|
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 \`${
|
|
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(
|
|
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 ${
|
|
582
|
+
note(`Connecting to ${initialCreds.relayerUrl}...`);
|
|
549
583
|
log.info("bridge.connecting", {
|
|
550
|
-
relayer:
|
|
551
|
-
accountId:
|
|
552
|
-
delegate:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|