Fix: answer MCP initialize locally, connect relayer in background (#415) - #579
Conversation
ce3b71b to
810aaf6
Compare
ducnmm
left a comment
There was a problem hiding this comment.
@hungtranphamminh this is the right fix for #415 / WALM-321. Answering initialize + a static tools/list locally, wiring stdin before the SSE connect, and bounding the relayer with MEMWAL_MCP_CONNECT_TIMEOUT_MS (never 0) is the correct shape. The flush/reconnect path is careful: in-flight owns id-bearing requests so a flush-time 404 cannot double-post, suppressUpstreamReplies is a count that resets on replay, and reused initialize ids still get a real reply. Tests cover the actual failure mode.
Approve.
Nits, not blocking:
-
Rebase
dev. Branch is ~41 commits behind. MCP files look isolated, but please rebase before merge. -
Cold-start
TOOL_DEFINITIONSmust stay locked to the sidecar. You addedmemwal_remember_bulkandmemwal_healthand documented the mirror. OAuth-scoped sessions (memwal:readonly) will still advertise write tools untiltools/list_changed. Acceptable for the handshake, but a comment next toLOCAL_TOOLS_LISTthat scoped clients over-advertise until refresh would help the next editor. -
Truncated JSDoc above
failRequest— the previousfailPendingForwardcomment is left open and then overwritten. Cosmetic. -
serverInfo.versionis still"0.0.1"while CHANGELOG is 0.0.7. Pre-existing in auth-required too; not introduced here.
CI is green. Ready to merge after the rebase.
ducnmm
left a comment
There was a problem hiding this comment.
@hungtranphamminh second-pass review after a closer look at the new background connect vs the existing reconnect() / adoptCredentials protocol. The handshake shape is still right; I am reversing the earlier approve because the new path is a second session publisher.
Request changes
1. connectInBackground ignores credentialGeneration and can flush after a login reconnect.
sse = await openSseStream(creds.relayerUrl, creds);
signalFirstConnect();
await flushPendingForward();reconnect() already discards a handshake that finished after a key rotation (openingGeneration !== credentialGeneration). This loop does not.
Same-account memwal_login during the first connect:
- Cold start puts
initialize+tools/callin bothpendingForwardandinFlight. adoptCredentials(sameaccountId) does not purge. It bumpscredentialGenerationandreconnect("login-credentials-updated")replays all ofinFlighton the new session.- The in-flight
openSseStreamfromconnectInBackgroundthen completes, overwritessse, andflushPendingForward()POSTspendingForwardagain.
That is a double durable write (remember / remember_bulk) plus a second upstream initialize with only one suppress arm (second reply for the same id). If the stale handshake wins, later POSTs use the new bearer against the old session — postMessage only reconnects on 404, not 401.
Account-change is only half-fixed: pendingForward is purged, but connectInBackground can still publish account A’s session after B’s reconnect. The new live-login test asserts -32001 for the purged id and never sends a follow-up call, so it cannot see a clobbered session.
reconnect() already has the discard protocol. Smallest fix: do not have a second publisher — initial connect should be reconnect("initial", true) (or equivalent). If you keep two loops, on generation mismatch abort the candidate, do not flush, and do not overwrite a session reconnect() already published.
Please add a test: same-account memwal_login while the first SSE handshake is held → each buffered tools/call delivered once, initialize replied once, subsequent recall authenticated as the new bearer.
2. Account-change purge vs initialize suppression.
On account change, purge does suppressUpstreamReplies.delete(id) and returns for initialize without adding it to closedOutIds. If initialize was already POSTed and the reply is already queued on the old stream, iter.next() can still yield it after abort. With the arm cleared, the pump writes a second initialize reply.
Leave the one-shot arm so the late reply is consumed, or drop late initialize-shaped replies. Do not clear the arm and also skip closedOutIds.
What is still good
- Local
initialize+ statictools/listbefore SSE is the right #415 / WALM-321 fix. - Flush-404 / concurrent
server-pump-eofde-dup is careful for that pair;suppressUpstreamRepliesas a count is correct once the first upstream initialize reply has been consumed. resolveConnectTimeoutMs()never treats0as unbounded.- Auth-required
listChanged: trueis required for the post-login refresh.
Nits (after 1–2)
- Rebase
dev(~40 commits behind; real conflicts inauth-required.ts+ CHANGELOG 0.0.8). Keep sidecartitle/annotationsandremember_bulk/healthon the cold list. NAMESPACE_TOOLSstill omitsmemwal_remember_bulk.- Truncated JSDoc above
failRequest. serverInfo.versionstill"0.0.1"(pre-existing).- A hung relayer does not fail the tool call until stdin closes; between retries the client’s own timeout fires. The PR body overstates “degrades to a tool-call error”.
…ALM-321] The stdio↔SSE bridge awaited a full relayer connect (GET /version + SSE handshake + endpoint event) before answering the MCP `initialize` handshake. On a slow/cold credentialed start this exceeded the client's 30s connection timeout, so the process was SIGTERM'd and no memory tools loaded for the session. Answer `initialize` and a cold-start `tools/list` locally and instantly, wire stdin before the connect, and run the relayer connect in a background retry loop. Buffer `tools/call` until connected, then flush in order and emit `notifications/tools/list_changed` so the client re-lists the authoritative tool set. `initialize` is still forwarded upstream (for capability negotiation) with its reply suppressed. Bound the connect with `MEMWAL_MCP_CONNECT_TIMEOUT_MS` (default 10s, shared across the compatibility check and the SSE connect) so a hung relayer degrades to a tool-call error instead of a failed startup. Preserves reconnect + in-flight replay, the login/logout tools/list splice, namespace injection, the no-creds-wipe-on-401 behaviour, and the auth-required hot-handoff. Adds end-to-end tests for cold-start init, graceful timeout, flush-time reconnect de-duplication (incl. a concurrent-reconnect race), and initialize-id reuse. Fixes #415
…nect The background first-connect must not overwrite a session that reconnect()/adoptCredentials already owns. Abort a handshake whose credential generation moved, wait out an in-flight reconnect, and flush only leftover notifications if sse is already live. Keep initialize suppression + closedOutIds on account-change purge so a late upstream initialize reply cannot become a second client response. Adds a same-account login-during-cold-start test that asserts each buffered tools/call is POSTed once on the new bearer.
810aaf6 to
0d9bdd4
Compare
…id reusable Same-account credential rotation now strips id-bearing pendingForward so a mid-flush login cannot double-post after reconnect replay. Account-change purge keeps the one-shot initialize suppress arm and does not closedOut that id, so a later reused initialize id still gets a real reply. The account-change test now asserts initialize was answered once.
ducnmm
left a comment
There was a problem hiding this comment.
@hungtranphamminh / follow-up from Henry: the two blockers from the second-pass review are closed on 0d9bdd40 + this commit.
- First connect still uses
openSseStream+flushPendingForward(flush-404 / concurrent reconnect tests stay green). - A handshake that finishes after
credentialGenerationmoved, or afterreconnect()already publishedsse, is aborted. If login already owns reconnect, we wait it out and flush only leftover notifications. - Same-account login now drops id-bearing
pendingForwardso a mid-flush rotation cannot double-post. - Account-change purge keeps the initialize suppress arm and does not put that id in
closedOutIds(id reuse still works). - New test: same-account login during a delayed first handshake — each buffered
tools/callPOSTed once on the new bearer. Account-change test also asserts initialize answered once.
packages/mcp tests: 18/18.
Approve. Please still glance at CI after the rebase onto current dev.
Summary
Why
On a credentialed cold start (the common returning-user case),
memwal-mcpawaited a full relayer round-trip — TLS +
GET /version+ SSE handshake + theendpointevent — before answering the MCPinitializehandshake, becausethe stdio↔SSE bridge forwarded
initializeto the relayer rather than answeringit locally. When the cold-start path was slow (npx fetch + relayer TLS), this
exceeded the MCP client's 30s connection timeout, so the client SIGTERM'd the
process and no memory tools loaded for that session. Reported in #415
(WALM-321) with three timeout occurrences on the mainnet relayer.
What
initializelocally and instantly, and serve a static cold-starttools/list, before the relayer session exists — wiring stdin before theconnect.
tools/calluntil connected, then flush in arrival order and emit
notifications/tools/list_changedso the client re-lists the authoritativeupstream tool set (spliced with the locally-served
memwal_login/memwal_logout).MEMWAL_MCP_CONNECT_TIMEOUT_MS(default 10s),shared across the compatibility check and the SSE connect, so a hung relayer
degrades to a tool-call error instead of a failed startup.
initializeis still forwarded upstream for capability negotiation, with itsreply suppressed (one-shot, so a reused id still gets its real reply).
Solution
Mirrors the existing "auth-required" mode, which already answers
initializelocally when credentials are missing — this applies the same pattern to the
credentialed bridge path. The connect is decoupled from the handshake; the
existing reconnect + in-flight replay machinery owns delivery of any buffered
request after a stale-session 404 (so nothing is double-posted, even when a
concurrent reconnect races the flush). No new architectural surface; behaviour
is unchanged once the stream is up.
Preserved invariants: reconnect + in-flight replay, the login/logout
tools/listsplice, default-namespace injection, the no-creds-wipe-on-401 semantics, and the
auth-required → bridge hot-handoff.
Types of Changes
Benchmark impact
Testing
packages/mcp: 15/15,tscclean)New end-to-end tests (spawn the built binary against a mock relayer):
initializeanswered locally well before a slow relayer's SSE endpoint; statictools/listserved instantly; bufferedtools/callserved after connect;upstream
initializereply suppressed; cold list == post-connect spliced set.(no SIGTERM); buffered call closed out on shutdown.
reply), including a request arriving during the reconnect backoff and a
concurrent
server-pump-eofreconnect.initializeid for a later request still returns that request'sreal reply (result and error).
Checklist
MEMWAL_MCP_CONNECT_TIMEOUT_MSdocumented there)Related