From 79b81a0aa99c56f8936b956588fcc6800c48b1cc Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:15:03 +0700 Subject: [PATCH 1/2] refactor(mcp): serve stdio memory tools through the SDK, drop the SSE bridge The local stdio process already holds the delegate key. Forwarding every tools/call over a long-lived GET /api/mcp/sse session added a second protocol, a second occupancy slot, and a class of failures SDK clients never see (ip_active_cap 429, handshake 503, idle stream, 240s orphan). stdio MCP now answers initialize/tools/list locally, keeps memwal_login and memwal_logout on this machine, and implements remember/recall/bulk/ analyze/restore/health as MemWal.create({ key, accountId, serverUrl }) signed REST. Relayer 401 is a retryable error and does not wipe credentials.json. Logout still drops the in-process client. The Claude.ai Streamable HTTP /api/mcp path is unchanged. Tests cover remember/recall with an SDK stub (no SSE mock) and a spawn path that hits GET /health without opening /api/mcp/*. Follow-up: 410 /api/mcp/sse once plugin traffic is off it. --- .github/workflows/release-mcp.yml | 4 +- docs/mcp/changelog.mdx | 6 +- packages/mcp/CHANGELOG.md | 4 + packages/mcp/README.md | 2 +- packages/mcp/package.json | 12 +- packages/mcp/src/auth-required.ts | 587 +--- packages/mcp/src/auth.ts | 2 +- packages/mcp/src/bridge.ts | 2531 ----------------- packages/mcp/src/client-info.ts | 7 +- packages/mcp/src/compatibility.ts | 175 +- packages/mcp/src/format.ts | 200 ++ packages/mcp/src/index.ts | 71 +- packages/mcp/src/instructions.ts | 16 +- packages/mcp/src/messages.ts | 6 +- packages/mcp/src/namespace.ts | 30 + packages/mcp/src/server.ts | 408 +++ packages/mcp/src/session.ts | 131 + packages/mcp/src/tools.ts | 318 +++ packages/mcp/src/version.ts | 3 +- .../mcp/test/coldstart-flush-404.test.mjs | 426 --- packages/mcp/test/coldstart-init.test.mjs | 362 +-- packages/mcp/test/coldstart-timeout.test.mjs | 226 -- packages/mcp/test/concurrent-recall.test.mjs | 191 -- packages/mcp/test/default-namespace.test.mjs | 115 +- .../test/expired-credentials-recall.test.mjs | 831 ------ packages/mcp/test/handshake-contract.mjs | 28 +- .../test/health-relayer-annotation.test.mjs | 10 +- .../mcp/test/initialize-id-reuse.test.mjs | 172 -- .../mcp/test/live-login-credentials.test.mjs | 465 +-- .../mcp/test/login-failure-notice.test.mjs | 100 +- .../mcp/test/login-handoff-stdin.test.mjs | 244 -- packages/mcp/test/login-handoff.test.mjs | 130 +- .../mcp/test/login-success-notice.test.mjs | 167 +- .../mcp/test/logout-invalidation.test.mjs | 677 ----- packages/mcp/test/memory-tools.test.mjs | 171 ++ packages/mcp/test/no-sse.test.mjs | 128 + packages/mcp/test/orphaned-call.test.mjs | 326 --- .../mcp/test/pending-forward-stalled.test.mjs | 605 ---- packages/mcp/test/sdk-stdio.test.mjs | 297 ++ packages/mcp/test/sse-handshake-429.test.mjs | 404 --- packages/mcp/test/sse-idle-watchdog.test.mjs | 279 -- pnpm-lock.yaml | 60 +- 42 files changed, 1945 insertions(+), 8982 deletions(-) delete mode 100644 packages/mcp/src/bridge.ts create mode 100644 packages/mcp/src/format.ts create mode 100644 packages/mcp/src/namespace.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/session.ts create mode 100644 packages/mcp/src/tools.ts delete mode 100644 packages/mcp/test/coldstart-flush-404.test.mjs delete mode 100644 packages/mcp/test/coldstart-timeout.test.mjs delete mode 100644 packages/mcp/test/concurrent-recall.test.mjs delete mode 100644 packages/mcp/test/expired-credentials-recall.test.mjs delete mode 100644 packages/mcp/test/initialize-id-reuse.test.mjs delete mode 100644 packages/mcp/test/login-handoff-stdin.test.mjs delete mode 100644 packages/mcp/test/logout-invalidation.test.mjs create mode 100644 packages/mcp/test/memory-tools.test.mjs create mode 100644 packages/mcp/test/no-sse.test.mjs delete mode 100644 packages/mcp/test/orphaned-call.test.mjs delete mode 100644 packages/mcp/test/pending-forward-stalled.test.mjs create mode 100644 packages/mcp/test/sdk-stdio.test.mjs delete mode 100644 packages/mcp/test/sse-handshake-429.test.mjs delete mode 100644 packages/mcp/test/sse-idle-watchdog.test.mjs diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index 9e43c6c93..fe241055b 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -45,8 +45,8 @@ jobs: - name: Build MCP package run: pnpm --filter @mysten-incubation/memwal-mcp build - # Gate every publish on the integration suite (auth-required -> bridge - # hot-handoff, login callback). Never ship the MCP package without it. + # Gate every publish on the integration suite (login callback, SDK + # remember/recall). Never ship the MCP package without it. - name: Test MCP package run: pnpm --filter @mysten-incubation/memwal-mcp test diff --git a/docs/mcp/changelog.mdx b/docs/mcp/changelog.mdx index cd8cc258a..b28bfdefb 100644 --- a/docs/mcp/changelog.mdx +++ b/docs/mcp/changelog.mdx @@ -33,7 +33,11 @@ answer: >- ## 0.0.13 -This release stops a write whose reply was lost from being reported as safe to retry — repeating one can store a second paid copy — answers tool calls with an auth error pointing at `memwal_login` when the relayer rejects the saved delegate key, writes the credentials file through a fresh `0600` file that it renames into place, confirms a completed sign-in and keeps the bridge reading stdin afterwards, warns on unrecognised command-line options instead of ignoring them, documents the network presets in `--help`, names the relayer in `memwal_health`, reports restore `failed` counts when truncation is a transient download or embed blip, and pins plugin launch configs (`.mcp.json`, Cursor/Codex copies, and the Codex fallback installer) so npx cannot keep a cached 0.0.5. +This release cuts the local stdio MCP server off the SSE bridge: memory tools call the Walrus Memory SDK (signed REST) instead of `GET /api/mcp/sse`. It also stops a write whose reply was lost from being reported as safe to retry — repeating one can store a second paid copy — answers tool calls with an auth error pointing at `memwal_login` when the relayer rejects the saved delegate key, writes the credentials file through a fresh `0600` file that it renames into place, confirms a completed sign-in, warns on unrecognised command-line options instead of ignoring them, documents the network presets in `--help`, names the relayer in `memwal_health`, reports restore `failed` counts when truncation is a transient download or embed blip, and pins plugin launch configs (`.mcp.json`, Cursor/Codex copies, and the Codex fallback installer) so npx cannot keep a cached 0.0.5. + +### Changed + +- The stdio server implements memory tools itself through the Walrus Memory SDK (`MemWal.create` → signed REST). It no longer opens `GET /api/mcp/sse` or `POST /api/mcp/messages`, so this path no longer takes a relayer occupancy slot. `memwal_login` / `memwal_logout` stay local; logout still drops the in-process client. A relayer 401 is a retryable error and does not wipe `credentials.json`. The Claude.ai Streamable HTTP `/api/mcp` connector is unchanged. The package now depends on `@mysten/sui` and `@mysten/seal` so `npx` can build the SDK's SEAL session on remember/recall. ### Fixed diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 309ffa8b0..8367561b3 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.0.13 +### Changed + +- The stdio server implements memory tools itself through the Walrus Memory SDK (`MemWal.create` → signed REST). It no longer opens `GET /api/mcp/sse` or `POST /api/mcp/messages`, so this path no longer takes a relayer occupancy slot. `memwal_login` / `memwal_logout` stay local; logout still drops the in-process client. A relayer 401 is a retryable error and does not wipe `credentials.json`. The Claude.ai Streamable HTTP `/api/mcp` connector is unchanged. The package now depends on `@mysten/sui` and `@mysten/seal` so `npx` can build the SDK's SEAL session on remember/recall. + ### Fixed - Stop telling the user to retry a write whose reply was lost. A `memwal_remember`, `memwal_remember_bulk` or `memwal_analyze` that was POSTed and then timed out came back as "the connection to the relayer dropped before the result came back. Please retry." — but the relayer answers those with HTTP 202 and finishes the work in a durable queue, so a client-side deadline cancels nothing and the write may already have landed. `/api/remember/bulk` carries no idempotency key, unlike the single path, so following that advice stores a second paid copy that `recall` then hides behind the first. A sent write now says it may have completed, that the timeout did not undo it, and to check with `memwal_recall` before re-saving. A sent read still says plainly that retrying is safe. (WALM-618 follow-up) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4a21e8584..06b0691a4 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,6 +1,6 @@ # Walrus Memory MCP -Walrus Memory MCP is a stdio Model Context Protocol server for Walrus Memory. It lets MCP clients such as Cursor, Claude Desktop, Antigravity, and Claude Code connect to the Walrus Memory relayer without manually configuring remote headers or auth tokens. +Walrus Memory MCP is a stdio Model Context Protocol server for Walrus Memory. It lets MCP clients such as Cursor, Claude Desktop, Antigravity, and Claude Code sign in locally and then call the public Walrus Memory SDK (signed REST) — no remote MCP session, no SSE bridge. On first use, the package advertises a `memwal_login` tool to the MCP client. The agent can call it inline — no separate CLI command needed. The tool opens a browser-based wallet login flow and stores local credentials at `~/.memwal/credentials.json`. A matching `memwal_logout` tool clears the saved credentials. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 44738b58d..c6bda96d2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@mysten-incubation/memwal-mcp", "version": "0.0.13", - "description": "Walrus Memory MCP client — single-binary stdio MCP server that bridges Cursor / Claude Desktop / Antigravity / Claude Code to the Walrus Memory relayer. Handles browser-based wallet login on first run.", + "description": "Walrus Memory MCP client — stdio MCP server for Cursor / Claude Desktop / Antigravity / Claude Code. Local wallet login, then memory tools via the Walrus Memory SDK.", "type": "module", "engines": { "node": ">=20.0.0" @@ -18,14 +18,16 @@ } }, "scripts": { - "build": "tsc", + "build": "pnpm --filter @mysten-incubation/memwal build && tsc", "dev": "tsc --watch", - "typecheck": "tsc --noEmit", - "test": "tsc && node --test \"test/**/*.test.mjs\"", + "typecheck": "pnpm --filter @mysten-incubation/memwal build && tsc --noEmit", + "test": "pnpm --filter @mysten-incubation/memwal build && tsc && node --test \"test/**/*.test.mjs\"", "start": "node ./dist/bin/memwal-mcp.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", + "@mysten-incubation/memwal": "workspace:*", + "@mysten/seal": "^1.1.0", + "@mysten/sui": "^2.20.3", "@noble/ed25519": "2.3.0", "@noble/hashes": "2.0.0", "open": "10.1.0" diff --git a/packages/mcp/src/auth-required.ts b/packages/mcp/src/auth-required.ts index ed6bdfda2..f6b5ece5e 100644 --- a/packages/mcp/src/auth-required.ts +++ b/packages/mcp/src/auth-required.ts @@ -1,580 +1,9 @@ /** - * "Auth-required" stdio MCP server — run when ~/.memwal/credentials.json is - * missing but the package was spawned by an MCP client (Cursor / Claude - * Desktop / etc.). - * - * Instead of exiting (which makes the MCP client show a cryptic - * "Failed to start server" error that the user can't act on), we boot a - * minimal MCP server that: - * - * - Responds to `initialize` so the client sees a healthy server. - * - Advertises the 4 real Walrus Memory tools + a 5th `memwal_login` tool in - * `tools/list` so the agent knows what's available. - * - On `tools/call memwal_login`: invokes the browser-based wallet login - * flow inline so the user never has to leave their MCP client. Eliminates - * the previous "run a separate `npx ... login` command then restart" UX. - * - On any other `tools/call`: returns `isError: true` with a friendly - * instruction telling the agent to call `memwal_login` first (or run - * the CLI command as a fallback). - * - * Note: HTTP transport (`/api/mcp`) gets a separate native OAuth flow per - * MCP spec 2025-06 — see ENG-1750. The two paths cover different surfaces - * and coexist. - */ -import { credsPath, loadCreds, type MemWalCredentials } from "./auth.js"; -import { rememberInitializeClientInfo } from "./client-info.js"; -import { loginFailureNotice, loginPrompt, loginSuccessNotification } from "./messages.js"; -import { log } from "./logger.js"; -import { startOrReuseLoginFlow, resolveLoginTimeoutMs } from "./login.js"; -import { AUTH_REQUIRED_INSTRUCTIONS } from "./instructions.js"; -import { MEMWAL_MCP_VERSION } from "./version.js"; - -interface RpcMessage { - jsonrpc: "2.0"; - id?: number | string | null; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; -} - -const SIGNED_OUT_REMEMBER = - "Save a fact to the user's Walrus Memory personal memory. Call ONLY when the user explicitly asks to remember/save something. Pass the full, detailed text — never summarize."; -const SIGNED_IN_REMEMBER = - "Save a durable fact about the user or project to their Walrus Memory. Call this PROACTIVELY whenever the user states a preference, decision, constraint, correction, identity detail, or recurring workflow — even if they did not say 'remember this'. Skip one-off tasks, the current file or bug, and small talk. Pass the full statement; do not summarize. To save several facts at once, use memwal_remember_bulk instead."; -const SIGNED_OUT_RECALL = - "Search the user's Walrus Memory for facts relevant to a query. Returns matching memories ranked by relevance."; -const SIGNED_IN_RECALL = - "Search the user's Walrus Memory for relevant facts before responding. Call this PROACTIVELY at the start of a task, or whenever the user references past work, prior decisions, their preferences, or anything you may have stored earlier — don't wait to be asked. A single focused query is usually enough — recall is a real retrieval over encrypted storage, so do NOT fire multiple redundant searches for the same question. Returns matching memories ranked by relevance."; - -/** Build the static memory-tool list. `proactive` is true for the signed-in - * cold-start path (bridge: credentials exist, relayer not yet up) and false - * for auth-required (no credentials). Same tool names/order as the sidecar. */ -function buildToolDefinitions(proactive: boolean) { - return [ - { - name: "memwal_remember", - title: "Remember a Fact", - annotations: { readOnlyHint: false, destructiveHint: false }, - description: proactive ? SIGNED_IN_REMEMBER : SIGNED_OUT_REMEMBER, - inputSchema: { - type: "object", - properties: { - text: { type: "string", minLength: 1 }, - namespace: { type: "string" }, - }, - required: ["text"], - additionalProperties: false, - }, - }, - { - name: "memwal_remember_bulk", - title: "Remember Multiple Facts", - annotations: { readOnlyHint: false, destructiveHint: false }, - description: - "Save multiple durable facts in one call. Use when you learned several distinct facts at once (onboarding details, a list of preferences, decisions from a discussion). Pass an array of complete fact statements (max 20) — do not summarize. Prefer this over repeated memwal_remember calls.", - inputSchema: { - type: "object", - properties: { - facts: { - type: "array", - items: { type: "string", minLength: 1 }, - minItems: 1, - maxItems: 20, - }, - namespace: { type: "string" }, - }, - required: ["facts"], - additionalProperties: false, - }, - }, - { - name: "memwal_recall", - title: "Recall Memories", - annotations: { readOnlyHint: true, destructiveHint: false }, - description: proactive ? SIGNED_IN_RECALL : SIGNED_OUT_RECALL, - inputSchema: { - type: "object", - properties: { - query: { type: "string", minLength: 1 }, - limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, - namespace: { type: "string" }, - maxDistance: { - type: "number", - minimum: 0, - description: - "Optional cosine-distance cutoff (low = similar; 0 = identical). Hits with distance >= maxDistance are dropped. Omit to apply no cutoff. Displayed score is 1 - distance (high = similar); do not treat score as the cutoff.", - }, - }, - required: ["query"], - additionalProperties: false, - }, - }, - { - name: "memwal_analyze", - title: "Analyze and Remember", - annotations: { readOnlyHint: false, destructiveHint: true }, - description: - "Extract memorable facts from a longer passage of text (preferences, habits, biographical info, constraints) and save each as a separate Walrus Memory memory. Use this when you want MemWal's LLM to split the facts out of a transcript or notes for you; if you already know the exact facts, use memwal_remember or memwal_remember_bulk instead.", - inputSchema: { - type: "object", - properties: { - text: { type: "string", minLength: 1 }, - namespace: { type: "string" }, - }, - required: ["text"], - additionalProperties: false, - }, - }, - { - name: "memwal_restore", - title: "Restore Memory Index", - annotations: { readOnlyHint: false, destructiveHint: false }, - description: - "Recovery tool. Re-index a namespace from Walrus blobs back into the relayer's search index \u2014 use when memwal_recall unexpectedly returns nothing even though facts were saved before (e.g. on a new machine, a fresh relayer, or after switching servers). Returns restored/skipped/failed/total plus truncated \u2014 does not return memory texts. truncated=true is known-retryable-incomplete: retry the same limit on a download/embed blip; raising limit expands the sidecar cap only while limit < 20; after the cap saturates, truncation follows this call's missing-blob page. truncated=false is not completeness; WALM-451 will add sourceCapped. Call memwal_recall afterwards to query the rebuilt index.", - inputSchema: { - type: "object", - properties: { - namespace: { type: "string", minLength: 1 }, - limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, - }, - required: ["namespace"], - additionalProperties: false, - }, - }, - { - name: "memwal_health", - title: "Check Walrus Memory Health", - annotations: { readOnlyHint: true, destructiveHint: false }, - description: - "Quick connectivity check for Walrus Memory. Calls the relayer's lightweight health endpoint (no search, no decryption) and returns its status and version. Use this to confirm the server is reachable — do NOT use memwal_recall for health checks, which is a full and slow retrieval.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - { - name: "memwal_login", - title: "Sign In to Walrus Memory", - annotations: { readOnlyHint: false, destructiveHint: false }, - description: - "Sign this MCP client into your Walrus Memory account by opening a browser. Run once when the agent reports Walrus Memory is not signed in. Opens the dashboard in the default browser, waits for wallet approval, then writes credentials to ~/.memwal/credentials.json. Other memwal_* tools become usable on the next call after a successful login.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - ]; -} - -/** Signed-in cold-start list (bridge). Credentials exist; the relayer session - * is not up yet. Proactive wording so clients that keep the first tools/list - * still save/recall without being asked. */ -export const TOOL_DEFINITIONS = buildToolDefinitions(true); - -/** Signed-out list (auth-required). No credentials, so every memory call - * fails: keep conservative wording or the model will spam remember and get - * a stream of auth errors. */ -export const SIGNED_OUT_TOOL_DEFINITIONS = buildToolDefinitions(false); - -/** How long to wait for the local listener to bind + emit its URL before we - * give up and return an error. Should be near-instant; 5s is paranoia. */ -const URL_READY_TIMEOUT_MS = 5_000; - -const LOGIN_INSTRUCTION = [ - "❌ Walrus Memory isn't signed in yet.", - "", - "**Easiest fix — call the `memwal_login` tool from this client.** It opens a browser,", - "you approve the wallet sign-in, and on the next tool call this server picks up the", - "credentials automatically. No terminal command, no client restart.", - "", - "Fallback (if your client cannot call `memwal_login`, or you prefer a CLI):", - "", - " npx -y @mysten-incubation/memwal-mcp login", - "", - "(or `npx -y @mysten-incubation/memwal-mcp login --local` / `--dev` for a non-prod env)", - "", - "Either path opens a browser tab — click **Connect Sui Wallet** and approve the on-chain", - "`add_delegate_key` transaction. Credentials land at `~/.memwal/credentials.json`.", -].join("\n"); - -/** Set when a background `memwal_login` ends without credentials. The tool call - * already returned the URL by then, so this is the only place left to say so. */ -let lastLoginFailure: string | null = null; - -function writeStdoutMessage(msg: RpcMessage): void { - process.stdout.write(JSON.stringify(msg) + "\n"); -} - -/** Config passed in by the entry point (`index.ts`) so the login flow uses - * the same web/relayer URLs as the rest of the CLI (e.g. `--dev` → - * dashboard at `https://dev.memwal.ai`, not the prod default at - * `https://memory.walrus.xyz`). */ -export interface AuthRequiredConfig { - relayerUrl: string; - webUrl: string; - label: string; - /** Default memory namespace resolved at boot. Accepted here so the entry - * point can pass one config shape to both server modes — but auth-required - * mode never forwards a memory tool call (every non-login tool returns the - * login instruction), so there is nothing to namespace yet. It takes - * effect once credentials exist and the bridge runs. */ - namespace?: string; -} - -/** Send a `notifications/message` (MCP logging notification). Some clients - * surface these inline (Cursor); others swallow them (Claude Code as of - * 2026-05). We rely primarily on the tool result for the URL — this is a - * secondary surface for clients that show it. */ -function sendLogMessage(level: "info" | "warning" | "error", text: string): void { - writeStdoutMessage({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level, - logger: "memwal-mcp", - data: text, - }, - }); -} - -/** - * Start the browser-based login flow and return the click-able URL - * IMMEDIATELY in the tool result (do NOT block waiting for the user to - * approve). Reasons: - * - * - MCP clients enforce a tool-call timeout (~60s in Claude Code/Codex). - * The user's wallet flow can easily exceed it (hardware wallet review, - * Enoki sponsor lag, browser tab not focused). - * - The agent paraphrases timeout errors and may strip the URL when - * reporting to the user, leaving them stuck. - * - `notifications/message` is filtered out by some clients. - * - * The login HTTP listener stays alive for resolveLoginTimeoutMs() in the - * background. Once the user clicks the link and approves the wallet, the - * callback writes credentials to ~/.memwal/credentials.json. The user then - * issues any other memwal_* tool to verify — which now succeeds because - * the bridge picks up the saved creds on its next call. - */ -async function handleLoginToolCall( - config: AuthRequiredConfig, - _progressToken: unknown, -): Promise<{ text: string; isError: boolean }> { - // Fire login but DO NOT await the wallet callback — it runs in the - // background. openBrowser: false because (a) child-process spawning a - // browser is unreliable across MCP clients, and (b) macOS `open ` - // often foregrounds an existing memory.walrus.xyz tab instead of - // navigating to the full /connect/mcp?... URL. The agent surfaces the - // clickable URL from the tool result instead. - // - // Concurrent memwal_login calls join this in-flight flow instead of - // opening a second listener (that race hung later recall/remember). - lastLoginFailure = null; - const session = startOrReuseLoginFlow( - { - relayerUrl: config.relayerUrl, - webUrl: config.webUrl, - label: config.label, - timeoutMs: resolveLoginTimeoutMs(), - openBrowser: false, - onUrl: (url) => { - sendLogMessage("info", `Walrus Memory MCP login URL: ${url}`); - }, - }, - (creds) => { - lastLoginFailure = null; - log.info("memwal_login.bg.success", { - accountId: creds.accountId, - delegateAddress: creds.delegateAddress, - }); - // Symmetric with the failure branch below: the tool call returned - // the URL immediately, so nothing is left to carry the outcome - // except this notification and the banner the bridge prefixes onto - // the next tool result. - sendLogMessage( - "info", - loginSuccessNotification({ - accountId: creds.accountId, - delegateAddress: creds.delegateAddress, - credentialsPath: credsPath(), - }), - ); - }, - (err) => { - const msg = err instanceof Error ? err.message : String(err); - lastLoginFailure = msg; - log.warn("memwal_login.bg.failed", { msg }); - sendLogMessage("warning", `Walrus Memory sign-in did not complete: ${msg}`); - }, - ); - - // Race the URL-ready against a short timeout. The listener bind is - // synchronous-ish (single port allocation); 5s is a hard cap for a - // pathologically slow machine or unrelated bug. - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Listener never started")), - URL_READY_TIMEOUT_MS, - ).unref?.() as never, - ); - - let url: string; - try { - url = await Promise.race([session.url, timeoutPromise]); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.error("memwal_login.tool.url_not_ready", { msg }); - return { - isError: true, - text: [ - `❌ Failed to start Walrus Memory login: ${msg}`, - "", - "Try the CLI fallback:", - "", - " npx -y @mysten-incubation/memwal-mcp login", - ].join("\n"), - }; - } - - log.info("memwal_login.tool.url_ready", { url }); - return { - isError: false, - // Read from disk rather than assuming the stub only runs signed out: a - // completed callback writes credentials before the hand-off, so a - // second `memwal_login` in that window really would replace a stored - // key and must say so. - text: loginPrompt({ - url, - credentialsPath: credsPath(), - signedIn: loadCreds() !== null, - }), - }; -} - -/** Returned by {@link runAuthRequiredServer} when the user signs in mid-session - * (via `memwal_login`) and the request should now be served by the real bridge - * instead — WITHOUT a client restart. */ -export interface AuthHandoff { - creds: MemWalCredentials; - /** Lines the auth-required reader already pulled off stdin that the bridge - * must process first: the tool call that triggered the handoff, plus - * anything buffered behind it. */ - pendingLines: string[]; -} - -/** - * Dispatch one JSON-RPC line in auth-required mode. - * - * Returns the freshly-loaded credentials when `memwal_login` has written them - * since spawn and the request should be handed to the bridge for real - * servicing. Returns null when the line was fully handled locally (initialize, - * stub tools/list, login tool call, or the not-signed-in nudge). - */ -function handleAuthLine( - line: string, - config: AuthRequiredConfig, -): { creds: MemWalCredentials } | null { - let req: RpcMessage; - try { - req = JSON.parse(line) as RpcMessage; - } catch { - return null; - } - - // Notifications don't need a response. - if (req.id == null && typeof req.method === "string") { - return null; - } - - const id = req.id ?? null; - const method = req.method; - - if (method === "initialize") { - const clientInfo = rememberInitializeClientInfo(req.params); - if (clientInfo) { - log.info("bridge.agent_client", { - clientName: clientInfo.name, - clientVersion: clientInfo.version, - mode: "auth-required", - }); - } - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - // listChanged:true because the tool set DOES change after a - // mid-session login: the hot-handoff to the bridge emits - // `notifications/tools/list_changed`, and a client told - // `false` here would be entitled to ignore it and never pick up - // the real upstream tools (or `memwal_logout`). Advertise the - // capability the handoff depends on. - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: MEMWAL_MCP_VERSION }, - instructions: AUTH_REQUIRED_INSTRUCTIONS, - }, - }); - return null; - } - - if (method === "tools/list") { - // Signed in since spawn? Hand off so the client gets the real upstream - // tool list (with memwal_login/memwal_logout spliced in) from the bridge. - const creds = loadCreds(); - if (creds) return { creds }; - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { tools: SIGNED_OUT_TOOL_DEFINITIONS }, - }); - return null; - } - - if (method === "tools/call") { - const params = (req.params ?? {}) as { - name?: string; - arguments?: unknown; - _meta?: { progressToken?: unknown }; - }; - const toolName = params.name; - const progressToken = params._meta?.progressToken; - - if (toolName === "memwal_login") { - // Returns near-instantly with the click-able URL. The listener - // stays alive in the background — see handleLoginToolCall for the - // rationale on not blocking. - void handleLoginToolCall(config, progressToken).then((result) => { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - content: [{ type: "text", text: result.text }], - isError: result.isError, - }, - }); - }); - return null; - } - - // Any other memory tool. If `memwal_login` has since written - // credentials, hand off to the bridge so THIS call is served for real — - // no client restart (the historical "second reboot"). Otherwise nudge - // the agent to sign in first. - const creds = loadCreds(); - if (creds) { - lastLoginFailure = null; - return { creds }; - } - - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - content: [ - { type: "text", text: `${loginFailureNotice(lastLoginFailure)}${LOGIN_INSTRUCTION}` }, - ], - isError: true, - }, - }); - return null; - } - - // Anything else — return Method not found per JSON-RPC. - writeStdoutMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Method not found: ${method ?? "(missing)"}`, - }, - }); - return null; -} - -/** - * Run the auth-required stdio MCP server. - * - * Resolves with an {@link AuthHandoff} the moment `memwal_login` completes and - * the next memory tool call (or tools/list) arrives — so the caller can pick up - * the real bridge IN THE SAME PROCESS, eliminating the second client restart. - * Resolves with `undefined` if stdin closes before any sign-in. - * - * We manage the stdin listeners directly (rather than via the shared - * `readStdinLines`) so we can DETACH cleanly at handoff: the bridge attaches its - * own reader next, and two concurrent `data` listeners would double-process - * every line. `pause()` keeps any bytes that arrive during the switch buffered - * until the bridge's reader resumes the stream. - * - * The `config` parameter carries the same `relayerUrl` / `webUrl` / `label` - * that the rest of the CLI resolved (e.g. `--dev` → dev URLs). Without it, - * `memwal_login` would fall back to prod defaults and open the wrong dashboard. - */ -export async function runAuthRequiredServer( - config: AuthRequiredConfig, -): Promise { - log.info("auth_required_server.started", { - webUrl: config.webUrl, - relayerUrl: config.relayerUrl, - }); - - return await new Promise((resolve) => { - let buf = ""; - let settled = false; - - const detach = (): void => { - process.stdin.removeListener("data", onData); - process.stdin.removeListener("end", onEnd); - process.stdin.removeListener("close", onEnd); - }; - - const onData = (chunk: string): void => { - buf += chunk; - let nl: number; - while ((nl = buf.indexOf("\n")) >= 0) { - const rawLine = buf.slice(0, nl).replace(/\r$/, ""); - buf = buf.slice(nl + 1); - if (rawLine.length === 0) continue; - - const handoff = handleAuthLine(rawLine, config); - if (!handoff) continue; - - // Fresh credentials detected mid-session. Stop consuming stdin - // and hand the bridge everything we've read but not forwarded: - // the triggering line first, then anything buffered behind it. - settled = true; - detach(); - process.stdin.pause(); - - const pendingLines: string[] = [rawLine]; - let n2: number; - while ((n2 = buf.indexOf("\n")) >= 0) { - const l = buf.slice(0, n2).replace(/\r$/, ""); - buf = buf.slice(n2 + 1); - if (l.length > 0) pendingLines.push(l); - } - - log.info("auth_required_server.handoff_to_bridge", { - accountId: handoff.creds.accountId, - pendingLines: pendingLines.length, - }); - resolve({ creds: handoff.creds, pendingLines }); - return; - } - }; - - const onEnd = (): void => { - if (settled) return; - settled = true; - detach(); - log.info("auth_required_server.closed", {}); - resolve(undefined); - }; - - process.stdin.setEncoding("utf8"); - process.stdin.on("data", onData); - process.stdin.on("end", onEnd); - process.stdin.on("close", onEnd); - }); -} + * Tool-definition exports kept at this path so existing tests that import + * `../dist/auth-required.js` keep working. The stdio server itself lives in + * `server.ts`. + */ +export { + SIGNED_OUT_TOOL_DEFINITIONS, + TOOL_DEFINITIONS, +} from "./tools.js"; diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index be9d3bf73..91df6d9c3 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -34,7 +34,7 @@ export interface MemWalCredentials { accountId: string; /** 0x-prefixed Walrus Memory package id the account lives in. */ packageId: string; - /** Relayer base URL the bridge should connect to. */ + /** Relayer base URL the SDK should dial. */ relayerUrl: string; /** Human-readable label, e.g. "Cursor MCP" — surfaced in dashboard. */ label?: string; diff --git a/packages/mcp/src/bridge.ts b/packages/mcp/src/bridge.ts deleted file mode 100644 index 6b9319ee3..000000000 --- a/packages/mcp/src/bridge.ts +++ /dev/null @@ -1,2531 +0,0 @@ -/** - * stdio ↔ remote-SSE bridge. - * - * The MCP client (Cursor, Claude Desktop, etc.) speaks **stdio** MCP — JSON - * lines on stdin, JSON lines on stdout. The Walrus Memory relayer speaks **remote - * SSE** MCP at `/api/mcp/sse` + `/api/mcp/messages`. This module glues the - * two together so the user only adds a `command + args` entry to their MCP - * client config (no headers, no URL). - * - * On 401 from the relayer, we surface a clear error to the MCP client but - * leave the local credentials file untouched. A naive `clearCreds()` here - * was a creds-wipe DoS: anyone able to coerce a 401 response (transient WAF - * rule, future http_proxy MITM, local malware racing the relayer port on - * `--local`) would have wiped the user's saved seed without consent. - * Re-auth requires an explicit `memwal-mcp login` from the user. - */ -import type { MemWalCredentials } from "./auth.js"; -import { clearCreds, credsPath, loadCreds } from "./auth.js"; -import { TOOL_DEFINITIONS } from "./auth-required.js"; -import { - clientInfoHeaders, - lastClientInfoHeaders, - rememberInitializeClientInfo, -} from "./client-info.js"; -import { randomUUID } from "node:crypto"; -import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js"; -import { PROACTIVE_INSTRUCTIONS } from "./instructions.js"; -import { startOrReuseLoginFlow, resolveLoginTimeoutMs } from "./login.js"; -import { log, note } from "./logger.js"; -import { - loginPrompt, - loginSuccessNotice, - loginSuccessNotification, - type LoginSuccessInfo, -} from "./messages.js"; -import { MEMWAL_MCP_VERSION } from "./version.js"; - -/** Bridge mode runtime config — the URLs / label resolved at boot from - * `--dev` / `--staging` / etc. Needed so `memwal_login` (re-auth) opens - * the SAME dashboard the user originally signed in to, not the prod default. */ -export interface BridgeConfig { - relayerUrl: string; - webUrl: string; - label: string; - /** Default memory namespace resolved at boot (`--namespace` / - * `MEMWAL_NAMESPACE`). Injected into memory tool calls that omit a - * namespace. Undefined → don't inject; the relayer applies its own - * "default" namespace. */ - namespace?: string; -} - -/** Memory tools that take a `namespace` argument. `memwal_remember`, - * `memwal_remember_bulk`, `memwal_recall`, and `memwal_analyze` treat it as - * optional; `memwal_restore` requires it (its upstream schema still lists - * `namespace` as required, so agents normally pass one — but a configured - * default is filled in if the agent calls it without). */ -const NAMESPACE_TOOLS = new Set([ - "memwal_remember", - "memwal_remember_bulk", - "memwal_recall", - "memwal_analyze", - "memwal_restore", -]); - -/** - * Inject the configured default namespace into an outbound `tools/call` - * message when the agent omitted one. Mutates `msg.params.arguments` in place - * and returns `msg` (so it works inline before tracking/forwarding). - * - * No-op when: - * - no default namespace is configured (`namespace` falsy), or - * - the message is not a `tools/call` for a namespace-aware memory tool, or - * - the caller already supplied a non-empty `namespace` — an explicit - * per-call namespace always wins over the configured default. - */ -/** - * Name the relayer this process dialled in a `memwal_health` result. - * - * The relayer-side text can only report an origin its deployment published, and - * stays silent on a self-hosted or local one, where the sidecar knows nothing - * but the loopback address it dials. This side always knows the URL it - * connected to — it is exactly what `--prod` / `--relayer` / `MEMWAL_SERVER_URL` - * selected — so a client bound to the wrong network sees that here instead of - * by noticing its memories are missing. - * - * Rewrites an existing `relayer=` field rather than appending a second one: when - * both sides know the origin they describe the same session, and two - * conflicting fields would be worse than neither. - */ -export function annotateHealthResult( - result: { content?: unknown; isError?: unknown }, - relayerUrl: string, -): void { - // A failed health call has no session to describe; naming a relayer beside - // an error reads as though that relayer answered. - if (result.isError) return; - if (!Array.isArray(result.content)) return; - const block = (result.content as { type?: string; text?: string }[]).find( - (c) => c?.type === "text" && typeof c.text === "string", - ); - if (!block || typeof block.text !== "string") return; - const existing = /\brelayer=\S+/; - block.text = existing.test(block.text) - ? block.text.replace(existing, `relayer=${relayerUrl}`) - : `${block.text} relayer=${relayerUrl}`; -} - -export function applyDefaultNamespace(msg: RpcMessage, namespace?: string): RpcMessage { - if (!namespace) return msg; - if (msg.method !== "tools/call") return msg; - const params = msg.params as - | { name?: string; arguments?: Record } - | undefined; - if (!params || typeof params.name !== "string" || !NAMESPACE_TOOLS.has(params.name)) { - return msg; - } - const args = (params.arguments ??= {}); - const current = args.namespace; - // Explicit, non-empty per-call namespace wins. - if (typeof current === "string" && current.trim() !== "") return msg; - args.namespace = namespace; - return msg; -} - -/** Tools we serve LOCALLY (not forwarded to the relayer) so the user can - * re-auth or sign out without leaving the MCP client. The 4 memwal_* - * tools registered on the relayer side still come from `tools/list` - * upstream — we splice these in. */ -const LOCAL_TOOL_DEFINITIONS = [ - { - name: "memwal_login", - description: - "Sign in (or re-sign in) to Walrus Memory by opening a browser. Use to switch wallets, refresh credentials, or sign in for the first time. Returns a click-able URL — the user must approve in their browser.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - { - name: "memwal_logout", - 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.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, -]; - -/** Protocol versions this local `initialize` responder can speak. We echo the - * client's requested version when it's one of these, else fall back to our - * baseline — the same negotiation shape a real MCP server does. */ -const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]); -const FALLBACK_PROTOCOL_VERSION = "2024-11-05"; - -/** Build the `initialize` result we answer LOCALLY and instantly, before the - * relayer session is up. Echoes the client's requested protocolVersion when we - * support it (otherwise the baseline) instead of hard-coding one and ignoring - * the request. `tools.listChanged: true` is a deliberate difference from - * auth-required mode: the bridge serves a static `tools/list` at cold start and - * then emits `notifications/tools/list_changed` once the background relayer - * connect completes, so the client re-lists and picks up the real upstream tool - * set. Advertising `listChanged: false` (as auth-required does, since it never - * refreshes) would let a client ignore that notification. */ -function buildLocalInitializeResult(params: unknown): { - protocolVersion: string; - capabilities: { tools: { listChanged: boolean } }; - serverInfo: { name: string; version: string }; - instructions: string; -} { - const requested = (params as { protocolVersion?: unknown } | undefined)?.protocolVersion; - const protocolVersion = - typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.has(requested) - ? requested - : FALLBACK_PROTOCOL_VERSION; - return { - protocolVersion, - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: MEMWAL_MCP_VERSION }, - // The relayer sets `instructions` too, but that reply never reaches the - // client: this local answer wins and the upstream initialize reply is - // suppressed. Omitting it here silently strips the proactive contract - // from every stdio client, which is the WALM-324 regression itself. - instructions: PROACTIVE_INSTRUCTIONS, - }; -} - -/** Names of the tools we serve locally, so we can de-dup them out of the - * imported memory-tool list (which already carries its own `memwal_login` - * entry) before appending our canonical definitions. */ -const LOCAL_TOOL_NAMES = new Set(LOCAL_TOOL_DEFINITIONS.map((t) => t.name)); - -/** Reply for every memory tool call once `memwal_logout` has torn the session - * down, and for anything still in flight at that moment. Names the way back in - * so the client isn't left guessing why the tools stopped working. */ -const SIGNED_OUT_TEXT = - "❌ Signed out of Walrus Memory. Memory tools are unavailable on this connection until you call `memwal_login` again."; - -/** `failRequest` options for every signed-out refusal, so a request refused at - * logout time and one refused on arrival afterwards read identically. */ -const SIGNED_OUT_FAILURE = { - toolText: SIGNED_OUT_TEXT, - errorMessage: SIGNED_OUT_TEXT, -} as const; - -/** Reply for every request once the relayer has rejected the saved delegate - * key. An empty recall and a rejected key used to be indistinguishable to the - * agent — the queued call simply waited out the orphan sweeper and came back as - * "connection dropped, please retry", which is advice that cannot work. Name - * the cause and the way back in instead (GH #365 / WALM-602). */ -const UNAUTHORIZED_TEXT = - "❌ Walrus Memory rejected the saved credentials (HTTP 401). The delegate key may have been revoked or is no longer registered on this account. Call `memwal_login` to sign in again — saved credentials were NOT modified."; - -/** `failRequest` options for every credentials-rejected refusal, so one refused - * at handshake time and one refused on arrival afterwards read identically. */ -const UNAUTHORIZED_FAILURE = { - toolText: UNAUTHORIZED_TEXT, - errorMessage: UNAUTHORIZED_TEXT, -} as const; - -/** The `tools/list` we serve LOCALLY at cold start: the memory tools (from the - * same source as auth-required mode) plus the locally-handled login/logout - * tools. We strip any locally-served name from the imported list first — - * `TOOL_DEFINITIONS` bundles its own `memwal_login`, and concatenating - * `LOCAL_TOOL_DEFINITIONS` blindly would advertise `memwal_login` twice. This - * yields the SAME shape as the post-connect spliced list (upstream memory tools - * + login + logout, each once), so the static→refreshed transition doesn't - * change the tool set out from under the client. OAuth-scoped sessions may - * over-advertise write tools until `tools/list_changed` refreshes from the - * relayer. Refreshed via - * `tools/list_changed` once the relayer session is up. */ -const LOCAL_TOOLS_LIST = { - tools: [ - ...TOOL_DEFINITIONS.filter((t) => !LOCAL_TOOL_NAMES.has(t.name)), - ...LOCAL_TOOL_DEFINITIONS, - ], -}; - -const URL_READY_TIMEOUT_MS = 5_000; - -/** Maximum silence we tolerate on the SSE stream before assuming the - * relayer-side session has gone dead. The relayer sends keepalive events - * roughly every 3s, so 30s ≈ 10 missed heartbeats — well past any plausible - * network blip but quick enough that a stuck tool call recovers on its own. - * - * Override via `MEMWAL_MCP_SSE_IDLE_MS` (mostly for tests). Values below 500ms - * are clamped — anything tighter races the heartbeat cadence and produces - * spurious reconnects. */ -function resolveSseIdleMs(): number { - const raw = process.env.MEMWAL_MCP_SSE_IDLE_MS; - if (!raw) return 30_000; - const n = Number(raw); - if (!Number.isFinite(n) || n < 500) return 30_000; - return n; -} - -/** Longest deadline a server-side tool gives its own work (`analyze`). It - * lives in a package this one cannot import from, so raise this whenever that - * grows — otherwise the bridge declares healthy requests orphaned while the - * relayer is still working. */ -const SLOWEST_SERVER_TOOL_MS = 180_000; - -/** 240s as the constants stand. The headroom absorbs the relayer's own - * overhead, so expiry means the reply is lost rather than merely late. */ -const DEFAULT_CALL_TIMEOUT_MS = SLOWEST_SERVER_TOOL_MS + 60_000; - -/** An override below this is a mistake, not an intent. */ -const MIN_CALL_TIMEOUT_MS = 1_000; - -/** Without a cap, a long deadline drifts by a third of itself. */ -const MAX_ORPHAN_SWEEP_MS = 5_000; - -/** How long one request might sit unanswered. The idle watchdog above only sees - * a silent *stream*, which the keepalive prevents, so a lost reply needs its - * own deadline. Override via `MEMWAL_MCP_CALL_TIMEOUT_MS`, mostly for tests. */ -function resolveCallTimeoutMs(): number { - const raw = process.env.MEMWAL_MCP_CALL_TIMEOUT_MS; - if (!raw) return DEFAULT_CALL_TIMEOUT_MS; - const n = Number(raw); - if (!Number.isFinite(n) || n < MIN_CALL_TIMEOUT_MS) return DEFAULT_CALL_TIMEOUT_MS; - return n; -} - -/** How long to wait before retrying a handshake the relayer refused with a 429 - * that carried NO `Retry-After`. That is the relayer's concurrent-session cap - * (`ip_active_cap`), which deliberately sends no header because it clears when - * some other session closes, not on a timer — so the ordinary sub-second - * geometric retry is pure noise against it. - * - * Override via `MEMWAL_MCP_THROTTLE_FLOOR_MS` (mostly for tests). */ -const DEFAULT_THROTTLE_FLOOR_MS = 5_000; - -/** A relayer-supplied interval is a remote-controlled sleep, so cap it: a - * misconfigured (or hostile) `Retry-After: 86400` must not park the bridge for - * a day. Past this we retry anyway and take another 429 if we were wrong. */ -const MAX_THROTTLE_WAIT_MS = 60_000; - -function resolveThrottleFloorMs(): number { - const raw = process.env.MEMWAL_MCP_THROTTLE_FLOOR_MS; - if (!raw) return DEFAULT_THROTTLE_FLOOR_MS; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) return DEFAULT_THROTTLE_FLOOR_MS; - return Math.min(n, MAX_THROTTLE_WAIT_MS); -} - -/** Parse a `Retry-After` value into ms. The header is legally either - * delta-seconds or an HTTP-date (this relayer only ever emits the former, but - * a proxy in the path may rewrite it). Returns null for absent / unparseable - * values so the caller falls back to the floor — never NaN, which would poison - * the backoff arithmetic and break the retry loop outright. */ -function parseRetryAfterMs(raw: string | null): number | null { - if (!raw) return null; - const trimmed = raw.trim(); - if (trimmed === "") return null; - if (/^\d+$/.test(trimmed)) { - const seconds = Number(trimmed); - // Non-positive is not advice. `Retry-After: 0` is a real thing to - // receive (some intermediaries emit it for "unknown"), and taking it - // literally puts us back on the ~500ms geometric backoff that - // WALM-386 exists to stop — while still reporting `serverAdvised`, - // which would also suppress the one hint the user can act on. Treat - // it as no usable header and fall back to the floor. - return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null; - } - const at = Date.parse(trimmed); - if (!Number.isFinite(at)) return null; - // Same for an HTTP-date already in the past — which a correct server can - // produce simply by being a second behind the client's clock. - const waitMs = at - Date.now(); - return waitMs > 0 ? waitMs : null; -} - -/** The relayer refused the handshake with HTTP 429. Carried as a typed error so - * the retry loops can honour the throttle interval instead of re-deriving it - * from a message string — the whole point of WALM-386. `retryAfterMs` is - * already resolved (header, else floor) and clamped, so callers just sleep it. */ -class RelayerThrottledError extends Error { - readonly status = 429; - /** How long to wait before the next attempt, ms. Always a finite number. */ - readonly retryAfterMs: number; - /** True when the relayer actually sent a usable `Retry-After`. False means - * we applied the floor — the `ip_active_cap` shape, which has no ETA. */ - readonly serverAdvised: boolean; - - constructor(message: string, retryAfterHeader: string | null) { - super(message); - this.name = "RelayerThrottledError"; - const advised = parseRetryAfterMs(retryAfterHeader); - this.serverAdvised = advised !== null; - this.retryAfterMs = Math.min( - MAX_THROTTLE_WAIT_MS, - Math.max(0, advised ?? resolveThrottleFloorMs()), - ); - } -} - -/** Deadline for a request that is still buffered while the handshake has been - * failing for at least this long — i.e. one we can prove never left this - * process. - * - * It is much shorter than `callTimeoutMs` because the two cases carry - * different risk, not because the wait is less important. A request that was - * SENT might have been executed, so failing it early invites the agent to - * retry a `remember` that already landed. A request that was never sent - * cannot have executed: failing it is provably a no-op, and the agent's retry - * costs one round trip. - * - * 90s is well past a relayer cold start and past six reconnect attempts at the - * capped 15s backoff, so it does not fire on a slow-but-recovering relayer — - * and it does not apply at all while the handshake is healthy (a request - * buffered behind an in-progress flush keeps the full deadline). What it ends - * is the case from WALM-618: no working connection, nothing sent, and four - * minutes of silence before the user is told anything. */ -const DEFAULT_STALLED_HANDSHAKE_MS = 90_000; - -/** Same override shape as the call timeout, mostly for tests. Never longer - * than the call timeout itself: this deadline exists to fire sooner. */ -function resolveStalledHandshakeMs(callTimeoutMs: number): number { - const raw = process.env.MEMWAL_MCP_STALLED_HANDSHAKE_MS; - const n = raw ? Number(raw) : DEFAULT_STALLED_HANDSHAKE_MS; - const resolved = - Number.isFinite(n) && n >= MIN_CALL_TIMEOUT_MS ? n : DEFAULT_STALLED_HANDSHAKE_MS; - return Math.min(resolved, callTimeoutMs); -} - -interface RpcMessage { - jsonrpc: "2.0"; - id?: number | string | null; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; -} - -/** A request forwarded upstream and still awaiting its response. */ -interface InFlightEntry { - msg: RpcMessage; - startedAt: number; - /** Set once a POST has been issued for this request. - * - * This is what separates "cannot have executed" from "might have - * executed", and it has to live on the entry: `pendingForward` only holds - * requests buffered before the first successful connect, so in a - * mid-session outage — the ordinary case — a request that never left the - * process was indistinguishable from one already sent. */ - sent?: boolean; -} - -/** The relayer rejected the saved delegate key (HTTP 401 on the handshake). - * Distinct from every other connect failure because retrying cannot fix it: - * the caller must re-authenticate. Carrying it as a type keeps the background - * connect loop from backing off forever on a key that will never be accepted, - * which left tool calls parked until the orphan sweeper's deadline (WALM-602). */ -class RelayerUnauthorizedError extends Error { - constructor(message: string) { - super(message); - this.name = "RelayerUnauthorizedError"; - } -} - -interface SseHandshakeResult { - /** Absolute URL the client must POST to for outbound JSON-RPC messages. */ - postUrl: string; - /** Per-line iterator for incoming SSE messages (already-parsed JSON-RPC). */ - iter: AsyncIterator; - /** Abort + close the SSE stream. */ - abort: () => void; -} - -function mcpAuthHeaders( - creds: MemWalCredentials, - extra: Record = {}, -): Record { - return { - authorization: `Bearer ${creds.delegatePrivateKey}`, - "x-memwal-account-id": creds.accountId, - ...extra, - }; -} - -async function openSseStream( - relayerUrl: string, - creds: MemWalCredentials, - extraHeaders: Record = {}, -): Promise { - const connectTimeoutMs = resolveConnectTimeoutMs(); - // One shared budget for the WHOLE attempt: the compatibility check (GET - // /version + /health fallback) and the SSE connect below both honour this - // single deadline, so an attempt is bounded by connectTimeoutMs in total - // rather than each step getting its own (which could sum to 2–3×). - const budgetSignal = AbortSignal.timeout(connectTimeoutMs); - await ensureCompatibleRelayer(relayerUrl, budgetSignal); - - const url = `${relayerUrl.replace(/\/+$/, "")}/api/mcp/sse`; - const controller = new AbortController(); - - // Bound the INITIAL connect (headers + the wait-for-`endpoint`-event loop - // below) on the SAME shared budget as the compat check above, so a hung - // relayer aborts well before the MCP client's ~30s timeout and one attempt - // never exceeds connectTimeoutMs total. This is distinct from the idle - // watchdog, which only bounds silence AFTER the stream is up. When the - // budget fires we abort `controller` (so the in-flight fetch/read unwinds); - // we detach the listener the instant the endpoint resolves — past that - // point the idle watchdog owns liveness and this must never fire, or it - // would tear down a healthy stream. - let connectTimedOut = false; - const onBudgetExpired = (): void => { - if (!controller.signal.aborted) { - connectTimedOut = true; - log.warn("bridge.connect_timeout", { url, timeoutMs: connectTimeoutMs }); - controller.abort(); - } - }; - // If the compat check already burned the whole budget, `budgetSignal` is - // already aborted — fire synchronously so we don't even attempt the SSE GET. - if (budgetSignal.aborted) onBudgetExpired(); - else budgetSignal.addEventListener("abort", onBudgetExpired, { once: true }); - const clearConnectTimer = (): void => - budgetSignal.removeEventListener("abort", onBudgetExpired); - - let resp: Response; - try { - resp = await fetch(url, { - method: "GET", - headers: { - ...mcpAuthHeaders(creds, extraHeaders), - accept: "text/event-stream", - "cache-control": "no-cache", - }, - signal: controller.signal, - }); - } catch (err) { - clearConnectTimer(); - if (connectTimedOut) { - throw new Error( - `Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` + - `(${url}). The relayer may be slow, cold-starting, or unreachable.` - ); - } - throw err; - } - - // Every non-OK exit below DRAINS the body and deliberately does NOT abort - // `controller`. Aborting a handshake response we have already read is what - // produced the Windows libuv assertion in WALM-386 - // (`!(handle->flags & UV_HANDLE_CLOSING)`, src/win/async.c:76); 45b0ad87 - // removed those aborts on purpose ("the stdio bridge drains handshake error - // bodies instead of aborting the socket", CHANGELOG 0.0.11). Draining to - // completion lets undici return the socket to its pool normally. Do not - // re-add `controller.abort()` here. - if (resp.status === 401) { - clearConnectTimer(); - if (resp.body) { - await resp.text().catch(() => ""); - } - log.warn("bridge.unauthorized", { url }); - // DO NOT wipe creds here. A 401 from the relayer is *evidence* of - // a problem but not *proof* the saved seed is the cause. Possible - // sources: revoked delegate key (genuine), transient WAF / rate - // limit (false positive), http_proxy interposed somewhere on the - // path, or — on `--local` — local malware racing the relayer port. - // Auto-wiping the seed turns any one of those into a permanent - // outage that forces re-login. Force-fail loud instead; the user - // runs `memwal-mcp login` if they want to actually rotate. - throw new RelayerUnauthorizedError( - "Walrus Memory relayer rejected credentials (HTTP 401). " + - "Delegate key may have been revoked, the relayer may be " + - "rate-limiting, or a proxy may be interposed. Saved " + - `credentials at ${credsPath()} were NOT modified. ` + - "Run `memwal-mcp login` if you need to rotate the key." - ); - } - if (resp.status === 429) { - clearConnectTimer(); - const retryAfter = resp.headers.get("retry-after"); - const body = resp.body ? await resp.text().catch(() => "") : ""; - // Throw a TYPED error: the interval has to survive as a number for the - // retry loops to honour it. Stringifying it into the message (what this - // used to do) left both loops guessing, so they retried a throttled - // handshake after 500ms — WALM-386. - throw new RelayerThrottledError( - `Walrus Memory relayer SSE handshake rate-limited (HTTP 429` + - `${retryAfter ? `, retry after ${retryAfter}s` : ""}). ${body.slice(0, 200)}`.trim(), - retryAfter, - ); - } - if (!resp.ok || !resp.body) { - clearConnectTimer(); - const body = resp.body ? await resp.text().catch(() => "") : ""; - throw new Error( - `Walrus Memory relayer SSE handshake failed: HTTP ${resp.status} ${body.slice(0, 200)}` - ); - } - - const ct = resp.headers.get("content-type") ?? ""; - if (!ct.includes("event-stream")) { - clearConnectTimer(); - if (resp.body) { - await resp.text().catch(() => ""); - } - throw new Error( - `Walrus Memory relayer returned unexpected content-type "${ct}" for SSE endpoint` - ); - } - - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - let endpointResolved = false; - let endpointPath = ""; - let streamEnded = false; - let streamError: string | null = null; - const events: RpcMessage[] = []; - type Waker = () => void; - let queueResolver: Waker | null = null; - function wake(): void { - const r = queueResolver; - if (r) { - queueResolver = null; - r(); - } - } - - function pushEvent(ev: RpcMessage): void { - events.push(ev); - wake(); - } - - // Heartbeat watchdog: an alive SSE session emits keepalive events every - // few seconds. If reader.read() stops yielding chunks entirely, the - // server-side session has gone dead even though the TCP socket may still - // be open (observed in the wild: relayer session state silently dropped - // while the bridge waited forever for a response that never arrived, - // because the next POST landed in the void). Abort the controller — the - // catch block sets streamEnded=true and runBridge's serverPump triggers - // reconnect("server-pump-eof"), which replays any in-flight requests on - // the fresh session. - const idleTimeoutMs = resolveSseIdleMs(); - const checkIntervalMs = Math.max(500, Math.floor(idleTimeoutMs / 3)); - let lastChunkAt = Date.now(); - const watchdog = setInterval(() => { - const idleMs = Date.now() - lastChunkAt; - if (idleMs > idleTimeoutMs && !controller.signal.aborted) { - log.warn("bridge.sse_idle_watchdog_fired", { idleMs, idleTimeoutMs }); - controller.abort(); - } - }, checkIntervalMs); - // unref so the watchdog never holds the event loop open during shutdown. - watchdog.unref?.(); - - // Pump the SSE stream in the background. - const pump = (async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - lastChunkAt = Date.now(); - buf += decoder.decode(value, { stream: true }); - let sep: number; - while ((sep = buf.indexOf("\n\n")) >= 0) { - const chunk = buf.slice(0, sep); - buf = buf.slice(sep + 2); - const lines = chunk.split("\n"); - const event = lines - .find((l) => l.startsWith("event:")) - ?.slice("event:".length) - .trim(); - const data = lines - .filter((l) => l.startsWith("data:")) - .map((l) => l.slice("data:".length).replace(/^\s/, "")) - .join("\n"); - if (event === "endpoint" && !endpointResolved) { - endpointPath = data.trim(); - endpointResolved = true; - wake(); - continue; - } - if (event === "message" || (!event && data)) { - try { - const parsed = JSON.parse(data) as RpcMessage; - pushEvent(parsed); - } catch { - log.warn("bridge.sse_parse_failed", { data: data.slice(0, 120) }); - } - } - } - } - } catch (err) { - if (!controller.signal.aborted) { - const msg = err instanceof Error ? err.message : String(err); - streamError = msg; - // `terminated` is undici's keep-alive idle drop — happens on - // long-idle SSE in manual tests. The MCP client wrapping us - // (Cursor / Claude Desktop) will re-spawn the process if it - // needs the bridge again, so a clean exit is fine. - if (msg === "terminated" || msg.includes("ECONNRESET")) { - log.warn("bridge.sse_idle_closed", { reason: msg }); - } else { - log.error("bridge.sse_pump_error", { err: msg }); - } - } - } finally { - clearInterval(watchdog); - streamEnded = true; - // Wake any waiter so they see EOF. - wake(); - } - })(); - - // Wait for the `endpoint` event (or first message) before returning. - while (!endpointResolved) { - if (streamEnded) { - clearConnectTimer(); - controller.abort(); - if (connectTimedOut) { - throw new Error( - `Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` + - `(${url}) waiting for the endpoint event. The relayer may be slow, ` + - `cold-starting, or unreachable.` - ); - } - throw new Error( - `Walrus Memory relayer SSE handshake ended before endpoint event${streamError ? `: ${streamError}` : ""}` - ); - } - await new Promise((r) => (queueResolver = r)); - } - - // Endpoint resolved — the stream is up. Hand liveness over to the idle - // watchdog and disarm the connect timer so it can never abort a healthy - // stream. - clearConnectTimer(); - - const iter: AsyncIterator = { - async next(): Promise> { - while (events.length === 0) { - if (controller.signal.aborted) return { value: undefined as never, done: true }; - if (streamEnded) return { value: undefined as never, done: true }; - await new Promise((r) => (queueResolver = r)); - } - return { value: events.shift()!, done: false }; - }, - }; - - // `endpointPath` may be relative (`/api/mcp/messages?sessionId=...`) or - // absolute. Make it absolute for `fetch()`. - const postUrl = endpointPath.startsWith("http") - ? endpointPath - : `${relayerUrl.replace(/\/+$/, "")}${endpointPath}`; - - return { - postUrl, - iter, - abort: () => { - controller.abort(); - void pump; // suppress unused warning - }, - }; -} - -async function postMessage( - postUrl: string, - msg: RpcMessage, - creds: MemWalCredentials, - extraHeaders: Record = {}, -): Promise { - const resp = await fetch(postUrl, { - method: "POST", - headers: { - ...mcpAuthHeaders(creds, extraHeaders), - "content-type": "application/json", - }, - body: JSON.stringify(msg), - }); - if (!resp.ok && resp.status !== 202) { - const body = await resp.text(); - log.warn("bridge.post_non_ok", { status: resp.status, body: body.slice(0, 200) }); - } - return resp.status; -} - -function readStdinLines(onLine: (line: string) => void): Promise { - return new Promise((resolve) => { - let buf = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk: string) => { - buf += chunk; - let nl: number; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl).replace(/\r$/, ""); - buf = buf.slice(nl + 1); - if (line.length > 0) onLine(line); - } - }); - process.stdin.on("end", () => resolve()); - process.stdin.on("close", () => resolve()); - // Attaching a `data` listener only starts the flow on a stream that was - // never explicitly paused. The auth-required stub hands off by calling - // `process.stdin.pause()`, and a stream paused that way stays paused no - // matter how many listeners attach — so after an in-session - // `memwal_login` the bridge read NOTHING beyond the requests replayed - // from `pendingLines`, and every later call hung unanswered. Harmless - // on the cold path, where stdin is already flowing. - process.stdin.resume(); - }); -} - -function writeStdoutMessage(msg: RpcMessage): void { - process.stdout.write(JSON.stringify(msg) + "\n"); -} - -/** Run the browser-based login flow inline — same pattern as auth-required - * mode, but available even when creds already exist (so user can re-login, - * switch wallets, or refresh). Returns a click-able URL near-instantly; - * listener stays alive in the background until callback or timeout. */ -async function handleLocalLogin( - config: BridgeConfig, - onCredentials: (creds: MemWalCredentials) => Promise, -): Promise<{ text: string; isError: boolean }> { - const session = startOrReuseLoginFlow( - { - relayerUrl: config.relayerUrl, - webUrl: config.webUrl, - label: config.label, - timeoutMs: resolveLoginTimeoutMs(), - openBrowser: false, - }, - async (creds) => { - await onCredentials(creds); - log.info("memwal_login.bridge.success", { - accountId: creds.accountId, - delegateAddress: creds.delegateAddress, - }); - // The tool call returned the URL long ago, so — exactly as on the - // failure path below — this notification and the banner on the next - // tool result are the only ways left to say the sign-in landed. - writeStdoutMessage({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level: "info", - logger: "memwal-mcp", - data: loginSuccessNotification({ - accountId: creds.accountId, - delegateAddress: creds.delegateAddress, - credentialsPath: credsPath(), - }), - }, - }); - }, - (err) => { - const msg = err instanceof Error ? err.message : String(err); - log.warn("memwal_login.bridge.failed", { msg }); - writeStdoutMessage({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level: "warning", - logger: "memwal-mcp", - data: `Walrus Memory sign-in did not complete: ${msg}. Existing credentials are unchanged; call memwal_login again to retry.`, - }, - }); - }, - ); - - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Listener never started")), - URL_READY_TIMEOUT_MS, - ).unref?.() as never, - ); - - let url: string; - try { - url = await Promise.race([session.url, timeoutPromise]); - } catch (err) { - return { - isError: true, - text: `❌ Failed to start login: ${err instanceof Error ? err.message : String(err)}`, - }; - } - - return { - isError: false, - // Read from disk, never assumed from the mode. The bridge usually runs - // with credentials, but `memwal_logout` in this same session deletes - // them and login is intercepted before the signed-out guard — claiming - // "already signed in" there tells the user logout did not take. - text: loginPrompt({ - url, - credentialsPath: credsPath(), - signedIn: loadCreds() !== null, - }), - }; -} - -/** Sign out by clearing the local credentials file. Does NOT revoke the - * on-chain delegate key — that requires a separate dashboard action. */ -function handleLocalLogout(): { text: string; isError: boolean } { - try { - const cleared = clearCreds(); - log.info("memwal_logout.bridge.success", { - removedPath: cleared.removedPath ?? null, - fallbackPath: cleared.fallbackPath ?? null, - }); - if (!cleared.removedPath) { - return { - isError: false, - // The bridge only runs with credentials loaded, so a missing - // file here still means a live in-memory session to tear down — - // exactly the GH #616 case. Say so rather than implying nothing - // happened. - text: - `✅ Already signed out. No credentials at \`${credsPath()}\`, and this ` + - `connection's memory session has been closed — memory tools will refuse ` + - `to run until you sign in again.`, - }; - } - return { - isError: false, - text: [ - `✅ 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.`, - ...(cleared.fallbackPath - ? [ - ``, - `**Still signed in elsewhere:** \`${cleared.fallbackPath}\` remains and is ` + - `what the next run loads, under a possibly different account. Remove ` + - `that file too to sign out everywhere.`, - ] - : []), - ``, - `**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.`, - ``, - `Call \`memwal_login\` to sign in again with the same or a different wallet.`, - ].join("\n"), - }; - } catch (err) { - return { - isError: true, - text: `❌ Logout failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } -} - -/** - * A completed sign-in waiting to be reported to the client. - * - * Set when credentials are adopted — either mid-session via `adoptCredentials` - * or on the cold hand-off from the auth-required stub, which is why this is - * module state with a setter rather than a local inside `runBridge`: on the - * cold path the sign-in happens before the bridge exists. - * - * Consumed by {@link takePendingLoginSuccess}, so it can only ever be reported - * once. - */ -let pendingLoginSuccess: LoginSuccessInfo | null = null; - -/** Record a completed sign-in for the next tool result to carry. */ -export function notePendingLoginSuccess(info: LoginSuccessInfo): void { - pendingLoginSuccess = info; -} - -/** Read the pending sign-in AND clear it — the read is the consumption. */ -function takePendingLoginSuccess(): LoginSuccessInfo | null { - const pending = pendingLoginSuccess; - pendingLoginSuccess = null; - return pending; -} - -/** - * Prefix the sign-in banner onto a tool result, if one is pending. - * - * Only ever called for a `tools/call` reply. A `tools/list` or `ping` response - * would consume the banner into somewhere the user never reads it, so the - * caller checks which request is being answered first. - */ -function applyPendingLoginSuccess(value: RpcMessage): void { - const result = value.result as { content?: unknown } | undefined; - if (!result || typeof result !== "object" || !Array.isArray(result.content)) return; - - const first = result.content[0] as { type?: string; text?: string } | undefined; - if (!first || first.type !== "text" || typeof first.text !== "string") return; - - const pending = takePendingLoginSuccess(); - if (!pending) return; - - first.text = `${loginSuccessNotice(pending)}${first.text}`; - log.info("bridge.login_success_notice_attached", { accountId: pending.accountId }); -} - -/** - * Open the SSE bridge and forward stdio ↔ relayer until stdin closes. - * - * On SSE drop (idle timeout in the Rust proxy / undici keep-alive / network - * blip), we transparently reopen the stream — the relayer issues a fresh - * sessionId, we route subsequent POSTs there. stdin stays open the whole - * time, so the MCP client (Cursor / Claude Desktop / etc.) never sees the - * reconnection. - * - * Two tools (`memwal_login`, `memwal_logout`) are intercepted LOCALLY and - * never forwarded to the relayer — they manipulate the local credentials - * file directly. They appear in `tools/list` by splicing them into the - * relayer's response on the way back to the client. - */ -export async function runBridge( - initialCreds: MemWalCredentials, - config: BridgeConfig, - /** Requests the auth-required server already read off stdin before it - * detected fresh credentials and handed control here (e.g. the - * `memwal_recall` that triggered the hot-handoff). Replayed once the SSE - * stream is up so they're served for real instead of being lost in the - * mode switch — this is what removes the historical "second restart". */ - pendingLines: string[] = [], -): Promise { - note(`Connecting to ${initialCreds.relayerUrl}...`); - log.info("bridge.connecting", { - relayer: initialCreds.relayerUrl, - accountId: initialCreds.accountId, - delegate: initialCreds.delegateAddress, - }); - - // Live handle to the current SSE stream — replaced whenever we reconnect. - // Starts null: we answer `initialize` / `tools/list` LOCALLY and wire stdin - // BEFORE the relayer session exists, so the MCP client's handshake never - // waits on a (possibly slow / cold) relayer round-trip. The connect runs in - // the background; anything that must reach the relayer (`tools/call`) is - // buffered in `pendingForward` until `sse` is live, then flushed. - let sse: SseHandshakeResult | null = null; - - /** The delegate key this bridge is currently authorized to act with. - * Nulled by `invalidateSession()` so signing out drops the key itself - * rather than only setting a flag that every forwarding path has to - * remember to check — after logout there is simply nothing left to sign - * with. `adoptCredentials` republishes it on the next login. */ - let creds: MemWalCredentials | null = initialCreds; - /** - * Best-effort `x-memwal-client` / `x-memwal-client-version` forwarded to - * the sidecar. Seeded from `lastClientInfoHeaders()`, which is only - * populated after the auth-required → bridge handoff (that path does not - * replay `initialize`). On a normal already-signed-in start this object - * is empty at first SSE connect: `connectInBackground` opens the stream - * before stdin is wired. Headers are filled when we see `initialize` and - * then land on reconnects / later POSTs. The sidecar treats the MCP - * handshake (`initialize.clientInfo`) as the authoritative source. - */ - const extraHeaders: Record = { ...lastClientInfoHeaders() }; - - let stdinClosed = false; - /** Set by `memwal_logout`. Unlike `stdinClosed` the process stays up and - * the client keeps talking to us — `memwal_login` must still work — but the - * relayer session is torn down and must never be re-established with the - * credentials the user just deleted. Every connect/reconnect path therefore - * checks this alongside `stdinClosed`; `adoptCredentials` clears it when a - * new login lands. */ - let loggedOut = false; - /** Set when the relayer 401s the handshake, cleared on the next successful - * connect. While set, requests fail fast with `UNAUTHORIZED_FAILURE` rather - * than parking in `pendingForward` behind a connect that cannot succeed. */ - let credentialsRejected = false; - /** Resolves once a post-logout `memwal_login` has published a fresh session - * (or stdin closed). The server pump parks on this instead of exiting, so - * signing back in resumes streaming without the user restarting their MCP - * client. Recreated on every logout; null while signed in. */ - let logoutPark: Promise | null = null; - let releaseLogoutPark: (() => void) | null = null; - let reconnectAttempt = 0; - let reconnectPromise: Promise | null = null; - let firstConnectDone = false; - /** Wall-clock instant before which the relayer told us (HTTP 429) not to - * open another session. Both retry loops floor their backoff at this, so a - * throttle survives across the separate `reconnect()` calls that would - * otherwise each start from a fresh 500ms. 0 = not throttled. */ - let throttledUntilMs = 0; - /** One "we are being throttled" note per throttle episode. A sustained cap - * would otherwise print a line per retry cycle for as long as it lasts. */ - let throttleNoticed = false; - /** Why the last handshake attempt failed, and when the run of failures - * started. Kept so a request that ages out while buffered can say what - * it was actually waiting on instead of a generic "unavailable" — the - * user-visible half of WALM-618, where the bridge retried in silence and - * a `remember` looked like it was just slow. Cleared on every success. */ - let lastHandshakeError: string | null = null; - let handshakeFailingSince: number | null = null; - /** Identifies one "I need a session" episode, and stays the same across - * every retry inside it. Sent on each handshake as `x-memwal-connect-id`. - * - * Without it the relayer sees N unrelated sub-second requests and cannot - * tell they were one user waiting: its per-request id is minted fresh each - * time, so a four-minute wait leaves no four-minute anything in its logs, - * only a scatter of fast 401s and 429s. With it, one grep returns the - * whole episode and the span between first and last line IS the wait. - * Cleared on success, so the next outage starts a new episode. */ - let connectEpisodeId: string | null = null; - const connectHeaders = (): Record => { - connectEpisodeId ??= randomUUID(); - return { - // `x-memwal-client` is only known after `initialize`, and a first - // connect happens before stdin is even wired — so on the attempt - // that matters most the relayer has no idea who is calling. The - // bridge's own version it always knows, and "which build is - // looping" is the actionable half anyway. - "x-memwal-bridge-version": MEMWAL_MCP_VERSION, - ...extraHeaders, - "x-memwal-connect-id": connectEpisodeId, - }; - }; - const endConnectEpisode = (): void => { - connectEpisodeId = null; - }; - const noteHandshakeFailure = (reason: string): void => { - lastHandshakeError = reason; - handshakeFailingSince ??= Date.now(); - }; - const clearHandshakeFailure = (): void => { - lastHandshakeError = null; - handshakeFailingSince = null; - }; - /** Bumped when the live SSE session is aborted or replaced so queued - * POSTs captured against a stale URL are skipped (reconnect replays). */ - let sessionEpoch = 0; - /** One in-flight POST per SSE session — overlapping POSTs drop the stream. */ - let postChain: Promise = Promise.resolve(); - function enqueuePost(fn: () => Promise): Promise { - const run = postChain.then(fn, fn); - postChain = run.then( - () => undefined, - () => undefined, - ); - return run; - } - function postIfCurrent( - epoch: number, - postUrl: string, - msg: RpcMessage, - postCreds: MemWalCredentials, - ): Promise { - if (epoch !== sessionEpoch) return Promise.resolve(0); - // Marked before the await, not after. Once the POST is issued we can - // no longer prove the call did not run, so it must keep the full call - // timeout even if the socket then fails — failing it early is what - // invites a duplicate `remember`. - if (msg.id !== undefined && msg.id !== null) { - const tracked = inFlight.get(msg.id); - if (tracked) tracked.sent = true; - } - return postMessage(postUrl, msg, postCreds, extraHeaders).then((status) => { - // 404 is the relayer saying that session does not exist, so the - // message was discarded rather than routed: it provably did not - // run, and the request goes back to being never-sent. - // - // This is not a corner case. `sse` is not cleared when the server - // pump hits EOF — it keeps pointing at the dead session until a - // reconnect succeeds — so a call arriving during a mid-session - // outage takes the POST path, posts to the stale URL, and gets - // exactly this. Without the reset it would be marked sent and - // wait out the full call timeout, which is the WALM-618 symptom - // the stalled deadline exists to remove. - if (status === 404 && msg.id !== undefined && msg.id !== null) { - const tracked = inFlight.get(msg.id); - if (tracked) tracked.sent = false; - } - return status; - }); - } - let credentialGeneration = 0; - let activeCredentialGeneration = 0; - - /** Callbacks to run once, the moment stdin closes — used to wake anything - * parked on a timer (e.g. the connect-retry backoff) so shutdown is prompt - * instead of waiting out the timer. `markStdinClosed` is the single writer - * of `stdinClosed`; call it instead of assigning the flag directly. */ - const stdinCloseListeners = new Set<() => void>(); - /** Register a shutdown callback. Returns an unregister fn so a caller that - * only cares about shutdown *while it's parked* (e.g. one backoff sleep) can - * detach when it wakes normally — otherwise the set would grow one stale - * closure per retry for the whole session. Fires immediately if already - * closed (unregister is then a no-op). */ - function onStdinClose(fn: () => void): () => void { - if (stdinClosed) { - fn(); - return () => {}; - } - stdinCloseListeners.add(fn); - return () => stdinCloseListeners.delete(fn); - } - function markStdinClosed(): void { - if (stdinClosed) return; - stdinClosed = true; - // Wake a pump parked on logout, or shutdown would block on a login - // that is never coming. - releaseLogoutPark?.(); - releaseLogoutPark = null; - for (const fn of stdinCloseListeners) { - try { - fn(); - } catch { - /* listener failure must not block shutdown */ - } - } - stdinCloseListeners.clear(); - } - - /** Requests that arrived before the relayer session came up. Held here and - * flushed in order once `sse` is live. `tools/call` (and any other request - * that must reach the relayer) lands here; `tools/list` and - * `memwal_login|logout` are answered locally and never buffered. `initialize` - * IS buffered (to forward upstream for capability negotiation) but is never - * failed back — `failRequest` skips `method === "initialize"`. */ - const pendingForward: RpcMessage[] = []; - - /** True while `flushPendingForward` is draining the buffer after the first - * connect. New stdin requests that arrive mid-drain must keep buffering - * rather than posting directly, or they'd overtake still-queued items and - * break arrival order. */ - let flushing = false; - - /** Resolves the first time the SSE stream is up (or when stdin closes before - * that ever happens). `serverPump` waits on this before reading from - * `sse.iter`; after it resolves, `sse` is either a live handle or null - * (stdin closed) — the pump loop guards on both. Idempotent. */ - let signalFirstConnect: () => void = () => {}; - let firstConnectSignaled = false; - const firstConnect = new Promise((r) => { - signalFirstConnect = () => { - if (firstConnectSignaled) return; - firstConnectSignaled = true; - r(); - }; - }); - - /** Expected-suppression COUNT per id, for requests we answered locally but - * still forwarded upstream (currently just `initialize`, so the relayer - * session negotiates capabilities). The upstream reply must be dropped in - * the pump — the client already has our local reply, and a second response - * for the same id corrupts its JSON-RPC state. - * - * A count (not a bare Set) so suppression is EXACT and self-limiting: we - * expect exactly one upstream reply per forward, so we increment on each - * forward (initial + every reconnect replay) and decrement on each dropped - * reply, removing the id at zero. Once the initialize replies are all - * consumed, the id stops suppressing — so a client that later REUSES the - * initialize id for a real request gets that request's genuine reply - * (result OR error) through, instead of it being swallowed forever. */ - const suppressUpstreamReplies = new Map(); - - /** IDs we've already answered with a shutdown "unavailable" envelope - * (`failRequest`). If a late upstream reply for one of these still arrives — - * e.g. a flush-404 reconnect re-posted the request onto a live session that - * answers just as we were closing out at shutdown — the pump must DROP it, - * or the client would get two responses for one id. */ - const closedOutIds = new Set(); - - const expectSuppressedReply = (id: string | number): void => { - suppressUpstreamReplies.set(id, (suppressUpstreamReplies.get(id) ?? 0) + 1); - }; - /** Consume one expected suppression for `id`. Returns true if the reply - * should be dropped (an outstanding local-answer suppression existed). */ - const consumeSuppressedReply = (id: string | number): boolean => { - const n = suppressUpstreamReplies.get(id); - if (!n) return false; - if (n <= 1) suppressUpstreamReplies.delete(id); - else suppressUpstreamReplies.set(id, n - 1); - return true; - }; - - // In-flight requests pending a response. We replay them after a forced - // reconnect so a server-side session swap doesn't strand a tool call - // forever waiting for a reply that will never come. Notifications - // (no id) and responses (no method) are not tracked. - // `startedAt` is never refreshed, not even by a replay: a reconnect loop - // would otherwise keep pushing the deadline out. - const inFlight = new Map(); - const callTimeoutMs = resolveCallTimeoutMs(); - const stalledHandshakeMs = resolveStalledHandshakeMs(callTimeoutMs); - - /** IDs of `tools/list` requests we've forwarded to the relayer. When - * the response comes back through the SSE pump, we splice in the - * locally-served `memwal_login` + `memwal_logout` tools so the MCP - * client surfaces them in its tool palette. */ - const pendingListIds = new Set(); - - /** IDs of forwarded `memwal_health` calls, each against the relayer URL the - * call went out on. Captured at send time rather than read at reply time so - * a reconnect that swapped credentials mid-flight cannot label the answer - * with a relayer it did not come from. */ - const pendingHealthIds = new Map(); - - /** Record a 429 and tell the user ONCE that this is a rate limit rather - * than a broken config — the distinction the MCP host cannot make for - * itself, and the reason a throttled bridge reads as "memwal is down". */ - function noteThrottled(err: RelayerThrottledError): void { - throttledUntilMs = Math.max(throttledUntilMs, Date.now() + err.retryAfterMs); - log.warn("bridge.relayer_throttled", { - retryAfterMs: err.retryAfterMs, - serverAdvised: err.serverAdvised, - err: err.message, - }); - if (throttleNoticed) return; - throttleNoticed = true; - const seconds = Math.max(1, Math.round(err.retryAfterMs / 1000)); - note( - `Relayer is rate-limiting new MCP sessions (HTTP 429). This is a ` + - `throttle, not a bad config or bad credentials — retrying in ` + - `${seconds}s. Memory tools start working once a session opens.` + - (err.serverAdvised - ? "" - : " The cap counts concurrent sessions, so closing another " + - "MCP client using this account clears it sooner."), - ); - } - - /** Reopen the SSE stream and replay outstanding `inFlight` requests against - * the fresh session. All callers await the SAME reconnect via - * `reconnectPromise` — returning immediately while one is active would let - * the server pump spin on the aborted stream and let client messages race - * the stale POST URL. Any reconnect replays the WHOLE `inFlight` map, so - * callers must treat every id-bearing request as reconnect-owned and never - * re-post it themselves. `immediate` skips the backoff (used right after a - * login credential swap). Credential-generation checks discard a session - * whose key rotated mid-handshake. */ - async function reconnect(reason: string, immediate = false): Promise { - if (stdinClosed || loggedOut) return; - if (reconnectPromise) return reconnectPromise; - - reconnectPromise = (async () => { - sessionEpoch += 1; - try { - sse?.abort(); - } catch { - /* already dead */ - } - // `immediate` (a login credential swap) still bypasses everything, - // throttle included: that path trades a possible extra 429 for a - // re-login that doesn't stall behind a multi-second floor. - const backoff = immediate - ? 0 - : Math.max( - Math.min(15_000, 500 * Math.pow(2, reconnectAttempt)), - throttledUntilMs - Date.now(), - ); - reconnectAttempt += 1; - log.warn("bridge.reconnecting", { - reason, - backoffMs: backoff, - attempt: reconnectAttempt, - }); - // Sleep, but wake immediately on stdin close so shutdown isn't held - // up for the whole backoff; unref'd so the timer never keeps the - // event loop alive on its own (mirrors the connect-retry backoff). - if (backoff > 0) { - await new Promise((resolve) => { - const timer = setTimeout(() => { - unregister(); - resolve(); - }, backoff); - timer.unref?.(); - const unregister = onStdinClose(() => { - clearTimeout(timer); - resolve(); - }); - }); - } - // Hoisted so the catch below can ask whether the credentials moved - // since the handshake that threw was opened — a 401 for a key a - // login has already replaced says nothing about the new one. - let openingGeneration = credentialGeneration; - try { - while (!stdinClosed && !loggedOut) { - openingGeneration = credentialGeneration; - const openingCreds = creds; - // Signed out between the guard above and here: the key is - // gone, so there is nothing to authorize a new session - // with. Belt-and-braces against `loggedOut` alone. - if (!openingCreds) break; - const candidate = await openSseStream( - openingCreds.relayerUrl, - openingCreds, - connectHeaders(), - ); - - // Logout can also land mid-handshake. Same reasoning as the - // stale-credentials case below, except there is no new key - // to reconnect with — drop the session and stop. - if (loggedOut) { - candidate.abort(); - log.info("bridge.reconnect_discarded_signed_out", {}); - break; - } - - // Login can finish while an older handshake is awaiting its - // endpoint event. Never publish that stale session: its GET - // used the old key, while subsequent POSTs would use the new - // key and fail session authentication. - if (openingGeneration !== credentialGeneration) { - candidate.abort(); - log.info("bridge.reconnect_discarded_stale_credentials", { - openingGeneration, - credentialGeneration, - }); - continue; - } - - sessionEpoch += 1; - sse = candidate; - firstConnectDone = true; - activeCredentialGeneration = openingGeneration; - reconnectAttempt = 0; - throttledUntilMs = 0; - throttleNoticed = false; - clearHandshakeFailure(); - endConnectEpisode(); - // An accepted handshake retires any earlier rejection — - // `memwal_login` re-registers a key and lands here, not on - // the background connect's publish path, so clearing only - // there would leave every later request refused (WALM-602). - credentialsRejected = false; - // Usually a no-op: the pump is past `firstConnect` by the - // time anything reconnects. It is NOT a no-op when this is - // the first session to exist at all — a login after the - // saved key was rejected — and without it the pump would - // stay parked until the background connect's backoff - // happened to expire, with nothing draining this stream. - signalFirstConnect(); - log.info("bridge.reconnected", { - relayer: openingCreds.relayerUrl, - replayCount: inFlight.size, - // The count alone cannot tell "nothing was pending" - // from "the entry was dropped early" — the ambiguity - // behind WALM-328's unexplained `replayCount: 0`. - inFlight: Array.from(inFlight.entries()).map(([id, entry]) => ({ - id, - method: entry.msg.method ?? null, - })), - }); - // Replay any requests that haven't been answered yet against the - // fresh session. Iterate over a snapshot — postMessage is async - // and the SSE pump may delete entries concurrently as replies - // start arriving on the new session. - for (const [id, entry] of Array.from(inFlight.entries())) { - // Replay awaits a POST per entry, so a logout can land - // partway through this loop. The snapshot and - // `openingCreds` both predate it, so without this the - // remaining entries would still go out under the key the - // user just deleted — `invalidateSession` clearing - // `inFlight` cannot stop a snapshot already taken. - if (loggedOut || openingGeneration !== credentialGeneration) { - log.info("bridge.replay_halted_signed_out", { id }); - break; - } - const msg = entry.msg; - try { - // A replayed `initialize` produces a fresh upstream - // reply on the NEW session that must also be dropped. - // REPLACE (not stack) any pending suppression for this - // id: the old session was aborted, so its initialize - // reply will never arrive to consume its own arm. - // Re-arming without clearing would leave that orphaned - // arm forever, and a later reused id would have its - // real reply wrongly dropped. Reset to exactly one — - // the single reply the new session will send. - if (msg.method === "initialize" && msg.id != null) { - suppressUpstreamReplies.delete(msg.id); - expectSuppressedReply(msg.id); - } - const epoch = sessionEpoch; - const postUrl = sse.postUrl; - const status = await enqueuePost(() => - postIfCurrent(epoch, postUrl, msg, openingCreds), - ); - log.info("bridge.replayed", { id, status }); - } catch (err) { - log.error("bridge.replay_failed", { - id, - err: err instanceof Error ? err.message : String(err), - }); - } - } - // Credentials can also rotate while replay awaits POSTs. - // In that case this candidate is already stale even though - // it passed the first generation check. - if (openingGeneration !== credentialGeneration) { - candidate.abort(); - // The replay above armed one initialize suppression for - // THIS (now-discarded) candidate; its reply will never - // arrive to consume it. Clear those arms so the count - // doesn't leak if the loop exits before another replay - // re-arms (a leaked arm would swallow a later reused-id - // reply). A surviving candidate re-arms fresh next pass. - for (const [, entry] of inFlight) { - if (entry.msg.method === "initialize" && entry.msg.id != null) { - suppressUpstreamReplies.delete(entry.msg.id); - } - } - continue; - } - break; - } - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - // A 429 must outlive this call: reconnect() gives up after one - // failure, so without recording the deadline the next caller - // would compute a fresh sub-second backoff and hammer the cap. - if (err instanceof RelayerThrottledError) noteThrottled(err); - noteHandshakeFailure(reason); - log.error("bridge.reconnect_failed", { err: reason }); - // A key revoked mid-session lands here rather than on the - // background connect, and retrying cannot fix it either. Answer - // the replay set now instead of letting the orphan sweeper hand - // back "connection dropped, please retry" four minutes later — - // the same WALM-602 symptom, one path over. - // - // This does not strand the transient case: the server pump is - // still looping on the dead stream, so it keeps driving - // `reconnect()` on its own growing backoff, and the publish - // above clears the flag the moment a handshake is accepted. - // - // It also takes precedence over the stalled-handshake deadline - // added here: a 401 is terminal until the user logs in again, - // so there is nothing to gain by waiting out even the short - // deadline for it. - if ( - err instanceof RelayerUnauthorizedError && - openingGeneration === credentialGeneration - ) { - credentialsRejected = true; - failInFlightRequests("credentials rejected", UNAUTHORIZED_FAILURE); - } - // Try again on the next stdin message rather than spinning. - } - })(); - - try { - await reconnectPromise; - } finally { - reconnectPromise = null; - } - } - - async function adoptCredentials(nextCreds: MemWalCredentials): Promise { - // `null` after a logout — treated as an account change, which is the - // safe direction: it purges rather than replays. (`invalidateSession` - // already emptied both queues, so the purge is a no-op there.) - const previousAccountId = creds?.accountId ?? null; - const accountChanged = previousAccountId !== nextCreds.accountId; - - // Never replay an operation authorized for account A against account B. - // Return explicit retryable errors instead; the caller can decide which - // operations belong in the newly-selected account. - if (accountChanged) { - try { - sse?.abort(); - } catch { - /* already dead */ - } - // Purge EVERY structure that holds an account-A request. `inFlight` - // (tracked requests) AND `pendingForward` (cold-start / mid-flush - // buffered requests) — the latter is unique to the cold-start path - // and would otherwise be flushed to account B's session (a - // cross-account replay) since the flush posts with the current - // `creds`. For each, reply once with a retryable error and stop - // tracking; never write a second reply for a locally-answered - // `initialize`. Keep its one-shot suppress arm so a queued - // upstream initialize reply is consumed. Do not put initialize in - // closedOutIds — a later reused id must still get a real reply. - const purge = (msg: RpcMessage): void => { - if (msg.id == null) return; // notification — nothing to reply to - pendingListIds.delete(msg.id); - pendingHealthIds.delete(msg.id); - if (msg.method === "initialize") { - return; - } - suppressUpstreamReplies.delete(msg.id); - // A request can be in BOTH inFlight and pendingForward (cold-start - // dual-tracking), so guard against answering the same id twice. - if (closedOutIds.has(msg.id)) return; - // Record the id so a late reply for it (e.g. one already - // in-flight on the aborted session, or a racing replay) is - // dropped by the pump rather than becoming a second response. - closedOutIds.add(msg.id); - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - error: { - code: -32001, - message: - "Walrus Memory account changed during login; retry this request for the new account", - }, - }); - }; - for (const [, entry] of Array.from(inFlight.entries())) purge(entry.msg); - inFlight.clear(); - for (const msg of pendingForward.splice(0, pendingForward.length)) purge(msg); - } else { - // Same account: reconnect() owns inFlight. Drop id-bearing - // pendingForward so a login mid-flush cannot POST the same - // remember/recall again after replay. - const leftover = pendingForward.filter((m) => m.id == null); - pendingForward.length = 0; - pendingForward.push(...leftover); - } - - creds = nextCreds; - credentialGeneration += 1; - reconnectAttempt = 0; - // Lift the logout halt BEFORE reconnecting — reconnect() refuses to run - // while it is set. The parked pump is released further down, once the - // new session actually exists. - loggedOut = false; - log.info("bridge.credentials_updated", { - previousAccountId, - accountId: creds.accountId, - delegate: creds.delegateAddress, - }); - await reconnect("login-credentials-updated", true); - // Covers the narrow case where this update joined a reconnect just as - // its promise was resolving, after its final generation check. - if (activeCredentialGeneration !== credentialGeneration) { - await reconnect("login-credentials-generation-mismatch", true); - } - // Session is live again: wake a pump parked by a previous logout. - releaseLogoutPark?.(); - releaseLogoutPark = null; - logoutPark = null; - - // Queue the confirmation only once the session is actually live, so - // the banner cannot claim an authenticated connection before there is - // one. It rides out on the next `tools/call` result. - notePendingLoginSuccess({ - accountId: creds.accountId, - delegateAddress: creds.delegateAddress, - credentialsPath: credsPath(), - }); - } - - /** - * Tear the relayer session down after a successful `memwal_logout`. - * - * Deleting the credentials file is not revocation on its own: the bridge - * holds the delegate key in memory and owns a live SSE session, so without - * this every later memory tool call would still be forwarded and executed - * under the key the user just removed (GH #616). - * - * `loggedOut` is set FIRST so the abort below cannot race the pump into - * `reconnect("server-pump-eof")` and immediately re-authorize a new session - * with those same in-memory credentials. - */ - function invalidateSession(): void { - if (loggedOut) return; - loggedOut = true; - logoutPark = new Promise((resolve) => { - releaseLogoutPark = resolve; - }); - try { - sse?.abort(); - } catch { - /* already dead */ - } - sse = null; - // Drop the delegate key itself, not just the flag. Revocation that - // rests only on `loggedOut` is one missed check away from forwarding - // under the key the user deleted; with `creds` null there is nothing - // left to sign with and every forwarding path fails closed instead. - creds = null; - // Bump the generation so any handshake or replay that captured the old - // key before this point discards its work on its next check, exactly as - // it would for a mid-flight key rotation. - credentialGeneration += 1; - // Answer everything still outstanding rather than stranding it: these - // were authorized under the old key and must not be replayed later. - for (const [, entry] of Array.from(inFlight.entries())) { - failRequest(entry.msg, "signed out", SIGNED_OUT_FAILURE); - } - inFlight.clear(); - for (const msg of pendingForward.splice(0, pendingForward.length)) { - failRequest(msg, "signed out", SIGNED_OUT_FAILURE); - } - log.info("bridge.session_invalidated", { reason: "logout" }); - } - - // Server → client: stream SSE messages to stdout. Loop forever, restart - // pump on stream end (which means SSE got cut → we already reconnected). - const serverPump = (async () => { - // Nothing to pump until the first relayer session is up. `firstConnect` - // resolves only on a SUCCESSFUL connect (the background connector - // retries failures with backoff), unless stdin closed first — in which - // case `sse` stays null and we exit the loop immediately. - await firstConnect; - while (!stdinClosed) { - try { - // Snapshot the current stream. `sse` is non-null here: set before - // signalFirstConnect(), and reconnect() only ever replaces it with - // another live handle. Reading through a local keeps us on one - // stream for the duration of this drain; a reconnect swaps `sse` - // and we pick up the new handle on the next outer iteration. - // Cast: TS control-flow narrows `sse` to `null` in the outer - // scope because every non-null assignment happens inside a - // sibling closure (connectInBackground / reconnect) that TS - // analyzes independently. At runtime `sse` is a live handle here. - const stream = sse as SseHandshakeResult | null; - if (!stream) { - // Signed out: park rather than exit. Exiting would end the - // pump for good, so a later `memwal_login` would reconnect a - // session with nothing draining it — the client would hang - // instead of recovering. `logoutPark` resolves once the new - // session is published (or stdin closes). - // Cast for the same reason as `stream` above: every - // assignment to `logoutPark` happens in a sibling closure, - // so TS narrows it to `null` here. No `!stdinClosed` guard: - // the `while` above already established it and nothing is - // awaited in between, so it cannot have changed. - if (loggedOut) { - await (logoutPark as Promise | null); - continue; - } - break; // stdin closed before we ever connected - } - while (true) { - const { value, done } = await stream.iter.next(); - if (done) break; - // Drop the upstream reply to a request we already answered - // locally (e.g. `initialize`). Writing it would be a second - // response for the same id. We consume exactly ONE expected - // suppression per id (see suppressUpstreamReplies), so once - // the initialize reply(s) are drained the id stops - // suppressing — a client that later reuses that id for a real - // request still gets THAT request's reply (result or error). - if ( - value && - value.id !== undefined && - value.id !== null && - (value.result !== undefined || value.error !== undefined) && - consumeSuppressedReply(value.id) - ) { - inFlight.delete(value.id); - continue; - } - // Drop a late reply for an id we already closed out at - // shutdown — writing it would be a second response for that - // id (see closedOutIds / failRequest). - if ( - value && - value.id !== undefined && - value.id !== null && - (value.result !== undefined || value.error !== undefined) && - closedOutIds.has(value.id) - ) { - inFlight.delete(value.id); - continue; - } - // Which request this reply answers. Captured BEFORE the - // `inFlight.delete` below drops the entry, so the sign-in - // banner can tell a `tools/call` result from a `tools/list` - // or a `ping` and avoid being consumed by a response the - // user never reads. - const answeredMethod = - value && value.id !== undefined && value.id !== null - ? inFlight.get(value.id)?.msg.method - : undefined; - - // Clear in-flight tracking once the response lands. - if ( - value && - (value.result !== undefined || value.error !== undefined) && - value.id !== undefined && - value.id !== null - ) { - inFlight.delete(value.id); - } - // Splice local tools into `tools/list` responses so - // memwal_login + memwal_logout appear in the client's - // tool palette alongside the relayer-side tools. - if ( - value && - value.id !== undefined && - value.id !== null && - pendingListIds.has(value.id) && - value.result && - typeof value.result === "object" - ) { - pendingListIds.delete(value.id); - const result = value.result as { tools?: unknown }; - if (Array.isArray(result.tools)) { - // Strip any locally-served name from the upstream set - // before appending ours, so a relayer that ever - // advertises login/logout itself can't produce a - // duplicate tool name. Mirrors LOCAL_TOOLS_LIST. - const upstream = (result.tools as { name?: string }[]).filter( - (t) => !LOCAL_TOOL_NAMES.has(t.name ?? ""), - ); - result.tools = [...upstream, ...LOCAL_TOOL_DEFINITIONS]; - } - } - if ( - value && - value.id !== undefined && - value.id !== null && - pendingHealthIds.has(value.id) && - value.result && - typeof value.result === "object" - ) { - const dialled = pendingHealthIds.get(value.id); - pendingHealthIds.delete(value.id); - if (dialled !== undefined) { - annotateHealthResult( - value.result as { content?: unknown; isError?: unknown }, - dialled, - ); - } - } - // Health annotation runs BEFORE the sign-in banner. Both - // rewrite the same first text block, and annotateHealthResult - // replaces the first `relayer=` it finds — so a banner - // prefixed first would be the thing it rewrote if that text - // ever names a relayer. - if (answeredMethod === "tools/call") { - applyPendingLoginSuccess(value); - } - writeStdoutMessage(value); - } - } catch (err) { - log.error("bridge.server_pump_error", { - err: err instanceof Error ? err.message : String(err), - }); - } - // Deliberately NOT short-circuited on `loggedOut`: breaking here - // would end the pump for good and strand a later re-login. Fall - // through instead — `reconnect()` no-ops while signed out, and the - // next iteration parks on `logoutPark` at the top of the loop. - if (stdinClosed) break; - // Stream ended. If a reconnect is ALREADY in progress (e.g. the - // flush hit a 404), await THAT one rather than hammering reconnect() - // — otherwise this loop would spin on the dead stream's immediate - // `done`. reconnect() itself returns the shared reconnectPromise when - // one is active, so awaiting it here is enough; on the next - // iteration `sse` has been swapped to the fresh session and we - // resume reading. If no reconnect is in progress, this starts one. - await reconnect("server-pump-eof"); - } - })(); - - // Client → server: forward stdin lines as POST messages. On 404 (the - // relayer doesn't know our sessionId — happens right after a reconnect - // if the message races the new handshake), trigger another reconnect. - const handleClientLine = (line: string): void => { - void (async () => { - try { - const msg = JSON.parse(line) as RpcMessage; - - // Answer `initialize` LOCALLY and instantly so the MCP client's - // handshake never waits on the relayer connect (the cold-start - // bug). We STILL forward it upstream (below) so the relayer - // session negotiates capabilities — but suppress that upstream - // reply, since the client already has this one. - if (msg.method === "initialize" && msg.id != null) { - const clientInfo = rememberInitializeClientInfo(msg.params); - if (clientInfo) { - Object.assign(extraHeaders, clientInfoHeaders(clientInfo)); - log.info("bridge.agent_client", { - clientName: clientInfo.name, - clientVersion: clientInfo.version, - }); - } - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - result: buildLocalInitializeResult(msg.params), - }); - // Signed out, or the key was rejected: the local reply is the - // whole answer. Both refuse further down instead of - // forwarding, so do not arm a suppression that no reply can - // ever consume — a leaked arm would swallow the real reply - // if the client later reuses this id. - if (loggedOut || credentialsRejected) return; - // Expect exactly one upstream reply to drop for this forward. - expectSuppressedReply(msg.id); - // Fall through: forward/buffer the initialize upstream too. - } - - // Answer `tools/list` LOCALLY at cold start (before the relayer - // session exists) so tool discovery unblocks immediately. Once - // connected we emit `notifications/tools/list_changed` and the - // client re-lists — that re-list is forwarded upstream and gets - // the real tool set spliced (handled further down + in the pump). - if (msg.method === "tools/list" && msg.id != null && sse === null) { - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - result: LOCAL_TOOLS_LIST, - }); - return; - } - - // Local interception: `memwal_login` and `memwal_logout` - // are handled here, never sent to the relayer. The user - // can call them any time to re-auth or sign out without - // having to remove + re-add the MCP server. - if (msg.method === "tools/call" && msg.id != null) { - const params = (msg.params ?? {}) as { name?: string }; - // Tool NAME only, never `arguments` — memory text is the - // user's private data and must not reach a log file. - // Without this the only trace of a call is the host's own - // `method="tools/call" id=N` line, which cannot say WHICH - // tool ran. Scoring the WALM-368 T1-T3 cases needs exactly - // that: "remember never fired" and "remember fired and - // failed" are different bugs that looked identical. - log.info("bridge.tool_call", { - tool: params.name ?? null, - id: msg.id, - }); - if (params.name === "memwal_login") { - const result = await handleLocalLogin(config, adoptCredentials); - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: result.text }], - isError: result.isError, - }, - }); - return; - } - if (params.name === "memwal_logout") { - const result = handleLocalLogout(); - // Tear the session down before replying, so by the time - // the client is told it is signed out that is actually - // true. Only on success: if the credentials file could - // not be removed the user is still signed in. - if (!result.isError) invalidateSession(); - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: result.text }], - isError: result.isError, - }, - }); - return; - } - } - - // Signed out: refuse EVERY remaining request locally, not just - // memory tool calls. `login`/`logout` returned above, and - // `initialize`/`tools/list` are answered locally further up, so - // anything still here would need the delegate key the user - // deleted. Falling through instead would park it in - // `pendingForward` — `sse` is null and `reconnect()` no-ops - // while signed out — where it would either hang the client until - // a login that may never come, or be flushed afterwards under a - // NEW key the client never authorized it against. `failRequest` - // picks the right shape per method: tool-result text for - // `tools/call`, a JSON-RPC error for `ping` and friends, and - // nothing at all for notifications. - if (loggedOut) { - failRequest(msg, "signed out", SIGNED_OUT_FAILURE); - return; - } - - // Credentials rejected: same reasoning as `loggedOut` above. - // `memwal_login` returned locally already, so refusing here - // still leaves the user a way back in. Falling through would - // park the request in `pendingForward` behind a connect loop - // that keeps 401ing, and the client would learn nothing until - // the orphan sweeper's deadline — the WALM-602 symptom. - if (credentialsRejected) { - failRequest(msg, "credentials rejected", UNAUTHORIZED_FAILURE); - return; - } - - // Fill in the configured default namespace for memory tool - // calls that didn't pass one. Mutates msg in place so the - // forwarded — and any replayed-on-reconnect — copy carries it. - applyDefaultNamespace(msg, config.namespace); - - // Track `tools/list` requests so the SSE pump can splice - // our local tools into the upstream response. - if (msg.method === "tools/list" && msg.id != null) { - pendingListIds.add(msg.id); - } - - // Same idea for `memwal_health`: record the relayer this - // session is bound to so the pump can name it on the reply. - if ( - msg.method === "tools/call" && - msg.id != null && - (msg.params as { name?: string } | undefined)?.name === "memwal_health" - ) { - pendingHealthIds.set(msg.id, creds?.relayerUrl ?? config.relayerUrl); - } - - // Track requests (have both method and id) so we can replay - // them on reconnect. Notifications and responses are not - // tracked. - if ( - msg.method !== undefined && - msg.id !== undefined && - msg.id !== null - ) { - inFlight.set(msg.id, { msg, startedAt: Date.now() }); - } - // Relayer session not up yet, OR the post-connect flush is still - // draining — buffer so this request stays behind everything that - // arrived before it (posting directly here would let it overtake - // a still-queued buffered item). The flush (or the next connect) - // forwards it in order. Dropping it would strand the request. - if (flushing || (sse === null && !firstConnectDone)) { - pendingForward.push(msg); - log.info("bridge.buffered_pre_connect", { - method: msg.method, - id: msg.id ?? null, - }); - return; - } - // After the first connect, do not buffer: nothing flushes - // pendingForward once connectInBackground has returned. - if (sse === null) { - await reconnect("sse-missing"); - return; - } - - // A successful background login swaps credentials and SSE - // sessions asynchronously. Wait for that swap before sending a - // new request so it cannot race the stale session URL/key. - if (reconnectPromise) await reconnectPromise; - if (activeCredentialGeneration !== credentialGeneration) { - await reconnect("post-credential-generation-mismatch", true); - } - // A logout can land while the two awaits above are parked. The - // key is gone by then, so answer locally instead of posting. - if (loggedOut || !creds) { - failRequest(msg, "signed out", SIGNED_OUT_FAILURE); - return; - } - if (!sse) { - await reconnect("sse-missing"); - return; - } - const epoch = sessionEpoch; - const postUrl = sse.postUrl; - const postCreds = creds; - const status = await enqueuePost(() => - postIfCurrent(epoch, postUrl, msg, postCreds), - ); - if (status === 404) { - log.warn("bridge.session_stale", { sessionUrl: sse.postUrl }); - // reconnect() itself replays in-flight against the fresh - // session, so no explicit per-message retry is needed. - await reconnect("post-404"); - } - } catch { - log.warn("bridge.stdin_parse_failed", { line: line.slice(0, 120) }); - } - })(); - }; - - /** Flush everything buffered before the session came up, in arrival order, - * then announce the real tool set. Called once, right after the first - * successful connect. `flushing` keeps concurrently-arriving stdin requests - * buffering (rather than posting directly and overtaking the queue); we - * drain until the buffer is empty so those late arrivals are forwarded too. */ - async function flushPendingForward(): Promise { - flushing = true; - try { - if (pendingForward.length > 0) { - log.info("bridge.flushing_pre_connect", { count: pendingForward.length }); - } - while (pendingForward.length > 0) { - if (stdinClosed) { - // Shutting down mid-flush: don't post to a torn-down session. - // Everything still buffered (plus what we've already shifted - // into inFlight but not delivered) is closed out below. - break; - } - // `invalidateSession` nulls both; either one means this queue - // must not be drained onto the relayer. - if (!sse || !creds) break; // lost the session; reconnect replays inFlight - const msg = pendingForward.shift()!; - try { - const epoch = sessionEpoch; - const postUrl = sse.postUrl; - const postCreds = creds; - const status = await enqueuePost(() => - postIfCurrent(epoch, postUrl, msg, postCreds), - ); - if (status === 404) { - // Stale session right after connect. EVERY id-bearing - // request is in `inFlight`, and ANY reconnect — this - // flush's own, or a concurrent `server-pump-eof` one that - // shares the same `reconnectPromise` — replays the whole - // `inFlight` map against the fresh session. So id-bearing - // items are owned by reconnect, period; re-posting them - // from the flush would duplicate them (double write + - // two replies for one id). We therefore drop ALL - // id-bearing items from the queue after reconnect and - // keep only id-less notifications (never in `inFlight`, - // so no reconnect carries them) to re-drain. `await - // reconnect()` resolves the shared reconnectPromise, so - // `inFlight` has been fully replayed by the time we - // decide what's left to send — no matter which caller - // owns the reconnect. - log.warn("bridge.session_stale", { sessionUrl: sse.postUrl }); - if (msg.id == null) pendingForward.unshift(msg); - await reconnect("post-404"); - const notifications = pendingForward.filter((m) => m.id == null); - pendingForward.length = 0; - pendingForward.push(...notifications); - continue; - } - } catch (err) { - log.error("bridge.flush_failed", { - id: msg.id ?? null, - err: err instanceof Error ? err.message : String(err), - }); - } - } - } finally { - flushing = false; - } - // If stdin closed while we were draining, close out anything still open - // (buffered + already-in-inFlight-but-undelivered) so those calls get an - // error envelope instead of hanging until the client's own timeout. - if (stdinClosed) { - failPendingForward("connection lost during shutdown"); - failInFlightRequests("connection lost during shutdown"); - return; - } - // The client discovered tools from our static `tools/list`. Now that the - // real relayer session is up, tell it to re-list so it picks up the - // authoritative upstream set (spliced with login/logout in the pump). - writeStdoutMessage({ - jsonrpc: "2.0", - method: "notifications/tools/list_changed", - }); - } - - /** Write a failure reply for one open request, stop tracking it, and never - * double-answer a locally-answered request. Shared by the buffered - * (`failPendingForward`) and in-flight (`failInFlightRequests`) close-outs, - * and by the orphan sweeper — which passes `opts` because "relayer - * unavailable" would be a lie there: the relayer is fine, one reply just - * never arrived. Skips: - * - notifications (no id → nothing to reply to; also unforwardable now). - * - `initialize` (we already answered it locally; a second response for - * that id would corrupt the client's JSON-RPC state — just untrack). - * Only `tools/call` shaped requests get the tool-result error envelope; any - * other id-bearing request gets a JSON-RPC error object (the correct shape - * for a non-tool request). */ - /** `opts` overrides the default "relayer unavailable" wording for callers - * whose failure is not an outage — logout, for one, where blaming the - * relayer would be actively misleading. */ - function failRequest( - msg: RpcMessage, - reason: string, - opts: { toolText?: string; errorMessage?: string } = {}, - ): void { - if (msg.id == null) return; // notification — nothing to answer - if (msg.method === "initialize") { - // Locally answered already. Never write a second reply for this id. - // Keep any suppress arm so a late upstream initialize result is - // consumed; do not closedOut the id (clients may reuse it later). - inFlight.delete(msg.id); - return; - } - inFlight.delete(msg.id); - // Remember we answered this id, so a late genuine reply (e.g. from a - // flush-404 reconnect that re-posted onto a live session) is dropped by - // the pump instead of becoming a second response for the same id. - closedOutIds.add(msg.id); - if (msg.method === "tools/call") { - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [ - { - type: "text", - text: - opts.toolText ?? - `❌ Walrus Memory relayer unavailable: ${reason}. The memory tool could not run. Please retry shortly.`, - }, - ], - isError: true, - }, - }); - } else { - writeStdoutMessage({ - jsonrpc: "2.0", - id: msg.id, - error: { - code: -32000, - message: - opts.errorMessage ?? - `Walrus Memory relayer unavailable: ${reason}`, - }, - }); - } - } - - function failPendingForward( - reason: string, - opts: { toolText?: string; errorMessage?: string } = {}, - ): void { - const queued = pendingForward.splice(0, pendingForward.length); - for (const msg of queued) failRequest(msg, reason, opts); - } - - /** Close out requests that reached `inFlight` but were never delivered a - * reply — the shutdown counterpart of `failPendingForward`. Used when stdin - * closes mid-flush: items already shifted out of `pendingForward` and posted - * to a torn-down session would otherwise hang, since no upstream reply is - * coming. Idempotent w.r.t. ids already closed out (delete-then-skip). */ - function failInFlightRequests( - reason: string, - opts: { toolText?: string; errorMessage?: string } = {}, - ): void { - for (const entry of Array.from(inFlight.values())) failRequest(entry.msg, reason, opts); - } - - /** How long the current run of handshake failures has lasted, or `null` - * when the last attempt succeeded. */ - function handshakeStalledForMs(now: number): number | null { - return handshakeFailingSince === null ? null : now - handshakeFailingSince; - } - - /** How a request that just hit its deadline should be explained. - * - * Three cases, where the old wording only described one. A request for - * which no POST was ever issued never left this process: no session ever - * carried it. Telling the user the connection "dropped before the result - * came back" points them at the relayer, or at a half-written memory, when - * the truth is that nothing was attempted (WALM-618 — the bridge retried - * in silence, so a `remember` looked like it was merely slow for minutes). - * And a buffered request is only evidence of a *failing* connection when - * one is actually failing: post-connect, `handleClientLine` also buffers - * behind an in-progress flush, on a perfectly healthy session. - * - * Pure: the caller is responsible for dropping a `neverSent` message from - * the buffer, which it must, or a later flush would run the call we just - * said never ran. */ - /** Tools whose call, once POSTed, may have written to Walrus. - * - * The relayer answers these with HTTP 202 and finishes the work in a - * durable queue, so a client-side deadline cancels nothing: the write can - * still land minutes after we have given up waiting for the reply. And - * `/api/remember/bulk` carries no idempotency key — unlike the single - * path — so a blind retry mints a second paid blob that `recall` will then - * hide behind the first. Telling the user to "please retry" here is how a - * lost reply turns into duplicate paid storage. */ - const MUTATING_TOOLS = new Set([ - "memwal_remember", - "memwal_remember_bulk", - "memwal_analyze", - ]); - - /** Name of the tool a tracked request was calling, when it was one. */ - function toolNameOf(msg: RpcMessage): string | null { - if (msg.method !== "tools/call") return null; - const params = msg.params as { name?: unknown } | undefined; - return typeof params?.name === "string" ? params.name : null; - } - - function expiredRequestReport( - neverSent: boolean, - now: number, - tool: string | null, - ): { - reason: string; - opts: { toolText: string; errorMessage: string }; - } { - if (!neverSent) { - // The request reached the relayer. What is missing is the reply, - // and for a write that distinction is the whole message: the work - // may have completed, may still be running, and cannot be assumed - // undone. "Please retry" is only safe advice for a read. - if (tool !== null && MUTATING_TOOLS.has(tool)) { - return { - reason: "no response to a sent write", - opts: { - toolText: - `⚠️ Walrus Memory accepted this ${tool} call but did not return a ` + - "result in time. The write was sent, so it may have completed or may " + - "still be finishing in the background — a timeout here does not cancel " + - "it and does not mean nothing was stored. Do NOT simply repeat the " + - "call: run `memwal_recall` for this content first, and only re-save " + - "what is genuinely missing. Repeating a bulk save that already " + - "landed stores a second paid copy.", - errorMessage: - `Walrus Memory ${tool} was sent but its reply never arrived. The write ` + - "may have completed; verify with recall before retrying.", - }, - }; - } - return { - reason: "no response", - opts: { - toolText: - "❌ Walrus Memory did not answer this call. The request reached the " + - "relayer but the reply never came back. This call only reads, so it is " + - "safe to retry.", - errorMessage: - "Walrus Memory call was orphaned by a reconnect and never " + - "received a response. Safe to retry: this call only reads.", - }, - }; - } - - const stalledForMs = handshakeStalledForMs(now); - if (stalledForMs === null) { - // Buffered on a live session (a flush was draining) and still - // unsent at the deadline. Nothing ran, but nothing is failing - // either — do not invent an outage. - return { - reason: "never left the queue", - opts: { - toolText: - "❌ Walrus Memory never sent this call — it was still queued when " + - "the call timed out, so nothing was stored. Please retry.", - errorMessage: - "Walrus Memory call was still queued when it timed out and was " + - "never sent. Please retry.", - }, - }; - } - - const waited = `for ${Math.round(stalledForMs / 1000)}s`; - const detail = lastHandshakeError ? ` Last handshake error: ${lastHandshakeError}` : ""; - return { - reason: "never reached the relayer", - opts: { - toolText: - `❌ Walrus Memory could not reach the relayer — the MCP connection has ` + - `been failing ${waited}, so this call never ran and nothing was stored.` + - `${detail} Check the relayer, or run \`memwal-mcp login\` if the delegate ` + - `key was revoked, then retry.`, - errorMessage: - `Walrus Memory call never reached the relayer: the MCP connection has ` + - `been failing ${waited}.${detail}`, - }, - }; - } - - /** Close out requests whose deadline has passed. Without this a reply lost - * on a still-healthy stream leaves its request tracked forever. */ - // Same shape as the SSE watchdog's check interval, but capped. - const sweepIntervalMs = Math.min( - MAX_ORPHAN_SWEEP_MS, - Math.max(500, Math.floor(callTimeoutMs / 3)), - ); - const orphanSweeper = setInterval(() => { - const now = Date.now(); - const handshakeStalledMs = handshakeStalledForMs(now); - for (const [id, entry] of Array.from(inFlight.entries())) { - const elapsedMs = now - entry.startedAt; - // Never sent = no POST was ever issued for it. Read from the entry - // rather than from `pendingForward` membership, which only ever - // covered the cold-start window. - const neverSent = entry.sent !== true; - // A call we can prove never left this process, while no working - // connection has existed for `stalledHandshakeMs`, does not need - // the full `callTimeoutMs`: it cannot have executed, so answering - // it early is a no-op the agent can safely retry. Anything that - // was actually sent — or that is queued on a healthy session — - // keeps the full deadline, because there a premature failure - // invites a duplicate write. - const handshakeIsStalled = - handshakeStalledMs !== null && handshakeStalledMs > stalledHandshakeMs; - const deadlineMs = - neverSent && handshakeIsStalled ? stalledHandshakeMs : callTimeoutMs; - if (elapsedMs <= deadlineMs) continue; - // Built only for what actually expired: this walks `pendingForward` - // and interpolates two user-facing strings, and the branch it - // serves fires roughly never. - const { reason, opts } = expiredRequestReport( - neverSent, - now, - toolNameOf(entry.msg), - ); - // Drop it from the buffer before answering: a later successful - // connect would otherwise flush and actually run the call we are - // about to report as never having run. - // - // `initialize` is the exception, as everywhere else here: it was - // answered locally and is only buffered so the relayer session can - // still negotiate capabilities, and `failRequest` writes it no - // reply. Removing it would silently cost that negotiation on the - // first connect after a long outage. - if (neverSent && entry.msg.method !== "initialize") { - // Only buffered requests are in there at all now, so the miss - // is ordinary — `splice(-1, 1)` would drop the last entry. - const queuedAt = pendingForward.indexOf(entry.msg); - if (queuedAt >= 0) pendingForward.splice(queuedAt, 1); - } - log.warn("bridge.call_orphaned", { - id, - method: entry.msg.method ?? null, - elapsedMs, - deadlineMs, - reason, - handshakeStalledMs, - lastHandshakeError, - }); - failRequest(entry.msg, reason, opts); - } - }, sweepIntervalMs); - // unref so the sweeper never holds the event loop open during shutdown. - orphanSweeper.unref?.(); - - // Kick off the relayer connect in the BACKGROUND — do NOT await it before - // wiring stdin below. This is the whole fix: `initialize` / `tools/list` are - // answered locally the moment they arrive, while the (possibly slow / cold) - // relayer round-trip proceeds off the handshake's critical path. - // - // Retry with backoff so a cold-starting relayer eventually connects. We do - // NOT fail buffered requests between attempts: a request that the next - // attempt would serve must not get a spurious "unavailable" error (that - // would also drop the auth-required hot-handoff request). Buffered tool - // calls stay queued and are flushed on the first SUCCESS. They are no - // longer left to the client's own per-tool timeout, though: once no - // connection has existed for `stalledHandshakeMs` the orphan sweeper - // answers them (see `DEFAULT_STALLED_HANDSHAKE_MS`), because a call that - // was never sent cannot have executed and silence helps nobody. On - // shutdown `failPendingForward` closes out anything still open. `initialize` - // is answered locally, so it never blocks and is only forwarded, not failed. - // First connect stays on `openSseStream` + `flushPendingForward` so a - // flush-time 404 still goes through the existing reconnect/replay path. - // It must NOT publish if login already owns `sse`, or if the handshake - // finished after `credentialGeneration` moved — that was the double-flush. - const connectInBackground = (async () => { - let attempt = 0; - while (!stdinClosed && !loggedOut) { - if (reconnectPromise) { - await reconnectPromise; - continue; - } - if (sse) { - signalFirstConnect(); - const notifications = pendingForward.filter((m) => m.id == null); - pendingForward.length = 0; - pendingForward.push(...notifications); - await flushPendingForward(); - return; - } - const openingGeneration = credentialGeneration; - try { - const candidate = await openSseStream(creds.relayerUrl, creds, connectHeaders()); - if (stdinClosed) { - candidate.abort(); - break; - } - // Signed out while this handshake was in flight. The loop guard - // above only runs between iterations, so without this the - // session would be published — an open, authenticated stream - // holding the delegate key the user just deleted. - if (loggedOut) { - candidate.abort(); - break; - } - if (openingGeneration !== credentialGeneration || sse) { - candidate.abort(); - continue; - } - sessionEpoch += 1; - sse = candidate; - firstConnectDone = true; - throttledUntilMs = 0; - throttleNoticed = false; - clearHandshakeFailure(); - endConnectEpisode(); - // A key that was rejected earlier is evidently accepted now - // (re-registered, or the 401 was a transient WAF/rate-limit - // false positive), so stop failing requests fast. - credentialsRejected = false; - note(`Connected. Bridging stdio MCP ↔ ${creds.relayerUrl}`); - log.info("bridge.connected", { relayer: creds.relayerUrl }); - signalFirstConnect(); - await flushPendingForward(); - return; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - noteHandshakeFailure(reason); - attempt += 1; - if (err instanceof RelayerThrottledError) noteThrottled(err); - log.error("bridge.initial_connect_failed", { err: reason, attempt }); - // A rejected key will not start working on the next attempt, so - // answer everything queued instead of leaving it to the orphan - // sweeper. Keep looping: `memwal_login` re-registers a key on - // this same relayer, and whichever path publishes the next - // session clears the flag and resumes normal buffering. - // - // Do NOT signal `firstConnect` here. It means "a session - // exists", and none does — the pump would fall straight through - // its `break; // stdin closed before we ever connected`, win the - // shutdown race in `runBridge`, and `markStdinClosed()` would - // disable the very `reconnect()` the error text tells the user - // to reach via `memwal_login`. `failPendingForward` writes to - // stdout directly and needs no pump. - // - // Same staleness test as the publish path above: a 401 for the - // key a login already replaced says nothing about the new one, - // and latching the flag on it would refuse every request against - // a session that is live and fine. - if ( - err instanceof RelayerUnauthorizedError && - !sse && - openingGeneration === credentialGeneration - ) { - credentialsRejected = true; - // Everything still queued never left the process, so no - // upstream initialize reply will arrive to consume its arm. - // `failRequest` keeps initialize arms for replies that CAN - // still arrive; a leaked one here would swallow the reply to - // a reused id after `memwal_login`. - for (const msg of pendingForward) { - if (msg.method === "initialize" && msg.id != null) { - suppressUpstreamReplies.delete(msg.id); - } - } - failPendingForward("credentials rejected", UNAUTHORIZED_FAILURE); - } - if (stdinClosed) break; - // Floor the geometric backoff at whatever throttle window is - // still open. Without this the first retry after a 429 lands - // 500ms later, well inside the interval the relayer asked for. - const backoff = Math.max( - Math.min(15_000, 500 * Math.pow(2, attempt - 1)), - throttledUntilMs - Date.now(), - ); - await new Promise((resolve) => { - const timer = setTimeout(() => { - unregister(); - resolve(); - }, backoff); - timer.unref?.(); - const unregister = onStdinClose(() => { - clearTimeout(timer); - resolve(); - }); - }); - } - } - signalFirstConnect(); - failPendingForward("connection not established before shutdown"); - })(); - - // Replay anything the auth-required server handed off (the tool call that - // triggered the hot-handoff, plus anything buffered behind it). These run - // through handleClientLine, which buffers them into pendingForward until the - // background connect lands — so the triggering request is served for real - // instead of being dropped in the mode switch. - if (pendingLines.length > 0) { - log.info("bridge.replaying_handoff", { count: pendingLines.length }); - for (const line of pendingLines) handleClientLine(line); - } - - const clientPump = readStdinLines(handleClientLine).then(() => { - markStdinClosed(); - sse?.abort(); - }); - - try { - await Promise.race([serverPump, clientPump]); - } finally { - clearInterval(orphanSweeper); - } - markStdinClosed(); - const finalStream = sse as SseHandshakeResult | null; - finalStream?.abort(); - await connectInBackground.catch(() => {}); - log.info("bridge.closed", {}); -} diff --git a/packages/mcp/src/client-info.ts b/packages/mcp/src/client-info.ts index 113fd8651..9ce2f01d8 100644 --- a/packages/mcp/src/client-info.ts +++ b/packages/mcp/src/client-info.ts @@ -1,6 +1,6 @@ /** - * Capture MCP `initialize` `clientInfo` so the stdio bridge can tell the - * relayer/sidecar which coding agent opened the session. + * Capture MCP `initialize` `clientInfo` so stderr logs can name the coding + * agent that opened the session. * * Header names must stay under the `x-memwal-` prefix: the relayer proxy * forwards that family and drops everything else. @@ -44,8 +44,7 @@ export function clientInfoHeaders(info: { return headers; } -/** Last `initialize.clientInfo` seen on this process. Survives the - * auth-required → bridge handoff, which does not replay `initialize`. */ +/** Last `initialize.clientInfo` seen on this process. */ let lastClientInfo: { name: string; version: string | null } | null = null; export function rememberInitializeClientInfo( diff --git a/packages/mcp/src/compatibility.ts b/packages/mcp/src/compatibility.ts index d273d7f49..b01817850 100644 --- a/packages/mcp/src/compatibility.ts +++ b/packages/mcp/src/compatibility.ts @@ -1,170 +1,9 @@ -export const MEMWAL_MCP_COMPATIBILITY_VERSION = "0.0.1"; -export const SUPPORTED_RELAYER_API_MAJOR = 1; - -/** Default budget for ONE relayer connect attempt — the compatibility check - * (`GET /version`, and a `/health` fallback) PLUS the initial SSE `GET` share - * this deadline (the caller threads a single `AbortSignal` through both). Kept - * well under the MCP client's ~30s connection timeout so a slow-but-alive - * relayer still succeeds, while a hung one aborts long before the client would - * SIGTERM us. Since `initialize` is now answered locally, exceeding this only - * defers when `tools/call` becomes available — it surfaces as a tool-call - * error, never a failed handshake. */ -const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; - -/** - * Resolve the per-step relayer connect timeout. Overridable via - * `MEMWAL_MCP_CONNECT_TIMEOUT_MS` (same pattern as `MEMWAL_MCP_SSE_IDLE_MS`). - * Non-numeric / non-positive values fall back to the default. A value of `0` - * is treated as "use the default" rather than "no timeout" — an unbounded - * connect is the bug we're fixing, so we never expose a way back to it. - */ -export function resolveConnectTimeoutMs(): number { - const raw = process.env.MEMWAL_MCP_CONNECT_TIMEOUT_MS; - if (!raw) return DEFAULT_CONNECT_TIMEOUT_MS; - const n = Number(raw); - if (!Number.isFinite(n) || n <= 0) return DEFAULT_CONNECT_TIMEOUT_MS; - return n; -} - -interface RelayerVersionMetadata { - relayerVersion?: string; - apiVersion?: string; - minSupportedSdk?: { - mcp?: string; - }; -} - -let compatibilityCache: RelayerVersionMetadata | null = null; -let compatibilityCacheUrl: string | null = null; -let compatibilityPromise: Promise | null = null; - /** - * @param signal Optional abort signal bounding the whole check. The caller - * passes ONE signal shared with the subsequent SSE connect so a single - * connect attempt is bounded in total, not per-step. When omitted, each fetch - * gets its own `AbortSignal.timeout(resolveConnectTimeoutMs())`. + * Relayer-contract baseline for this MCP package. + * + * The stdio server no longer probes `/version` itself — the SDK does that + * on the first signed request. This constant still has to match + * `MIN_MCP_PACKAGE_VERSION` in the relayer (`scripts/check-compatibility-contract.mjs`). */ -export async function ensureCompatibleRelayer( - relayerUrl: string, - signal?: AbortSignal, -): Promise { - const base = relayerUrl.replace(/\/+$/, ""); - if (compatibilityCache && compatibilityCacheUrl === base) return; - if (compatibilityPromise) return compatibilityPromise; - - compatibilityPromise = fetchAndValidate(base, signal).finally(() => { - compatibilityPromise = null; - }); - return compatibilityPromise; -} - -async function fetchAndValidate(relayerUrl: string, signal?: AbortSignal): Promise { - const base = relayerUrl; - // Bound the request(s) so a hung relayer aborts well before the MCP client's - // ~30s connection timeout. Prefer the caller's shared signal (so the compat - // check + SSE connect share one budget); else fall back to a per-check one. - const abort = signal ?? AbortSignal.timeout(resolveConnectTimeoutMs()); - const versionResp = await fetch(`${base}/version`, { - method: "GET", - signal: abort, - }); - let metadata: RelayerVersionMetadata; - - if (versionResp.ok) { - metadata = (await versionResp.json()) as RelayerVersionMetadata; - } else if (versionResp.status === 404 || versionResp.status === 405) { - const healthResp = await fetch(`${base}/health`, { - method: "GET", - signal: abort, - }); - if (!healthResp.ok) { - throw new Error( - `Walrus Memory MCP compatibility check failed: GET /version returned ` + - `${versionResp.status}, and GET /health returned ${healthResp.status}` - ); - } - metadata = (await healthResp.json()) as RelayerVersionMetadata; - } else { - throw new Error( - `Walrus Memory MCP compatibility check failed: GET /version returned ${versionResp.status}` - ); - } - - assertCompatible(metadata, base); - compatibilityCache = metadata; - compatibilityCacheUrl = base; -} - -function assertCompatible(metadata: RelayerVersionMetadata, relayerUrl: string): void { - if ( - !metadata.apiVersion || - !metadata.relayerVersion || - !metadata.minSupportedSdk || - typeof metadata.minSupportedSdk !== "object" - ) { - throw new Error( - `Walrus Memory relayer at ${relayerUrl} does not expose compatibility metadata. ` + - "Upgrade the relayer to a version that serves GET /version, or use an older MCP package." - ); - } - - const apiMajor = semverMajor(metadata.apiVersion); - if (apiMajor === null) { - throw new Error( - `Walrus Memory relayer at ${relayerUrl} returned invalid apiVersion ` + - `"${metadata.apiVersion}".` - ); - } - - if (apiMajor !== SUPPORTED_RELAYER_API_MAJOR) { - throw new Error( - `This Walrus Memory MCP package supports relayer API ` + - `${SUPPORTED_RELAYER_API_MAJOR}.x, but ${relayerUrl} reports ` + - `apiVersion ${metadata.apiVersion}. Upgrade or downgrade the MCP package/relayer pair.` - ); - } - - const minMcp = metadata.minSupportedSdk.mcp; - if (!minMcp) { - throw new Error( - `Walrus Memory relayer at ${relayerUrl} did not report minSupportedSdk.mcp.` - ); - } - if (semverMajor(minMcp) === null) { - throw new Error( - `Walrus Memory relayer at ${relayerUrl} returned invalid minSupportedSdk.mcp "${minMcp}".` - ); - } - if (compareSemver(MEMWAL_MCP_COMPATIBILITY_VERSION, minMcp) < 0) { - throw new Error( - `Walrus Memory relayer at ${relayerUrl} requires MCP package >= ${minMcp}, ` + - `but this package supports the ${MEMWAL_MCP_COMPATIBILITY_VERSION} ` + - "compatibility baseline. Upgrade " + - "@mysten-incubation/memwal-mcp or use an older compatible relayer." - ); - } -} - -function semverMajor(version: string): number | null { - const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); - return match ? Number(match[1]) : null; -} - -function compareSemver(a: string, b: string): number { - const left = parseSemver(a); - const right = parseSemver(b); - if (!left || !right) { - throw new Error(`invalid semver comparison: ${a} vs ${b}`); - } - - for (let idx = 0; idx < 3; idx += 1) { - if (left[idx] !== right[idx]) return left[idx] - right[idx]; - } - return 0; -} - -function parseSemver(version: string): [number, number, number] | null { - const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); - if (!match) return null; - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} +export const MEMWAL_MCP_COMPATIBILITY_VERSION = "0.0.1"; +export const SUPPORTED_RELAYER_API_MAJOR = 1; diff --git a/packages/mcp/src/format.ts b/packages/mcp/src/format.ts new file mode 100644 index 000000000..b733dbecd --- /dev/null +++ b/packages/mcp/src/format.ts @@ -0,0 +1,200 @@ +/** + * Tool-result copy for the stdio memory tools. + * + * Shapes match the relayer sidecar (`services/server/scripts/mcp/tools/`) + * so the agent ↔ stdio contract does not change when calls go through the + * SDK instead of the SSE bridge. + */ + +const SIDECAR_CAP_SATURATES_AT_LIMIT = 20; + +/** Canonical Walruscan explorer URL for a blob. */ +export function walruscanBlobUrl(blobId: string): string { + const network = process.env.SUI_NETWORK === "testnet" ? "testnet" : "mainnet"; + return `https://walruscan.com/${network}/blob/${blobId}`; +} + +/** One-line footer for write-tool results that list several blob ids. */ +export function explorerFooter(): string { + return `Explorer: ${walruscanBlobUrl("")} for any blob_id above.`; +} + +/** + * Name the relayer this process dialled in a `memwal_health` result. + * + * Rewrites an existing `relayer=` field rather than appending a second one. + */ +export function annotateHealthResult( + result: { content?: unknown; isError?: unknown }, + relayerUrl: string, +): void { + if (result.isError) return; + if (!Array.isArray(result.content)) return; + const block = (result.content as { type?: string; text?: string }[]).find( + (c) => c?.type === "text" && typeof c.text === "string", + ); + if (!block || typeof block.text !== "string") return; + const existing = /\brelayer=\S+/; + block.text = existing.test(block.text) + ? block.text.replace(existing, `relayer=${relayerUrl}`) + : `${block.text} relayer=${relayerUrl}`; +} + +/** Key deciding whether two recall hits say the same thing. */ +function dedupeKey(text: string): string { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +/** + * Collapse results carrying identical text, keeping the first (best-ranked) + * occurrence. + */ +export function collapseDuplicates( + results: T[], +): { unique: T[]; collapsed: number } { + const seen = new Map(); + for (const item of results) { + const key = dedupeKey(item.text); + if (!seen.has(key)) seen.set(key, item); + } + const unique = [...seen.values()]; + return { unique, collapsed: results.length - unique.length }; +} + +export function emptyRecallText(resultCount: number, dropped: number): string { + if (resultCount > 0) { + const unchecked = + dropped > 0 + ? ` (${dropped} further ${dropped === 1 ? "match was" : "matches were"} never checked against the cutoff: they failed to download or decrypt.)` + : ""; + return `All matching memories were outside maxDistance.${unchecked}`; + } + if (dropped > 0) { + return `No matching memories could be returned (${dropped} matched but failed to download or decrypt). This is not an empty namespace.`; + } + return "No matching memories found."; +} + +export function formatRecallLine( + memory: { text: string; distance: number; created_at?: unknown }, + index: number, +): string { + const score = (1 - memory.distance).toFixed(3); + const distance = memory.distance.toFixed(3); + const written = isoDateOrNull(memory.created_at); + const stamp = written ? ` [written=${written}]` : ""; + return `${index + 1}. [score=${score} distance=${distance}]${stamp} ${memory.text}`; +} + +function isoDateOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const ms = Date.parse(value); + if (Number.isNaN(ms)) return null; + return new Date(ms).toISOString().slice(0, 10); +} + +export function formatRecallResult(result: { + results: { text: string; distance: number; created_at?: unknown }[]; + dropped_count?: number; +}): string { + const droppedRaw = result.dropped_count; + const dropped = typeof droppedRaw === "number" ? droppedRaw : 0; + if (result.results.length === 0) { + return emptyRecallText(0, dropped); + } + const { unique, collapsed } = collapseDuplicates(result.results); + const lines = unique.map((m, i) => formatRecallLine(m, i)); + if (collapsed > 0) { + lines.push( + `\n(${collapsed} duplicate ${collapsed === 1 ? "copy" : "copies"} of the above collapsed; the same fact is stored more than once.)`, + ); + } + if (dropped > 0) { + lines.push( + `\n(${dropped} additional matches could not be decrypted and were omitted.)`, + ); + } + return lines.join("\n"); +} + +export function formatRestoreResult( + result: { + namespace: string; + total: number; + restored: number; + skipped: number; + failed?: number; + truncated?: boolean; + }, + limit = 10, +): string { + const truncated = result.truncated === true; + const failed = result.failed ?? 0; + const transientPage = + truncated && result.restored === 0 && result.skipped + failed < result.total; + const hint = !truncated + ? "\n truncated=false is not proof the sidecar saw every blob." + : transientPage + ? "\n ⚠️ This page did not restore (download/embed blip) — retry the same limit." + : limit < SIDECAR_CAP_SATURATES_AT_LIMIT + ? "\n ⚠️ More blobs remain to restore — increase limit and call again." + : "\n ⚠️ Sidecar cap is saturated — truncation follows this call's missing-blob page; truncated is not completeness (WALM-451 sourceCapped)."; + return ( + `${truncated ? "Restore partially complete" : "Restore page finished"} for namespace "${result.namespace}":\n` + + ` total=${result.total} restored=${result.restored} skipped=${result.skipped} failed=${failed} truncated=${truncated}` + + hint + ); +} + +export function formatHealthResult(result: { + status: string; + version: string; + write_ready?: boolean; + writes?: string; +}): string { + const readyNote = + result.write_ready === false + ? " write_ready=false (writes unavailable)" + : result.write_ready === true + ? " write_ready=true" + : ""; + const pausedNote = result.writes === "paused" ? " writes=paused" : ""; + return `Walrus Memory is reachable. status=${result.status} version=${result.version}${readyNote}${pausedNote}`; +} + +/** Reply when the saved delegate key is rejected (HTTP 401). File is not wiped. */ +export const UNAUTHORIZED_TEXT = + "❌ Walrus Memory rejected the saved credentials (HTTP 401). The delegate key may have been revoked or is no longer registered on this account. Call `memwal_login` to sign in again — saved credentials were NOT modified."; + +/** Reply once `memwal_logout` has dropped in-process credentials. */ +export const SIGNED_OUT_TEXT = + "❌ Signed out of Walrus Memory. Memory tools are unavailable on this connection until you call `memwal_login` again."; + +export const LOGIN_INSTRUCTION = [ + "❌ Walrus Memory isn't signed in yet.", + "", + "**Easiest fix — call the `memwal_login` tool from this client.** It opens a browser,", + "you approve the wallet sign-in, and on the next tool call this server picks up the", + "credentials automatically. No terminal command, no client restart.", + "", + "Fallback (if your client cannot call `memwal_login`, or you prefer a CLI):", + "", + " npx -y @mysten-incubation/memwal-mcp login", + "", + "(or `npx -y @mysten-incubation/memwal-mcp login --local` / `--dev` for a non-prod env)", + "", + "Either path opens a browser tab — click **Connect Sui Wallet** and approve the on-chain", + "`add_delegate_key` transaction. Credentials land at `~/.memwal/credentials.json`.", +].join("\n"); + +export function formatToolError(err: unknown): { text: string; isError: true } { + const e = err as { status?: number; message?: string; serverCode?: string }; + if (e.status === 401 || e.serverCode === "AUTH_REJECTED") { + return { text: UNAUTHORIZED_TEXT, isError: true }; + } + const msg = e.message ?? String(err); + if (/\b401\b/.test(msg) && /unauthor/i.test(msg)) { + return { text: UNAUTHORIZED_TEXT, isError: true }; + } + return { text: `❌ Walrus Memory error: ${msg}`, isError: true }; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 02b9b3fff..a36623481 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -4,16 +4,16 @@ * Boot sequence: * 1. If `--logout` flag → wipe credentials.json and exit. * 2. Load credentials from `~/.memwal/credentials.json`. - * 3. If missing → run `loginFlow()` (browser-based wallet sign-in). - * 4. Bridge stdio MCP ↔ remote SSE relayer using the loaded credentials. - * 5. On 401 (revoked key), the bridge wipes credentials before throwing - * — the next process spawn will re-trigger login. + * 3. If missing on a TTY → run `loginFlow()` (browser-based wallet sign-in). + * 4. If spawned by an MCP client (stdin is not a TTY) → run the stdio + * server. Login/logout stay local. Memory tools call the SDK + * (`MemWal.create` → signed REST). There is no SSE session. + * 5. A relayer 401 is a retryable error. It does NOT wipe the file. */ import { clearCreds, credsPath, loadCreds } from "./auth.js"; -import { runAuthRequiredServer } from "./auth-required.js"; -import { notePendingLoginSuccess, runBridge } from "./bridge.js"; import { loginFlow } from "./login.js"; import { log, note } from "./logger.js"; +import { runStdioServer } from "./server.js"; /** * Parsed CLI flags. All optional — env vars cover the same surface. @@ -176,7 +176,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise, + namespace?: string, +): Record { + if (!namespace || !NAMESPACE_TOOLS.has(toolName)) return args; + const current = args.namespace; + if (typeof current === "string" && current.trim() !== "") return args; + return { ...args, namespace }; +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 000000000..8a6e02e2b --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,408 @@ +/** + * Unified stdio MCP server. + * + * One process answers initialize / tools/list / tools/call locally. + * `memwal_login` / `memwal_logout` stay on this machine. Memory tools call + * the public SDK (`MemWal.create` → signed REST). There is no SSE session + * and no occupancy slot. + */ +import { credsPath, loadCreds, type MemWalCredentials } from "./auth.js"; +import { rememberInitializeClientInfo } from "./client-info.js"; +import { + LOGIN_INSTRUCTION, + SIGNED_OUT_TEXT, +} from "./format.js"; +import { AUTH_REQUIRED_INSTRUCTIONS, PROACTIVE_INSTRUCTIONS } from "./instructions.js"; +import { log } from "./logger.js"; +import { resolveLoginTimeoutMs, startOrReuseLoginFlow } from "./login.js"; +import { + loginFailureNotice, + loginPrompt, + loginSuccessNotice, + loginSuccessNotification, + type LoginSuccessInfo, +} from "./messages.js"; +import { dropClient, getClient, logout as logoutSession } from "./session.js"; +import { + SIGNED_OUT_TOOL_DEFINITIONS, + TOOL_DEFINITIONS, + isMemoryTool, + runMemoryTool, +} from "./tools.js"; +import { MEMWAL_MCP_VERSION } from "./version.js"; + +interface RpcMessage { + jsonrpc: "2.0"; + id?: number | string | null; + method?: string; + params?: unknown; + result?: unknown; + error?: unknown; +} + +export interface ServerConfig { + relayerUrl: string; + webUrl: string; + label: string; + namespace?: string; +} + +export interface ServerIo { + stdin: NodeJS.ReadableStream; + stdout: { write(chunk: string): unknown }; +} + +const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]); +const FALLBACK_PROTOCOL_VERSION = "2024-11-05"; +const URL_READY_TIMEOUT_MS = 5_000; + +let lastLoginFailure: string | null = null; +let pendingLoginSuccess: LoginSuccessInfo | null = null; +/** Set by `memwal_logout` so a later memory call names the sign-out rather + * than the first-run "isn't signed in yet" instruction. Cleared on login. */ +let signedOutLocally = false; + +export function notePendingLoginSuccess(info: LoginSuccessInfo): void { + pendingLoginSuccess = info; +} + +/** Test seam: clear login/logout module state between cases. */ +export function resetServerState(): void { + lastLoginFailure = null; + pendingLoginSuccess = null; + signedOutLocally = false; +} + +function takePendingLoginSuccess(): LoginSuccessInfo | null { + const pending = pendingLoginSuccess; + pendingLoginSuccess = null; + return pending; +} + +function applyPendingLoginSuccess(text: string): string { + const pending = takePendingLoginSuccess(); + if (!pending) return text; + log.info("server.login_success_notice_attached", { accountId: pending.accountId }); + return `${loginSuccessNotice(pending)}${text}`; +} + +function buildInitializeResult(params: unknown, signedIn: boolean) { + const requested = (params as { protocolVersion?: unknown } | undefined)?.protocolVersion; + const protocolVersion = + typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.has(requested) + ? requested + : FALLBACK_PROTOCOL_VERSION; + return { + protocolVersion, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: "memwal", version: MEMWAL_MCP_VERSION }, + instructions: signedIn ? PROACTIVE_INSTRUCTIONS : AUTH_REQUIRED_INSTRUCTIONS, + }; +} + +function writeStdout(stdout: ServerIo["stdout"], msg: RpcMessage): void { + stdout.write(JSON.stringify(msg) + "\n"); +} + +function sendLogMessage( + stdout: ServerIo["stdout"], + level: "info" | "warning" | "error", + text: string, +): void { + writeStdout(stdout, { + jsonrpc: "2.0", + method: "notifications/message", + params: { level, logger: "memwal-mcp", data: text }, + }); +} + +function toolResult( + stdout: ServerIo["stdout"], + id: RpcMessage["id"], + text: string, + isError: boolean, +): void { + writeStdout(stdout, { + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text }], + isError, + }, + }); +} + +function notifyToolsChanged(stdout: ServerIo["stdout"]): void { + writeStdout(stdout, { + jsonrpc: "2.0", + method: "notifications/tools/list_changed", + }); +} + +async function handleLoginToolCall( + config: ServerConfig, + stdout: ServerIo["stdout"], +): Promise<{ text: string; isError: boolean }> { + lastLoginFailure = null; + const session = startOrReuseLoginFlow( + { + relayerUrl: config.relayerUrl, + webUrl: config.webUrl, + label: config.label, + timeoutMs: resolveLoginTimeoutMs(), + openBrowser: false, + }, + (creds: MemWalCredentials) => { + lastLoginFailure = null; + signedOutLocally = false; + // Drop any previous SDK client so the next memory call rebuilds + // from the file this callback just wrote. + dropClient(); + log.info("memwal_login.success", { + accountId: creds.accountId, + delegateAddress: creds.delegateAddress, + }); + notePendingLoginSuccess({ + accountId: creds.accountId, + delegateAddress: creds.delegateAddress, + credentialsPath: credsPath(), + }); + sendLogMessage( + stdout, + "info", + loginSuccessNotification({ + accountId: creds.accountId, + delegateAddress: creds.delegateAddress, + credentialsPath: credsPath(), + }), + ); + notifyToolsChanged(stdout); + }, + (err) => { + const msg = err instanceof Error ? err.message : String(err); + lastLoginFailure = msg; + log.warn("memwal_login.failed", { msg }); + sendLogMessage( + stdout, + "warning", + `Walrus Memory sign-in did not complete: ${msg}. Existing credentials are unchanged; call memwal_login again to retry.`, + ); + }, + ); + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Listener never started")), + URL_READY_TIMEOUT_MS, + ).unref?.() as never, + ); + + let url: string; + try { + url = await Promise.race([session.url, timeoutPromise]); + } catch (err) { + return { + isError: true, + text: `❌ Failed to start login: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + return { + isError: false, + text: loginPrompt({ + url, + credentialsPath: credsPath(), + signedIn: loadCreds() !== null, + }), + }; +} + +function handleLogoutToolCall(): { text: string; isError: boolean } { + try { + signedOutLocally = true; + const cleared = logoutSession(); + log.info("memwal_logout.success", { + removedPath: cleared.removedPath ?? null, + fallbackPath: cleared.fallbackPath ?? null, + }); + if (!cleared.removedPath) { + return { + isError: false, + text: + `✅ Already signed out. No credentials at \`${credsPath()}\`, and this ` + + `connection's in-process client has been dropped — memory tools will refuse ` + + `to run until you sign in again.`, + }; + } + return { + isError: false, + text: [ + `✅ Signed out. Credentials removed from \`${cleared.removedPath}\`, and this connection's in-process client has been dropped — memory tools will refuse to run until you sign in again.`, + ...(cleared.fallbackPath + ? [ + ``, + `**Still signed in elsewhere:** \`${cleared.fallbackPath}\` remains and is ` + + `what the next run loads, under a possibly different account. Remove ` + + `that file too to sign out everywhere.`, + ] + : []), + ``, + `**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.`, + ``, + `Call \`memwal_login\` to sign in again with the same or a different wallet.`, + ].join("\n"), + }; + } catch (err) { + return { + isError: true, + text: `❌ Logout failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +function handleLine( + line: string, + config: ServerConfig, + stdout: ServerIo["stdout"], +): void { + let req: RpcMessage; + try { + req = JSON.parse(line) as RpcMessage; + } catch { + log.warn("server.stdin_parse_failed", { line: line.slice(0, 120) }); + return; + } + + if (req.id == null && typeof req.method === "string") { + return; + } + + const id = req.id ?? null; + const method = req.method; + + if (method === "initialize") { + const clientInfo = rememberInitializeClientInfo(req.params); + if (clientInfo) { + log.info("server.agent_client", { + clientName: clientInfo.name, + clientVersion: clientInfo.version, + }); + } + writeStdout(stdout, { + jsonrpc: "2.0", + id, + result: buildInitializeResult(req.params, loadCreds() !== null), + }); + return; + } + + if (method === "ping") { + writeStdout(stdout, { jsonrpc: "2.0", id, result: {} }); + return; + } + + if (method === "tools/list") { + const signedIn = loadCreds() !== null; + writeStdout(stdout, { + jsonrpc: "2.0", + id, + result: { tools: signedIn ? TOOL_DEFINITIONS : SIGNED_OUT_TOOL_DEFINITIONS }, + }); + return; + } + + if (method === "tools/call") { + const params = (req.params ?? {}) as { + name?: string; + arguments?: Record; + }; + const toolName = params.name ?? ""; + const args = params.arguments ?? {}; + + if (toolName === "memwal_login") { + void handleLoginToolCall(config, stdout).then((result) => { + toolResult(stdout, id, result.text, result.isError); + }); + return; + } + + if (toolName === "memwal_logout") { + const result = handleLogoutToolCall(); + toolResult(stdout, id, result.text, result.isError); + notifyToolsChanged(stdout); + return; + } + + if (!isMemoryTool(toolName)) { + writeStdout(stdout, { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Unknown tool: ${toolName}` }, + }); + return; + } + + const client = getClient(); + if (!client) { + const body = signedOutLocally + ? SIGNED_OUT_TEXT + : `${loginFailureNotice(lastLoginFailure)}${LOGIN_INSTRUCTION}`; + toolResult(stdout, id, applyPendingLoginSuccess(body), true); + return; + } + + const relayerUrl = loadCreds()?.relayerUrl ?? config.relayerUrl; + void runMemoryTool(toolName, args, config.namespace, client, relayerUrl).then((result) => { + toolResult(stdout, id, applyPendingLoginSuccess(result.text), result.isError); + }); + return; + } + + writeStdout(stdout, { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method ?? "(missing)"}` }, + }); +} + +function readStdinLines( + stdin: NodeJS.ReadableStream, + onLine: (line: string) => void, +): Promise { + return new Promise((resolve) => { + let buf = ""; + const readable = stdin as NodeJS.ReadStream; + if (typeof readable.setEncoding === "function") readable.setEncoding("utf8"); + stdin.on("data", (chunk: string | Buffer) => { + buf += String(chunk); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.length > 0) onLine(line); + } + }); + stdin.on("end", () => resolve()); + stdin.on("close", () => resolve()); + readable.resume?.(); + }); +} + +/** + * Run the stdio MCP server until stdin closes. + * + * `io` is a test seam (PassThrough streams). Production uses process stdio. + */ +export async function runStdioServer( + config: ServerConfig, + io: ServerIo = { stdin: process.stdin, stdout: process.stdout }, +): Promise { + log.info("server.started", { + webUrl: config.webUrl, + relayerUrl: config.relayerUrl, + signedIn: loadCreds() !== null, + }); + await readStdinLines(io.stdin, (line) => handleLine(line, config, io.stdout)); + dropClient(); + log.info("server.closed", {}); +} diff --git a/packages/mcp/src/session.ts b/packages/mcp/src/session.ts new file mode 100644 index 000000000..291914689 --- /dev/null +++ b/packages/mcp/src/session.ts @@ -0,0 +1,131 @@ +/** + * In-process MemWal client, built from `credentials.json`. + * + * Reloads the file on every lookup so a completed `memwal_login` is picked + * up without a client restart. Logout destroys the client so later memory + * tools cannot keep signing with a key the user just deleted. + */ +import { MemWal } from "@mysten-incubation/memwal"; +import { clearCreds, loadCreds, type MemWalCredentials } from "./auth.js"; + +export interface MemoryClient { + rememberAndWait( + text: string, + namespace?: string, + opts?: { timeoutMs?: number }, + ): Promise<{ blob_id: string; namespace: string }>; + rememberBulkAndWait( + items: { text: string; namespace?: string }[], + opts?: { timeoutMs?: number }, + ): Promise<{ + results: { status: string; blob_id?: string; error?: string }[]; + succeeded: number; + total: number; + failed: number; + }>; + recall(params: { + query: string; + limit?: number; + namespace?: string; + maxDistance?: number; + }): Promise<{ + results: { text: string; distance: number; created_at?: unknown }[]; + dropped_count?: number; + }>; + analyzeAndWait( + text: string, + namespace?: string, + opts?: { timeoutMs?: number }, + ): Promise<{ + facts: { text: string }[]; + results: { status: string; blob_id?: string }[]; + succeeded: number; + failed: number; + }>; + restore( + namespace: string, + limit?: number, + ): Promise<{ + namespace: string; + total: number; + restored: number; + skipped: number; + failed?: number; + truncated?: boolean; + }>; + health(): Promise<{ + status: string; + version: string; + write_ready?: boolean; + writes?: string; + }>; + destroy(): void; +} + +export type ClientFactory = (creds: MemWalCredentials) => MemoryClient; + +const defaultFactory: ClientFactory = (creds) => + MemWal.create({ + key: creds.delegatePrivateKey, + accountId: creds.accountId, + serverUrl: creds.relayerUrl, + }); + +let factory: ClientFactory = defaultFactory; +let cached: { creds: MemWalCredentials; client: MemoryClient } | null = null; + +/** Test seam. Pass `undefined` to restore the real SDK factory. */ +export function setClientFactory(next?: ClientFactory): void { + factory = next ?? defaultFactory; + dropClient(); +} + +function sameCreds(a: MemWalCredentials, b: MemWalCredentials): boolean { + return ( + a.delegatePrivateKey === b.delegatePrivateKey && + a.accountId === b.accountId && + a.relayerUrl === b.relayerUrl + ); +} + +export function dropClient(): void { + try { + cached?.client.destroy(); + } catch { + /* already torn down */ + } + cached = null; +} + +/** + * Return a live SDK client for the credentials currently on disk, or null + * when the file is missing. Recreates the client when the file's key, + * account, or relayer URL changes. + */ +export function getClient(): MemoryClient | null { + const creds = loadCreds(); + if (!creds) { + dropClient(); + return null; + } + if (cached && sameCreds(cached.creds, creds)) return cached.client; + dropClient(); + const client = factory(creds); + cached = { creds, client }; + return client; +} + +/** Currently-cached credentials, if a client is live. */ +export function currentCreds(): MemWalCredentials | null { + return cached?.creds ?? loadCreds(); +} + +/** + * Delete the credentials file and destroy the in-process client. + * A relayer 401 must NOT call this — that was a creds-wipe DoS. + */ +export function logout(): ReturnType { + const result = clearCreds(); + dropClient(); + return result; +} diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts new file mode 100644 index 000000000..f3cb1c8e4 --- /dev/null +++ b/packages/mcp/src/tools.ts @@ -0,0 +1,318 @@ +/** + * Memory-tool schemas and SDK dispatch for the stdio MCP server. + * + * Names, descriptions, and result text match the relayer sidecar so the + * agent contract does not change. Execution is `MemWal.create` + signed REST. + */ +import { + annotateHealthResult, + explorerFooter, + formatHealthResult, + formatRecallResult, + formatRestoreResult, + formatToolError, + walruscanBlobUrl, +} from "./format.js"; +import { applyDefaultNamespace } from "./namespace.js"; +import type { MemoryClient } from "./session.js"; + +const SIGNED_OUT_REMEMBER = + "Save a fact to the user's Walrus Memory personal memory. Call ONLY when the user explicitly asks to remember/save something. Pass the full, detailed text — never summarize."; +const SIGNED_IN_REMEMBER = + "Save a durable fact about the user or project to their Walrus Memory. Call this PROACTIVELY whenever the user states a preference, decision, constraint, correction, identity detail, or recurring workflow — even if they did not say 'remember this'. Skip one-off tasks, the current file or bug, and small talk. Pass the full statement; do not summarize. To save several facts at once, use memwal_remember_bulk instead."; +const SIGNED_OUT_RECALL = + "Search the user's Walrus Memory for facts relevant to a query. Returns matching memories ranked by relevance."; +const SIGNED_IN_RECALL = + "Search the user's Walrus Memory for relevant facts before responding. Call this PROACTIVELY at the start of a task, or whenever the user references past work, prior decisions, their preferences, or anything you may have stored earlier — don't wait to be asked. A single focused query is usually enough — recall is a real retrieval over encrypted storage, so do NOT fire multiple redundant searches for the same question. Returns matching memories ranked by relevance."; + +const LOGIN_TOOL = { + name: "memwal_login", + title: "Sign In to Walrus Memory", + annotations: { readOnlyHint: false, destructiveHint: false }, + description: + "Sign this MCP client into your Walrus Memory account by opening a browser. Run once when the agent reports Walrus Memory is not signed in. Opens the dashboard in the default browser, waits for wallet approval, then writes credentials to ~/.memwal/credentials.json. Other memwal_* tools become usable on the next call after a successful login.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, +} as const; + +const LOGOUT_TOOL = { + name: "memwal_logout", + title: "Sign Out of Walrus Memory", + annotations: { readOnlyHint: false, destructiveHint: false }, + description: + "Sign out of Walrus Memory: removes the saved credentials from this machine (~/.memwal/credentials.json) AND drops this connection's in-process client, 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.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, +} as const; + +function buildToolDefinitions(proactive: boolean) { + return [ + { + name: "memwal_remember", + title: "Remember a Fact", + annotations: { readOnlyHint: false, destructiveHint: false }, + description: proactive ? SIGNED_IN_REMEMBER : SIGNED_OUT_REMEMBER, + inputSchema: { + type: "object", + properties: { + text: { type: "string", minLength: 1 }, + namespace: { type: "string" }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + { + name: "memwal_remember_bulk", + title: "Remember Multiple Facts", + annotations: { readOnlyHint: false, destructiveHint: false }, + description: + "Save multiple durable facts in one call. Use when you learned several distinct facts at once (onboarding details, a list of preferences, decisions from a discussion). Pass an array of complete fact statements (max 20) — do not summarize. Prefer this over repeated memwal_remember calls.", + inputSchema: { + type: "object", + properties: { + facts: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: 20, + }, + namespace: { type: "string" }, + }, + required: ["facts"], + additionalProperties: false, + }, + }, + { + name: "memwal_recall", + title: "Recall Memories", + annotations: { readOnlyHint: true, destructiveHint: false }, + description: proactive ? SIGNED_IN_RECALL : SIGNED_OUT_RECALL, + inputSchema: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + namespace: { type: "string" }, + maxDistance: { + type: "number", + minimum: 0, + description: + "Optional cosine-distance cutoff (low = similar; 0 = identical). Hits with distance >= maxDistance are dropped. Omit to apply no cutoff. Displayed score is 1 - distance (high = similar); do not treat score as the cutoff.", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }, + { + name: "memwal_analyze", + title: "Analyze and Remember", + annotations: { readOnlyHint: false, destructiveHint: true }, + description: + "Extract memorable facts from a longer passage of text (preferences, habits, biographical info, constraints) and save each as a separate Walrus Memory memory. Use this when you want MemWal's LLM to split the facts out of a transcript or notes for you; if you already know the exact facts, use memwal_remember or memwal_remember_bulk instead.", + inputSchema: { + type: "object", + properties: { + text: { type: "string", minLength: 1 }, + namespace: { type: "string" }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + { + name: "memwal_restore", + title: "Restore Memory Index", + annotations: { readOnlyHint: false, destructiveHint: false }, + description: + "Recovery tool. Re-index a namespace from Walrus blobs back into the relayer's search index \u2014 use when memwal_recall unexpectedly returns nothing even though facts were saved before (e.g. on a new machine, a fresh relayer, or after switching servers). Returns restored/skipped/failed/total plus truncated \u2014 does not return memory texts. truncated=true is known-retryable-incomplete: retry the same limit on a download/embed blip; raising limit expands the sidecar cap only while limit < 20; after the cap saturates, truncation follows this call's missing-blob page. truncated=false is not completeness; WALM-451 will add sourceCapped. Call memwal_recall afterwards to query the rebuilt index.", + inputSchema: { + type: "object", + properties: { + namespace: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["namespace"], + additionalProperties: false, + }, + }, + { + name: "memwal_health", + title: "Check Walrus Memory Health", + annotations: { readOnlyHint: true, destructiveHint: false }, + description: + "Quick connectivity check for Walrus Memory. Calls the relayer's lightweight health endpoint (no search, no decryption) and returns its status and version. Use this to confirm the server is reachable — do NOT use memwal_recall for health checks, which is a full and slow retrieval.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + LOGIN_TOOL, + ]; +} + +/** Signed-in tool list (proactive wording + logout). */ +export const TOOL_DEFINITIONS = [...buildToolDefinitions(true), LOGOUT_TOOL]; + +/** Signed-out tool list (conservative wording, login only). */ +export const SIGNED_OUT_TOOL_DEFINITIONS = buildToolDefinitions(false); + +const MEMORY_TOOLS = new Set([ + "memwal_remember", + "memwal_remember_bulk", + "memwal_recall", + "memwal_analyze", + "memwal_restore", + "memwal_health", +]); + +export function isMemoryTool(name: string): boolean { + return MEMORY_TOOLS.has(name); +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${field} is required`); + } + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function optionalLimit(value: unknown, fallback: number): number { + if (value == null) return fallback; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(n) || n < 1) return fallback; + return Math.min(n, 100); +} + +function optionalMaxDistance(value: unknown): number | undefined { + if (value == null) return undefined; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n) || n < 0) { + throw new Error("maxDistance must be a number >= 0"); + } + return n; +} + +/** + * Run a memory tool against the SDK client. Caller guarantees `client` is live. + */ +export async function runMemoryTool( + name: string, + rawArgs: Record, + defaultNamespace: string | undefined, + client: MemoryClient, + relayerUrl: string, +): Promise<{ text: string; isError: boolean }> { + const args = applyDefaultNamespace(name, rawArgs, defaultNamespace); + try { + switch (name) { + case "memwal_remember": { + const text = requireString(args.text, "text"); + const result = await client.rememberAndWait(text, optionalString(args.namespace), { + timeoutMs: 90_000, + }); + return { + isError: false, + text: `Saved to Walrus Memory. blob_id=${result.blob_id} namespace=${result.namespace}\nExplorer: ${walruscanBlobUrl(result.blob_id)}`, + }; + } + case "memwal_remember_bulk": { + if (!Array.isArray(args.facts) || args.facts.length === 0) { + throw new Error("facts must be a non-empty array"); + } + if (args.facts.length > 20) { + throw new Error("facts supports at most 20 items"); + } + const facts = args.facts.map((f, i) => { + if (typeof f !== "string" || f.trim() === "") { + throw new Error(`facts[${i}] must be a non-empty string`); + } + return f; + }); + const namespace = optionalString(args.namespace); + const result = await client.rememberBulkAndWait( + facts.map((text) => ({ text, namespace })), + { timeoutMs: 120_000 }, + ); + const lines = result.results.map((r, i) => { + const text = facts[i] ?? ""; + const blob = r.blob_id ? ` blob_id=${r.blob_id}` : ""; + const err = r.error ? ` error=${r.error}` : ""; + return `${i + 1}. [${r.status}]${blob}${err}${text ? ` — ${text}` : ""}`; + }); + const summary = `Saved ${result.succeeded}/${result.total} fact(s) to Walrus Memory (failed=${result.failed}).`; + const footer = result.succeeded > 0 ? `\n\n${explorerFooter()}` : ""; + return { + isError: false, + text: + lines.length > 0 + ? `${summary}\n\n${lines.join("\n")}${footer}` + : `${summary}${footer}`, + }; + } + case "memwal_recall": { + const query = requireString(args.query, "query"); + const result = await client.recall({ + query, + limit: optionalLimit(args.limit, 10), + namespace: optionalString(args.namespace), + maxDistance: optionalMaxDistance(args.maxDistance), + }); + return { isError: false, text: formatRecallResult(result) }; + } + case "memwal_analyze": { + const text = requireString(args.text, "text"); + const result = await client.analyzeAndWait( + text, + optionalString(args.namespace), + { timeoutMs: 180_000 }, + ); + const lines = result.results.map( + (r, i) => + `${i + 1}. [${r.status}]${r.blob_id ? ` blob_id=${r.blob_id}` : ""} ${ + result.facts[i]?.text ?? "(unknown fact)" + }`, + ); + const summary = `Extracted ${result.facts.length} fact(s) — succeeded=${result.succeeded} failed=${result.failed}`; + const footer = result.succeeded > 0 ? `\n\n${explorerFooter()}` : ""; + return { + isError: false, + text: + lines.length > 0 + ? `${summary}\n\n${lines.join("\n")}${footer}` + : `${summary}${footer}`, + }; + } + case "memwal_restore": { + const namespace = requireString(args.namespace, "namespace"); + const limit = optionalLimit(args.limit, 10); + const result = await client.restore(namespace, limit); + return { isError: false, text: formatRestoreResult(result, limit) }; + } + case "memwal_health": { + const result = await client.health(); + const envelope = { + content: [{ type: "text", text: formatHealthResult(result) }], + }; + annotateHealthResult(envelope, relayerUrl); + return { isError: false, text: envelope.content[0].text }; + } + default: + throw new Error(`Unknown memory tool: ${name}`); + } + } catch (err) { + return formatToolError(err); + } +} diff --git a/packages/mcp/src/version.ts b/packages/mcp/src/version.ts index f8d1fab44..c5aae9e2c 100644 --- a/packages/mcp/src/version.ts +++ b/packages/mcp/src/version.ts @@ -10,7 +10,8 @@ * NOT the same value as `MEMWAL_MCP_COMPATIBILITY_VERSION` in compatibility.ts. * That one is a deliberately pinned relayer-contract baseline, checked against * the Rust `MIN_MCP_PACKAGE_VERSION` by scripts/check-compatibility-contract.mjs, - * and must NOT track the release version. + * and must NOT track the release version. The SDK, not this process, probes + * the relayer's `/version` on the first signed request. * * `../package.json` resolves correctly from any emitted module because tsc maps * rootDir `src/` onto outDir `dist/`, and npm always includes package.json in diff --git a/packages/mcp/test/coldstart-flush-404.test.mjs b/packages/mcp/test/coldstart-flush-404.test.mjs deleted file mode 100644 index 063a763bf..000000000 --- a/packages/mcp/test/coldstart-flush-404.test.mjs +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Regression test for GH #415 round-2 finding N1 — a 404 during the post-connect - * flush must NOT cause buffered requests to be delivered twice. - * - * Bug being guarded against: buffered tool calls live in BOTH pendingForward and - * inFlight. If a POST during flushPendingForward returns 404, reconnect() replays - * the ENTIRE inFlight map (all still-queued items) against the fresh session — and - * if the flush loop then keeps draining pendingForward, those items get POSTed a - * SECOND time (duplicate memory writes + two JSON-RPC replies for one id). - * - * Repro: - * - Client sends initialize + three tools/call BEFORE the relayer connects (all - * buffered). The mock delays the endpoint event so they queue up. - * - First SSE session: the first POST it receives returns 404 (stale-session - * race), forcing reconnect. The second session answers normally. - * - Assert: each of the three tool-call ids is answered EXACTLY once, and the - * relayer received each distinct fact EXACTLY once (no duplicate write). - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); -const SSE_DELAY_MS = 700; - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer: two SSE sessions. The FIRST POST to session-1 returns 404 - * (forcing a reconnect mid-flush); session-2 answers normally. Counts how many - * times each distinct fact text is received, to detect duplicate delivery. */ -function startFlaky404Relayer() { - const sessions = new Map(); - let sseGetCount = 0; - let firstPostRejected = false; - const factDeliveries = new Map(); // fact text -> count - - const server = http.createServer((req, res) => { - const u = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && u.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ apiVersion: "1.0.0", relayerVersion: "1.0.0", minSupportedSdk: { mcp: "0.0.1" } })); - return; - } - if (req.method === "GET" && u.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }); - // Delay the endpoint event on the FIRST session so the client buffers - // initialize + all three tool calls before the stream is up. - const delay = sseGetCount === 1 ? SSE_DELAY_MS : 0; - const t = setTimeout(() => { - if (res.writableEnded) return; - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, { res }); - const hb = setInterval(() => { if (!res.writableEnded) res.write(":\n\n"); else clearInterval(hb); }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - }, delay); - t.unref?.(); - return; - } - if (req.method === "POST" && u.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - const sessionId = u.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - let msg; - try { msg = JSON.parse(body); } catch { res.writeHead(202); res.end(); return; } - // Reject the very first tool-call POST once, to force a reconnect - // during the flush. initialize is allowed through so the handshake - // forward isn't what trips it. - if (!firstPostRejected && msg.method === "tools/call") { - firstPostRejected = true; - res.writeHead(404); - res.end(); - return; - } - if (!session) { res.writeHead(404); res.end(); return; } - res.writeHead(202); - res.end(); - if (msg.method === "initialize") return; // suppressed by the bridge - if (msg.method === "tools/call" && msg.params?.name === "memwal_remember") { - const fact = msg.params?.arguments?.text ?? ""; - factDeliveries.set(fact, (factDeliveries.get(fact) ?? 0) + 1); - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { content: [{ type: "text", text: `SAVED:${fact}` }], isError: false }, - })}\n\n`, - ); - return; - } - }); - return; - } - res.writeHead(404); res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ - server, - base: `http://127.0.0.1:${server.address().port}`, - factDeliveries, - getSseGetCount: () => sseGetCount, - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, packageId: "0x" + "4".repeat(64), - relayerUrl, label: "Flush404 Test", createdAt: new Date(0).toISOString(), version: 1, - }; -} - -test("a 404 during the post-connect flush does not double-deliver buffered requests", async (t) => { - const mock = await startFlaky404Relayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-flush404-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; try { msg = JSON.parse(line); } catch { continue; } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { listeners.delete(l); rej(new Error(`timeout\n${stderrBuf}\n${received.map((m) => JSON.stringify(m)).join("\n")}`)); }, ms); - const l = (m) => { if (pred(m)) { clearTimeout(timer); listeners.delete(l); res(m); } }; - listeners.add(l); - }); - }; - - t.after(() => { child.kill("SIGKILL"); mock.server.close(); rmSync(home, { recursive: true, force: true }); }); - - // Handshake + three remembers, all sent before the (delayed) connect → buffered. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 3_000); - for (const [id, fact] of [[2, "fact-A"], [3, "fact-B"], [4, "fact-C"]]) { - send({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "memwal_remember", arguments: { text: fact } } }); - } - - // All three must be answered (after the 404 → reconnect → replay recovers them). - await waitFor((m) => m.id === 2, 10_000); - await waitFor((m) => m.id === 3, 10_000); - await waitFor((m) => m.id === 4, 10_000); - - // Give any erroneous duplicate delivery a chance to land before asserting. - await new Promise((r) => setTimeout(r, 500)); - - // Each id answered EXACTLY once (no duplicate JSON-RPC response for an id). - for (const id of [2, 3, 4]) { - const replies = received.filter((m) => m.id === id && (m.result || m.error)); - assert.equal(replies.length, 1, `id=${id} must be answered exactly once, saw ${replies.length}: ${JSON.stringify(replies)}`); - } - - // Each distinct fact delivered to the relayer EXACTLY once (no duplicate write). - for (const fact of ["fact-A", "fact-B", "fact-C"]) { - assert.equal( - mock.factDeliveries.get(fact), - 1, - `fact "${fact}" must be written exactly once, was written ${mock.factDeliveries.get(fact)} time(s)`, - ); - } - - // Sanity: the 404 actually forced a reconnect (>=2 SSE handshakes). - assert.ok(mock.getSseGetCount() >= 2, `expected a reconnect (>=2 SSE handshakes), saw ${mock.getSseGetCount()}`); -}); - -test("a request arriving DURING the flush-404 reconnect backoff is delivered exactly once (not double-posted)", async (t) => { - // Round-4 finding C2: a tools/call that arrives while reconnect is in its - // backoff lands in BOTH inFlight (reconnect replays it) AND pendingForward - // (the flush would re-post it) → double delivery. This times a second - // request into that ~500ms backoff window and asserts single delivery. - const mock = await startFlaky404Relayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-flush404b-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; try { msg = JSON.parse(line); } catch { continue; } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { listeners.delete(l); rej(new Error(`timeout\n${stderrBuf}\n${received.map((m) => JSON.stringify(m)).join("\n")}`)); }, ms); - const l = (m) => { if (pred(m)) { clearTimeout(timer); listeners.delete(l); res(m); } }; - listeners.add(l); - }); - }; - - t.after(() => { child.kill("SIGKILL"); mock.server.close(); rmSync(home, { recursive: true, force: true }); }); - - // initialize + one remember up front. The remember's flush POST 404s → - // reconnect() enters a ~500ms backoff. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 5_000); - send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_remember", arguments: { text: "fact-A" } } }); - - // Send a SECOND remember ~150ms later — inside the reconnect backoff window, - // so it arrives while flushing===true and reconnect is sleeping. - await new Promise((r) => setTimeout(r, 150)); - send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "memwal_remember", arguments: { text: "fact-B" } } }); - - await waitFor((m) => m.id === 2, 10_000); - await waitFor((m) => m.id === 3, 10_000); - await new Promise((r) => setTimeout(r, 500)); // let any duplicate land - - for (const id of [2, 3]) { - const replies = received.filter((m) => m.id === id && (m.result || m.error)); - assert.equal(replies.length, 1, `id=${id} must be answered exactly once, saw ${replies.length}`); - } - for (const fact of ["fact-A", "fact-B"]) { - assert.equal( - mock.factDeliveries.get(fact), - 1, - `fact "${fact}" must be written exactly once, was written ${mock.factDeliveries.get(fact)} time(s)`, - ); - } -}); - -/** Like startFlaky404Relayer, but on the first tool-call POST it BOTH returns 404 - * AND closes session-1's SSE stream. Closing the stream makes the bridge's - * serverPump see EOF and trigger its OWN reconnect('server-pump-eof'), which can - * win the `reconnecting` flag before the flush's reconnect('post-404') — the - * round-5 finding D1 double-post scenario. */ -function startConcurrentReconnectRelayer() { - const sessions = new Map(); - let sseGetCount = 0; - let firstPostRejected = false; - const factDeliveries = new Map(); - const server = http.createServer((req, res) => { - const u = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && u.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ apiVersion: "1.0.0", relayerVersion: "1.0.0", minSupportedSdk: { mcp: "0.0.1" } })); - return; - } - if (req.method === "GET" && u.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }); - const delay = sseGetCount === 1 ? SSE_DELAY_MS : 0; - const t = setTimeout(() => { - if (res.writableEnded) return; - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, { res }); - const hb = setInterval(() => { if (!res.writableEnded) res.write(":\n\n"); else clearInterval(hb); }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - }, delay); - t.unref?.(); - return; - } - if (req.method === "POST" && u.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - const sessionId = u.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - let msg; - try { msg = JSON.parse(body); } catch { res.writeHead(202); res.end(); return; } - if (!firstPostRejected && msg.method === "tools/call") { - firstPostRejected = true; - // Close session-1's SSE stream so serverPump also reconnects. - const s1 = sessions.get(sessionId); - if (s1 && !s1.res.writableEnded) s1.res.end(); - sessions.delete(sessionId); - res.writeHead(404); res.end(); - return; - } - if (!session) { res.writeHead(404); res.end(); return; } - res.writeHead(202); res.end(); - if (msg.method === "initialize") return; - if (msg.method === "tools/call" && msg.params?.name === "memwal_remember") { - const fact = msg.params?.arguments?.text ?? ""; - factDeliveries.set(fact, (factDeliveries.get(fact) ?? 0) + 1); - session.res.write(`event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", id: msg.id, - result: { content: [{ type: "text", text: `SAVED:${fact}` }], isError: false }, - })}\n\n`); - return; - } - }); - return; - } - res.writeHead(404); res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ server, base: `http://127.0.0.1:${server.address().port}`, factDeliveries, getSseGetCount: () => sseGetCount }); - }); - }); -} - -test("a concurrent serverPump reconnect during the flush-404 does not double-deliver buffered requests", async (t) => { - const mock = await startConcurrentReconnectRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-flush404c-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; try { msg = JSON.parse(line); } catch { continue; } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { listeners.delete(l); rej(new Error(`timeout\n${stderrBuf}\n${received.map((m) => JSON.stringify(m)).join("\n")}`)); }, ms); - const l = (m) => { if (pred(m)) { clearTimeout(timer); listeners.delete(l); res(m); } }; - listeners.add(l); - }); - }; - - t.after(() => { child.kill("SIGKILL"); mock.server.close(); rmSync(home, { recursive: true, force: true }); }); - - // initialize + three remembers, all buffered before the delayed connect. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 5_000); - for (const [id, fact] of [[2, "fact-A"], [3, "fact-B"], [4, "fact-C"]]) { - send({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "memwal_remember", arguments: { text: fact } } }); - } - - await waitFor((m) => m.id === 2, 12_000); - await waitFor((m) => m.id === 3, 12_000); - await waitFor((m) => m.id === 4, 12_000); - await new Promise((r) => setTimeout(r, 600)); // let any duplicate land - - for (const id of [2, 3, 4]) { - const replies = received.filter((m) => m.id === id && (m.result || m.error)); - assert.equal(replies.length, 1, `id=${id} answered exactly once, saw ${replies.length}: ${JSON.stringify(replies)}`); - } - for (const fact of ["fact-A", "fact-B", "fact-C"]) { - assert.equal( - mock.factDeliveries.get(fact), - 1, - `fact "${fact}" written exactly once, was written ${mock.factDeliveries.get(fact)} time(s)`, - ); - } -}); diff --git a/packages/mcp/test/coldstart-init.test.mjs b/packages/mcp/test/coldstart-init.test.mjs index ef400c5bc..efd88d804 100644 --- a/packages/mcp/test/coldstart-init.test.mjs +++ b/packages/mcp/test/coldstart-init.test.mjs @@ -1,237 +1,54 @@ /** - * Regression test for GH #415 — the bridge must NOT block the MCP `initialize` - * handshake on the relayer connect during a slow cold start. - * - * Bug being guarded against: on a credentialed cold start, `runBridge` awaited a - * full relayer round-trip (TLS + GET /version + SSE handshake + endpoint event) - * BEFORE it read stdin or answered `initialize`. `initialize` was forwarded to - * the relayer, not answered locally, so a slow relayer tripped the MCP client's - * ~30s connection timeout → the client SIGTERM'd the process → no tools loaded. - * - * Repro here: - * - Mock relayer answers GET /version immediately but DELAYS the SSE endpoint - * event by SSE_DELAY_MS, simulating a slow/cold relayer. - * - The bridge must answer `initialize` and a cold-start `tools/list` LOCALLY - * and near-instantly — well before the SSE stream is up. - * - A `tools/call` sent before the stream is up must be BUFFERED and served - * once the relayer connect completes (not dropped, not hung). - * - Once connected, the bridge emits notifications/tools/list_changed so the - * client re-lists and gets the real upstream tool set. + * Credentialed cold start must answer initialize locally without waiting on + * any relayer — there is no SSE handshake to block the MCP client timeout. */ import { test } from "node:test"; import assert from "node:assert/strict"; -import http from "node:http"; import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); -/** How long the mock relayer withholds the SSE endpoint event. Large enough - * that a bridge which (wrongly) waited on the relayer before answering - * `initialize` would blow the assertion deadlines below. */ -const SSE_DELAY_MS = 3_000; - -/** The tools the real relayer sidecar registers - * (services/server/scripts/mcp/tools/index.ts). The cold-start static list must - * cover exactly these (plus the locally-served login/logout), so the - * static→refreshed transition doesn't change the tool set under the client. */ -const UPSTREAM_TOOL_NAMES = [ +const SIGNED_IN_TOOLS = [ "memwal_remember", "memwal_remember_bulk", "memwal_recall", "memwal_analyze", "memwal_restore", "memwal_health", + "memwal_login", + "memwal_logout", ]; -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer that cold-starts slowly: /version is instant, but the SSE - * endpoint event is delayed by SSE_DELAY_MS. After that, the session behaves - * normally (answers initialize + tools/call over the stream). */ -function startSlowRelayer() { - const sessions = new Map(); - let sseGetCount = 0; - let versionHits = 0; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - versionHits += 1; - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - // The slow part: withhold the endpoint event. A correct bridge has - // already answered initialize locally by now. - const t = setTimeout(() => { - if (res.writableEnded) return; - res.write( - `event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`, - ); - sessions.set(sessionId, { res }); - const hb = setInterval(() => { - if (res.writableEnded) { - clearInterval(hb); - return; - } - res.write(":\n\n"); - }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - }, SSE_DELAY_MS); - t.unref?.(); - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - const sessionId = url.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - if (!session) { - res.writeHead(404); - res.end(); - return; - } - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "initialize") { - // The upstream initialize reply — the bridge must SUPPRESS - // this (client already got the local one). If it leaked, the - // client would see two responses for the same id. - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal-upstream", version: "9.9.9" }, - }, - })}\n\n`, - ); - return; - } - if (msg.method === "tools/call" && msg.params?.name === "memwal_recall") { - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: "RECALL_OK: served after background connect" }], - isError: false, - }, - })}\n\n`, - ); - return; - } - if (msg.method === "tools/list") { - // The real upstream tool set (the 6 tools the sidecar - // registers). The bridge splices login/logout onto this. - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - tools: UPSTREAM_TOOL_NAMES.map((name) => ({ - name, - description: `upstream ${name}`, - inputSchema: { type: "object" }, - })), - }, - })}\n\n`, - ); - return; - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - getVersionHits: () => versionHits, - }); - }); - }); -} - -function makeCreds(relayerUrl) { +function makeCreds() { return { - delegatePrivateKey: EXPECTED_BEARER, + delegatePrivateKey: "a".repeat(64), delegatePublicKeyHex: "b".repeat(64), delegateAddress: "0x" + "1".repeat(64), walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, + accountId: "0x" + "3".repeat(64), packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Coldstart Test", + relayerUrl: "http://127.0.0.1:9", + label: "coldstart", createdAt: new Date(0).toISOString(), version: 1, }; } -test("initialize is answered locally during a slow relayer cold start; tools/call is buffered then served", async (t) => { - const mock = await startSlowRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-coldstart-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); +test("initialize and tools/list are answered locally with no relayer", async (t) => { + const credsDir = mkdtempSync(join(tmpdir(), "memwal-coldstart-")); + writeFileSync(join(credsDir, "credentials.json"), JSON.stringify(makeCreds()), { mode: 0o600 }); - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, + const child = spawn(process.execPath, [BIN, "--relayer", "http://127.0.0.1:9"], { + env: { ...process.env, MEMWAL_CREDS_DIR: credsDir, HOME: credsDir, USERPROFILE: credsDir }, stdio: ["pipe", "pipe", "pipe"], }); const received = []; - const listeners = new Set(); let buf = ""; child.stdout.on("data", (d) => { buf += d.toString(); @@ -240,156 +57,51 @@ test("initialize is answered locally during a slow relayer cold start; tools/cal const line = buf.slice(0, nl); buf = buf.slice(nl + 1); if (!line.trim()) continue; - let msg; try { - msg = JSON.parse(line); + received.push(JSON.parse(line)); } catch { - continue; + /* ignore */ } - received.push({ msg, at: Date.now() }); - for (const l of [...listeners]) l(msg); } }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find((r) => pred(r.msg)); - if (hit) return Promise.resolve(hit.msg); + const waitFor = (pred, ms = 5_000) => { + const hit = received.find(pred); + if (hit) return Promise.resolve(hit); return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((r) => JSON.stringify(r.msg)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } + const started = Date.now(); + const tick = () => { + const found = received.find(pred); + if (found) return res(found); + if (Date.now() - started > ms) return rej(new Error("timed out")); + setTimeout(tick, 20); }; - listeners.add(l); + tick(); }); }; t.after(() => { child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); + rmSync(credsDir, { recursive: true, force: true }); }); - const startedAt = Date.now(); - - // 1) initialize must come back near-instantly — well before the relayer's - // SSE endpoint event (SSE_DELAY_MS). This is the core fix. We request a - // specific protocolVersion to confirm the local responder ECHOES it - // (rather than hard-coding one and ignoring the client's request). + const t0 = Date.now(); send({ jsonrpc: "2.0", id: 1, method: "initialize", - params: { protocolVersion: "2025-06-18", capabilities: {} }, + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "coldstart" } }, }); - const init = await waitFor((m) => m.id === 1 && m.result, SSE_DELAY_MS); - const initElapsed = Date.now() - startedAt; + const init = await waitFor((m) => m.id === 1 && m.result); + assert.ok(Date.now() - t0 < 1_000, "initialize must not wait on a relayer"); assert.equal(init.result.serverInfo.name, "memwal"); - assert.equal(init.result.capabilities.tools.listChanged, true); - assert.equal( - init.result.protocolVersion, - "2025-06-18", - `expected the local initialize to echo the requested protocolVersion, got ${init.result.protocolVersion}`, - ); - // The relayer sets `instructions` too, but this local answer wins and the - // upstream initialize reply is suppressed, so omitting it here strips the - // proactive contract from every stdio client. That is the WALM-324 - // regression, and it is invisible to every other assertion in this file. - assert.ok( - typeof init.result.instructions === "string" && init.result.instructions.length > 0, - "local initialize must carry instructions; without it lazy-loading clients never learn the memwal_* tools are relevant", - ); - assert.match(init.result.instructions, /memwal_recall/); - assert.match(init.result.instructions, /memwal_remember/); - assert.notEqual( - init.result.serverInfo.version, - "0.0.1", - "serverInfo.version must track package.json, not the old hardcoded stub", - ); - assert.ok( - initElapsed < SSE_DELAY_MS - 500, - `initialize took ${initElapsed}ms — expected it answered locally, well before the ${SSE_DELAY_MS}ms relayer connect`, - ); + assert.match(init.result.instructions, /RECALL: before answering/); - // 2) tools/list at cold start is served locally and instantly with the - // static list, which must be EXACTLY the upstream tool set plus the - // locally-served login/logout — each name once. send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); - const list = await waitFor((m) => m.id === 2 && m.result, SSE_DELAY_MS); - const coldNames = list.result.tools.map((t) => t.name); - const expectedNames = new Set([...UPSTREAM_TOOL_NAMES, "memwal_login", "memwal_logout"]); - // Unique names (TOOL_DEFINITIONS bundles its own memwal_login; a blind concat - // with the local login/logout defs would list it twice). - assert.equal( - new Set(coldNames).size, - coldNames.length, - `cold tools/list has duplicate tool names: ${coldNames}`, - ); - // Exact set match — guards against the cold list drifting from the real - // upstream registration (e.g. missing memwal_remember_bulk / memwal_health). - assert.deepEqual( - new Set(coldNames), - expectedNames, - `cold tools/list set mismatch. got ${[...coldNames].sort()}, expected ${[...expectedNames].sort()}`, - ); - - // 3) tools/call sent BEFORE the stream is up must be buffered and served - // once the background connect completes (not dropped, not hung). - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - const recall = await waitFor((m) => m.id === 3, 15_000); - assert.notEqual(recall.result?.isError, true); - assert.match(JSON.stringify(recall.result), /RECALL_OK/); - - // 4) Once connected, the bridge announces the real tool set so the client - // re-lists (notifications/tools/list_changed). - const changed = await waitFor((m) => m.method === "notifications/tools/list_changed", 5_000); - assert.ok(changed); - - // 5) The upstream initialize reply (serverInfo "memwal-upstream") must have - // been SUPPRESSED — the client only ever saw our local reply for id 1. - const initReplies = received.filter((r) => r.msg.id === 1 && r.msg.result); - assert.equal( - initReplies.length, - 1, - `expected exactly one initialize reply, saw ${initReplies.length}: ${JSON.stringify(initReplies.map((r) => r.msg))}`, - ); - assert.equal(initReplies[0].msg.result.serverInfo.name, "memwal"); - - // 6) After connect, a re-list is forwarded upstream and spliced with - // login/logout. That authoritative set must EQUAL the cold static set — - // the static→refreshed transition must not change the tool set (each - // name once, no dup even if upstream ever served login). - send({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }); - const relist = await waitFor((m) => m.id === 4 && m.result, 10_000); - const splicedNames = relist.result.tools.map((t) => t.name); - assert.equal( - new Set(splicedNames).size, - splicedNames.length, - `post-connect tools/list has duplicate tool names: ${splicedNames}`, - ); + const listed = await waitFor((m) => m.id === 2 && m.result); assert.deepEqual( - new Set(splicedNames), - new Set(coldNames), - `cold and post-connect tool sets differ. cold=${[...coldNames].sort()} spliced=${[...splicedNames].sort()}`, + listed.result.tools.map((t) => t.name), + SIGNED_IN_TOOLS, ); - - assert.ok(mock.getSseGetCount() >= 1, "expected at least one SSE handshake"); }); diff --git a/packages/mcp/test/coldstart-timeout.test.mjs b/packages/mcp/test/coldstart-timeout.test.mjs deleted file mode 100644 index 5530d639b..000000000 --- a/packages/mcp/test/coldstart-timeout.test.mjs +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Regression test for GH #415 (graceful-degradation gate) — a hung relayer must - * NOT be fatal (no SIGTERM), and must not corrupt the handshake. - * - * Repro: - * - Mock relayer answers GET /version, accepts the SSE GET, then goes SILENT - * forever (never sends the endpoint event) — a relayer that's up enough to - * accept the socket but never completes the MCP handshake. - * - With MEMWAL_MCP_CONNECT_TIMEOUT_MS small, each connect attempt aborts at - * the bound and retries with backoff. - * - * Asserts the corrected degraded-path behaviour (post adversarial review): - * - `initialize` is answered locally EXACTLY ONCE (guards the double-reply bug - * where the connect-failure path wrote a second envelope for the same id). - * - a buffered `tools/call` is NOT eager-failed between retries (a call the - * next attempt could serve must not get a spurious error; this also protects - * the auth-required hot-handoff request). - * - the process stays alive throughout (no SIGTERM / startup failure). - * - on shutdown (stdin close) the still-open call is closed out with an error - * envelope rather than left hanging. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer that accepts the SSE GET but NEVER sends the endpoint event — - * the initial connect hangs until the bridge's bounded timeout aborts it. */ -function startHungRelayer() { - let sseGetCount = 0; - const openStreams = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - // Deliberately send nothing else — the handshake never completes. - openStreams.push(res); - return; - } - // No messages endpoint is ever reached (no session established). - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - closeStreams: () => openStreams.forEach((r) => r.end()), - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Coldstart Timeout Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -test("a hung relayer bounds the connect and returns a tool-call error instead of hanging", async (t) => { - const mock = await startHungRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-coldstart-timeout-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - MEMWAL_MCP_CONNECT_TIMEOUT_MS: "1000", - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => { - child.kill("SIGKILL"); - mock.closeStreams(); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - // initialize is still answered locally even though the relayer never - // completes its handshake. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 5_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - // A tool call buffered before connect. The relayer never comes up, so the - // connect retries with backoff. The call must NOT be eager-failed between - // attempts (a call the next attempt could serve must not get a spurious - // error — that also protects the auth-required hot-handoff request). It - // stays pending; the client's own per-tool timeout would handle it. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - - // Wait out several connect-retry cycles (timeout 1s + backoff). During this - // window the buffered call must NOT have been answered with an error. - await new Promise((r) => setTimeout(r, 4_500)); - assert.ok( - !received.some((m) => m.id === 2), - `id=2 must stay buffered during retries, but got a reply: ${JSON.stringify(received.find((m) => m.id === 2))}`, - ); - - // Regression guard for the double-`initialize` bug: id=1 must have been - // answered EXACTLY once (the local reply) — never a second envelope from the - // connect-failure path. - const initReplies = received.filter((m) => m.id === 1 && (m.result || m.error)); - assert.equal( - initReplies.length, - 1, - `initialize (id=1) must be answered exactly once; saw ${initReplies.length}: ${JSON.stringify(initReplies)}`, - ); - - // Process is still alive despite the hung relayer — the whole point is no - // SIGTERM / startup failure. - assert.equal(child.exitCode, null, "bridge should still be running, not exited"); - - // On shutdown (stdin closes) the still-buffered tool call is closed out with - // an error envelope instead of being left hanging. - child.stdin.end(); - const recall = await waitFor((m) => m.id === 2, 5_000); - assert.equal(recall.result?.isError, true, `expected isError envelope on shutdown, got ${JSON.stringify(recall)}`); - assert.match(JSON.stringify(recall.result), /relayer unavailable/i); -}); diff --git a/packages/mcp/test/concurrent-recall.test.mjs b/packages/mcp/test/concurrent-recall.test.mjs deleted file mode 100644 index a1a28b6c1..000000000 --- a/packages/mcp/test/concurrent-recall.test.mjs +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Concurrent tools/call must not overlap POSTs on the SSE session. - * Overlapping POSTs drop the session; afterwards even health hung until restart. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const BEARER = "a".repeat(64); -const ACCOUNT = "0x" + "3".repeat(64); - -function startMockRelayer() { - let sseRes = null; - let inFlightPosts = 0; - let overlap = 0; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - inFlightPosts += 1; - if (inFlightPosts > 1) overlap += 1; - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - setTimeout(() => { - inFlightPosts -= 1; - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "tools/call") { - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: `OK:${msg.params?.name}` }], - isError: false, - }, - })}\n\n`, - ); - } - }, 40); - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((resolveListen) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - resolveListen({ - server, - base: `http://127.0.0.1:${port}`, - overlap: () => overlap, - }); - }); - }); -} - -test("concurrent tool calls do not overlap POSTs on the SSE session", async (t) => { - const { server, base, overlap } = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-test-")); - mkdirSync(join(home, ".memwal")); - writeFileSync( - join(home, ".memwal", "credentials.json"), - JSON.stringify({ - delegatePrivateKey: BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: ACCOUNT, - packageId: "0x" + "4".repeat(64), - relayerUrl: base, - label: "test", - createdAt: new Date(0).toISOString(), - version: 1, - }), - ); - - const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej(new Error("timed out waiting for message")); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => { - child.kill("SIGKILL"); - server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test", version: "0" } } }); - await waitFor((m) => m.id === 1 && m.result); - - send({ jsonrpc: "2.0", id: 2, method: "notifications/initialized" }); - await waitFor((m) => m.method === "notifications/tools/list_changed"); - - for (const id of [10, 11, 12, 13]) { - send({ - jsonrpc: "2.0", - id, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: `q${id}` } }, - }); - } - - const replies = []; - for (const id of [10, 11, 12, 13]) { - replies.push(await waitFor((m) => m.id === id && (m.result || m.error))); - } - assert.equal(overlap(), 0); - for (const reply of replies) { - assert.equal(reply.error, undefined); - const text = reply.result?.content?.[0]?.text ?? ""; - assert.match(text, /^OK:/); - assert.doesNotMatch(text, /did not answer this call/); - } -}); diff --git a/packages/mcp/test/default-namespace.test.mjs b/packages/mcp/test/default-namespace.test.mjs index cca141c8a..a1760fc66 100644 --- a/packages/mcp/test/default-namespace.test.mjs +++ b/packages/mcp/test/default-namespace.test.mjs @@ -1,44 +1,21 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { applyDefaultNamespace } from "../dist/bridge.js"; +import { applyDefaultNamespace } from "../dist/namespace.js"; test("applyDefaultNamespace injects configured namespace into memwal_remember_bulk", () => { - const msg = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "memwal_remember_bulk", - arguments: { - facts: ["fact 1", "fact 2"], - }, - }, - }; - - const updated = applyDefaultNamespace(msg, "project-alpha"); - assert.equal(updated.params.arguments.namespace, "project-alpha"); - assert.deepEqual(updated.params.arguments.facts, ["fact 1", "fact 2"]); - // The call site ignores the return value — in-place mutation is the real contract. - assert.equal(msg.params.arguments.namespace, "project-alpha"); + const args = { facts: ["fact 1", "fact 2"] }; + const updated = applyDefaultNamespace("memwal_remember_bulk", args, "project-alpha"); + assert.equal(updated.namespace, "project-alpha"); + assert.deepEqual(updated.facts, ["fact 1", "fact 2"]); + // Original args are not mutated. + assert.equal(args.namespace, undefined); }); test("applyDefaultNamespace respects explicit namespace on memwal_remember_bulk", () => { - const msg = { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: "memwal_remember_bulk", - arguments: { - facts: ["fact 1"], - namespace: "explicit-scope", - }, - }, - }; - - const updated = applyDefaultNamespace(msg, "project-alpha"); - assert.equal(updated.params.arguments.namespace, "explicit-scope"); + const args = { facts: ["fact 1"], namespace: "explicit-scope" }; + const updated = applyDefaultNamespace("memwal_remember_bulk", args, "project-alpha"); + assert.equal(updated.namespace, "explicit-scope"); }); test("applyDefaultNamespace injects into all namespace-aware tools", () => { @@ -51,73 +28,25 @@ test("applyDefaultNamespace injects into all namespace-aware tools", () => { ]; for (const toolName of tools) { - const msg = { - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { - name: toolName, - arguments: {}, - }, - }; - - const updated = applyDefaultNamespace(msg, "shared-namespace"); + const updated = applyDefaultNamespace(toolName, {}, "shared-namespace"); assert.equal( - updated.params.arguments.namespace, + updated.namespace, "shared-namespace", - `expected default namespace to be injected for ${toolName}` + `expected default namespace to be injected for ${toolName}`, ); } }); -test("applyDefaultNamespace does not touch unrelated tools or non-call RPC messages", () => { - const loginMsg = { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { - name: "memwal_login", - arguments: {}, - }, - }; - const updatedLogin = applyDefaultNamespace(loginMsg, "test-ns"); - assert.equal(updatedLogin.params.arguments.namespace, undefined); - - const listMsg = { - jsonrpc: "2.0", - id: 5, - method: "tools/list", - params: { name: "memwal_remember_bulk", arguments: {} }, - }; - const updatedList = applyDefaultNamespace(listMsg, "test-ns"); - assert.equal(updatedList.params.arguments.namespace, undefined); -}); - -test("applyDefaultNamespace is a no-op when no default is configured", () => { - const msg = { - jsonrpc: "2.0", - id: 6, - method: "tools/call", - params: { name: "memwal_remember_bulk", arguments: { facts: ["fact 1"] } }, - }; +test("applyDefaultNamespace does not touch unrelated tools or empty defaults", () => { + const login = applyDefaultNamespace("memwal_login", {}, "shared-namespace"); + assert.equal(login.namespace, undefined); - applyDefaultNamespace(msg, undefined); - assert.equal(msg.params.arguments.namespace, undefined); -}); + const health = applyDefaultNamespace("memwal_health", {}, "shared-namespace"); + assert.equal(health.namespace, undefined); -test("applyDefaultNamespace overrides a blank explicit namespace", () => { - for (const blank of ["", " "]) { - const msg = { - jsonrpc: "2.0", - id: 7, - method: "tools/call", - params: { - name: "memwal_remember_bulk", - arguments: { facts: ["fact 1"], namespace: blank }, - }, - }; + const none = applyDefaultNamespace("memwal_remember", { text: "hi" }, undefined); + assert.equal(none.namespace, undefined); - applyDefaultNamespace(msg, "project-alpha"); - assert.equal(msg.params.arguments.namespace, "project-alpha"); - } + const blank = applyDefaultNamespace("memwal_remember", { namespace: " " }, "work"); + assert.equal(blank.namespace, "work"); }); diff --git a/packages/mcp/test/expired-credentials-recall.test.mjs b/packages/mcp/test/expired-credentials-recall.test.mjs deleted file mode 100644 index c84dd2d7c..000000000 --- a/packages/mcp/test/expired-credentials-recall.test.mjs +++ /dev/null @@ -1,831 +0,0 @@ -/** - * WALM-602 / GH #365 — an expired session must be distinguishable from an - * empty namespace. - * - * The original report was "recall silently returns empty instead of an auth - * error". The server half of that closed in 0.0.11 (`45b0ad87` made the MCP - * proxy require a registered delegate, so an unregistered key no longer opens - * a session that then honestly reports zero rows). What remains is the client - * half: a relayer that rejects the credentials 401s the SSE handshake, and the - * bridge's background connect treats that like any other connect failure — - * exponential-backoff retry — so the queued tool call waits out the orphan - * sweeper instead of being told the credentials were rejected. - * - * These tests pin the distinction the ticket asks for, and the way back out of - * it: - * - rejected credentials -> an auth error naming the way back in - * - valid creds, no hits -> an ordinary empty result, NOT an error - * - still rejected -> refused again, fast, with the bridge alive - * - `memwal_login` afterwards -> service restored, promptly - * - revoked mid-session -> the in-flight call answered, not orphaned - * - transient 401 mid-session -> recovers with no client intervention - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const BEARER = "a".repeat(64); -const ACCOUNT = "0x" + "3".repeat(64); - -/** Bound the whole exchange. Long enough for a couple of reconnect backoffs, - * short enough that a hang fails the test instead of stalling the suite. */ -const CALL_TIMEOUT_MS = 4000; - -function serveVersion(res) { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); -} - -/** - * Relayer that rejects the delegate key on the SSE handshake — what the proxy - * now does for a revoked or never-registered delegate. - */ -function startRejectingRelayer() { - let sseAttempts = 0; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - serveVersion(res); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - sseAttempts += 1; - res.writeHead(401, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "delegate key is not registered" })); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((ready) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - ready({ - server, - base: `http://127.0.0.1:${port}`, - sseAttempts: () => sseAttempts, - }); - }); - }); -} - -/** - * Healthy relayer whose namespace simply holds nothing — the contrast case. - * Mirrors the sidecar's own wording for a genuinely empty namespace. - */ -function startEmptyNamespaceRelayer() { - let sseRes = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - serveVersion(res); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "tools/call") { - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: "No matching memories found." }], - isError: false, - }, - })}\n\n`, - ); - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((ready) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - ready({ server, base: `http://127.0.0.1:${port}` }); - }); - }); -} - -/** Spawn the bridge against `base` with credentials on disk, wired for stdio. */ -function startBridge(base) { - const home = mkdtempSync(join(tmpdir(), "memwal-test-")); - mkdirSync(join(home, ".memwal")); - writeFileSync( - join(home, ".memwal", "credentials.json"), - JSON.stringify({ - delegatePrivateKey: BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: ACCOUNT, - packageId: "0x" + "4".repeat(64), - relayerUrl: base, - label: "test", - createdAt: new Date(0).toISOString(), - version: 1, - }), - ); - - const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - MEMWAL_MCP_CALL_TIMEOUT_MS: String(CALL_TIMEOUT_MS), - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej(new Error("timed out waiting for message")); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - return { - send, - waitFor, - cleanup: () => { - child.kill("SIGKILL"); - rmSync(home, { recursive: true, force: true }); - }, - }; -} - -function textOf(msg) { - const content = msg?.result?.content; - if (!Array.isArray(content)) return ""; - return content.map((c) => c?.text ?? "").join("\n"); -} - -test("recall on rejected credentials reports an auth error, not empty results", async (t) => { - const { server, base } = await startRejectingRelayer(); - const bridge = startBridge(base); - t.after(() => { - bridge.cleanup(); - server.close(); - }); - - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - - // Generous relative to CALL_TIMEOUT_MS so a slow machine doesn't flake, but - // far below the 240s production default: the point is that the answer comes - // from the 401, not from waiting out the orphan sweeper. - const reply = await bridge.waitFor((m) => m.id === 2 && (m.result || m.error), 20000); - const text = `${textOf(reply)} ${reply?.error?.message ?? ""}`.toLowerCase(); - - assert.ok( - reply.error || reply.result?.isError, - `recall against rejected credentials must be an error, got: ${JSON.stringify(reply)}`, - ); - assert.ok( - !text.includes("no matching memories"), - "rejected credentials must not read as an empty namespace", - ); - assert.ok( - /401|credential|unauthorized|signed out|memwal_login/.test(text), - `error must name the auth failure and the way back in, got: ${text}`, - ); -}); - -test("recall on an empty namespace reports empty results, not an auth error", async (t) => { - const { server, base } = await startEmptyNamespaceRelayer(); - const bridge = startBridge(base); - t.after(() => { - bridge.cleanup(); - server.close(); - }); - - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - - const reply = await bridge.waitFor((m) => m.id === 2 && (m.result || m.error), 20000); - const text = textOf(reply); - - assert.equal(reply.error, undefined, `empty namespace must not error: ${JSON.stringify(reply)}`); - assert.notEqual(reply.result?.isError, true, "empty namespace must not be an error result"); - assert.match(text, /no matching memories/i); - assert.ok( - !/401|unauthorized|signed out/i.test(text), - `empty namespace must not read as an auth failure, got: ${text}`, - ); -}); - -/** - * Relayer that 401s one specific delegate key and accepts every other one — - * what a revoked key looks like once `memwal_login` has registered a fresh one. - * Sessions that DO open answer `tools/call` with an ordinary empty result, so - * "recovered" is distinguishable from "still refusing". - * - * `holdRejections` parks each 401 until `releaseRejections()`, so a test can - * queue requests before the bridge learns the key is rejected. - */ -function startRevokedKeyRelayer(revokedBearer, { holdRejections = false } = {}) { - let sseRes = null; - let rejections = 0; - let accepted = 0; - let held = []; - const reject = (res) => { - rejections += 1; - res.writeHead(401, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "delegate key is not registered" })); - }; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - serveVersion(res); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - const bearer = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, ""); - if (bearer === revokedBearer) { - if (holdRejections) held.push(res); - else reject(res); - return; - } - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - accepted += 1; - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=recovered\n\n"); - const heartbeat = setInterval(() => res.write(": keepalive\n\n"), 250); - heartbeat.unref?.(); - res.on("close", () => clearInterval(heartbeat)); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.id == null) return; - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: - msg.method === "tools/call" - ? { - content: [ - { type: "text", text: "No matching memories found." }, - ], - isError: false, - } - : {}, - })}\n\n`, - ); - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((ready) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - ready({ - server, - base: `http://127.0.0.1:${port}`, - rejections: () => rejections, - accepted: () => accepted, - releaseRejections: () => { - holdRejections = false; - for (const res of held.splice(0)) reject(res); - }, - }); - }); - }); -} - -/** Drive the browser half of `memwal_login` against the bridge's own localhost - * listener — same handshake the dashboard performs (preflight, then callback). - * Mirrors `live-login-credentials.test.mjs`. */ -async function completeLogin(connectUrl, accountId) { - const url = new URL(connectUrl); - const callbackBase = `http://127.0.0.1:${url.searchParams.get("port")}`; - const headers = { origin: url.origin, "content-type": "application/json" }; - const body = { - state: url.searchParams.get("connectState"), - publicKey: url.searchParams.get("publicKey"), - relayer: url.searchParams.get("relayer"), - }; - - const preflight = await fetch(`${callbackBase}/preflight`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - assert.equal(preflight.status, 200); - - const callback = await fetch(`${callbackBase}/callback`, { - method: "POST", - headers, - body: JSON.stringify({ - state: body.state, - accountId, - walletAddress: "0x" + "2".repeat(64), - packageId: "0x" + "4".repeat(64), - }), - }); - assert.equal(callback.status, 200); -} - -/** Poll until `predicate` holds. Same shape as `live-login-credentials`. */ -async function waitUntil(predicate, timeoutMs = 10_000) { - const started = Date.now(); - while (!predicate()) { - if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for condition"); - await new Promise((r) => setTimeout(r, 25)); - } -} - -test("a rejected key keeps failing fast, and memwal_login restores service", async (t) => { - const relayer = await startRevokedKeyRelayer(BEARER); - const { server, base } = relayer; - const bridge = startBridge(base); - t.after(() => { - bridge.cleanup(); - server.close(); - }); - - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - - const recall = (id) => { - bridge.send({ - jsonrpc: "2.0", - id, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - return bridge.waitFor((m) => m.id === id && (m.result || m.error), 20000); - }; - - const first = await recall(2); - assert.ok(first.result?.isError || first.error, "first recall must be an auth error"); - - // The bridge must still be reading stdin after the 401 answered the first - // call. A second recall is refused ON ARRIVAL, so it comes back well inside - // CALL_TIMEOUT_MS — anything near that deadline means it parked instead. - const startedAt = Date.now(); - const second = await recall(3); - const elapsed = Date.now() - startedAt; - assert.ok( - second.result?.isError || second.error, - `second recall must also be an auth error, got: ${JSON.stringify(second)}`, - ); - assert.ok( - elapsed < CALL_TIMEOUT_MS / 2, - `second recall must fail fast, took ${elapsed}ms (deadline ${CALL_TIMEOUT_MS}ms)`, - ); - - // Let the background connect back off a few times before signing in — a - // real user takes seconds to click the link. By the 4th rejection the loop - // is asleep for ~4s, which is long enough that "the pump woke because the - // login published a session" and "the pump woke because the backoff - // happened to expire" are no longer the same measurement. - await waitUntil(() => relayer.rejections() >= 4, 15_000); - - // `memwal_login` is answered locally, so it must still work while the saved - // key is being refused — it is the only way back in. - bridge.send({ - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, - }); - const loginReply = await bridge.waitFor((m) => m.id === 4 && m.result, 20000); - const connectUrl = /\*\*URL:\*\* (\S+)/.exec(textOf(loginReply))?.[1]; - assert.ok(connectUrl, `memwal_login must return the browser URL, got: ${textOf(loginReply)}`); - await completeLogin(connectUrl, ACCOUNT); - // The login's own reconnect owns the new handshake; wait for the relayer to - // accept it before asking for the recall, so the assertion below is about - // the flag being cleared and not about who won a race. - await waitUntil(() => relayer.accepted() > 0); - - // The new key is accepted, so the bridge must resume normal buffering: an - // ordinary empty result, not the credentials-rejected refusal. It must also - // land promptly: the login's own reconnect has to release the server pump, - // because nothing else is draining this stream until the background - // connect's backoff — up to 15s in production — next expires. - const recoveredAt = Date.now(); - const recovered = await recall(5); - const recoveredIn = Date.now() - recoveredAt; - assert.equal( - recovered.error, - undefined, - `recall after re-login must not error: ${JSON.stringify(recovered)}`, - ); - assert.notEqual( - recovered.result?.isError, - true, - `recall after re-login must not be refused: ${JSON.stringify(recovered)}`, - ); - assert.match(textOf(recovered), /no matching memories/i); - assert.ok( - recoveredIn < 1500, - `recall after re-login must not wait for the connect backoff, took ${recoveredIn}ms`, - ); - // The saved key really was refused throughout, rather than the relayer - // having quietly accepted it at some point. - assert.ok(relayer.rejections() > 0, "the revoked key must have been 401'd"); -}); - -test("an initialize queued before the 401 does not swallow a reused id after memwal_login", async (t) => { - const relayer = await startRevokedKeyRelayer(BEARER, { holdRejections: true }); - const { server, base } = relayer; - const bridge = startBridge(base); - t.after(() => { - bridge.cleanup(); - server.close(); - }); - - // The bridge answers initialize locally and arms a suppression for the - // upstream reply it expects once the initialize is forwarded. With the 401 - // held, both requests are still queued when the key is rejected, so neither - // is ever forwarded and no upstream reply comes to consume that arm. - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - relayer.releaseRejections(); - const refused = await bridge.waitFor((m) => m.id === 2 && (m.result || m.error), 20000); - assert.ok(refused.result?.isError || refused.error, "queued recall must be an auth error"); - - bridge.send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, - }); - const loginReply = await bridge.waitFor((m) => m.id === 3 && m.result, 20000); - const connectUrl = /\*\*URL:\*\* (\S+)/.exec(textOf(loginReply))?.[1]; - assert.ok(connectUrl, `memwal_login must return the browser URL, got: ${textOf(loginReply)}`); - await completeLogin(connectUrl, ACCOUNT); - await waitUntil(() => relayer.accepted() > 0); - - // JSON-RPC lets a client reuse an id once its request is answered. A - // leftover arm drops this genuine reply and untracks the id, so not even - // the orphan sweeper answers it: the call hangs. - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - const reply = await bridge.waitFor( - (m) => m.id === 1 && Array.isArray(m.result?.content), - 20000, - ); - assert.notEqual( - reply.result.isError, - true, - `reused id must get the relayer's reply, got: ${JSON.stringify(reply)}`, - ); - assert.match(textOf(reply), /no matching memories/i); -}); - -/** - * Relayer whose key is revoked WHILE a session is live: the open stream is cut - * and every later handshake 401s. `restore()` puts it back, standing in for a - * WAF or rate-limit 401 that clears on its own. - */ -function startMidSessionRevokeRelayer() { - let sseRes = null; - let rejecting = false; - let parkCalls = false; - let accepted = 0; - let rejections = 0; - let calls = 0; - - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - serveVersion(res); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (rejecting) { - rejections += 1; - res.writeHead(401, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "delegate key was revoked" })); - return; - } - accepted += 1; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=s${accepted}\n\n`); - const heartbeat = setInterval(() => res.write(": keepalive\n\n"), 250); - heartbeat.unref?.(); - res.on("close", () => clearInterval(heartbeat)); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.id == null) return; - if (msg.method === "tools/call") { - calls += 1; - // Park it: the point of the revocation case is a call that - // is already in flight when the key stops being accepted. - if (parkCalls) return; - } - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: - msg.method === "tools/call" - ? { - content: [ - { type: "text", text: "No matching memories found." }, - ], - isError: false, - } - : {}, - })}\n\n`, - ); - }); - return; - } - res.writeHead(404); - res.end(); - }); - - return new Promise((ready) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - ready({ - server, - base: `http://127.0.0.1:${port}`, - accepted: () => accepted, - rejections: () => rejections, - calls: () => calls, - park: () => { - parkCalls = true; - }, - revoke: () => { - rejecting = true; - parkCalls = false; - sseRes?.destroy(); - sseRes = null; - }, - restore: () => { - rejecting = false; - }, - }); - }); - }); -} - -test("a key revoked mid-session answers the in-flight call instead of orphaning it", async (t) => { - const relayer = await startMidSessionRevokeRelayer(); - const bridge = startBridge(relayer.base); - t.after(() => { - bridge.cleanup(); - relayer.server.close(); - }); - - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - await waitUntil(() => relayer.accepted() > 0); - - // In flight against a live session, with no reply coming. - relayer.park(); - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - await waitUntil(() => relayer.calls() > 0); - - // The key is revoked underneath it: the stream is cut and the reconnect - // that follows is 401'd. - const revokedAt = Date.now(); - relayer.revoke(); - - const reply = await bridge.waitFor((m) => m.id === 2 && (m.result || m.error), 20000); - const elapsed = Date.now() - revokedAt; - const text = `${textOf(reply)} ${reply?.error?.message ?? ""}`.toLowerCase(); - - assert.ok(reply.error || reply.result?.isError, "the in-flight call must be answered as error"); - assert.match( - text, - /401|credential|unauthorized|memwal_login/, - `the in-flight call must name the rejection, got: ${text}`, - ); - assert.ok( - !text.includes("please retry"), - `"please retry" is the orphan sweeper's advice and cannot work here, got: ${text}`, - ); - assert.ok( - elapsed < CALL_TIMEOUT_MS, - `must beat the orphan sweeper's ${CALL_TIMEOUT_MS}ms deadline, took ${elapsed}ms`, - ); -}); - -test("a transient mid-session 401 recovers on its own, without memwal_login", async (t) => { - const relayer = await startMidSessionRevokeRelayer(); - const bridge = startBridge(relayer.base); - t.after(() => { - bridge.cleanup(); - relayer.server.close(); - }); - - bridge.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "test", version: "0" }, - }, - }); - await bridge.waitFor((m) => m.id === 1 && m.result, 15000); - await waitUntil(() => relayer.accepted() > 0); - - // A WAF or rate-limit blip: 401 for a while, then fine again. Nothing here - // calls `memwal_login` — the saved key was always good. - relayer.revoke(); - await waitUntil(() => relayer.rejections() >= 2, 15_000); - relayer.restore(); - - // The server pump keeps driving `reconnect()` on the dead stream, so the - // bridge must find its own way back without the client intervening. - await waitUntil(() => relayer.accepted() >= 2, 20_000); - - bridge.send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything", limit: 5 } }, - }); - const reply = await bridge.waitFor((m) => m.id === 3 && (m.result || m.error), 20000); - assert.equal(reply.error, undefined, `recovered recall must not error: ${JSON.stringify(reply)}`); - assert.notEqual( - reply.result?.isError, - true, - `recovered recall must not still be refused: ${JSON.stringify(reply)}`, - ); - assert.match(textOf(reply), /no matching memories/i); -}); diff --git a/packages/mcp/test/handshake-contract.mjs b/packages/mcp/test/handshake-contract.mjs index 32b5ab75d..48a991233 100644 --- a/packages/mcp/test/handshake-contract.mjs +++ b/packages/mcp/test/handshake-contract.mjs @@ -21,11 +21,10 @@ * lives in services/server/scripts, so a published npm package alone * changes nothing for a signed-in user. Testing before the deploy lands * measures the old server and looks like the fix failed. - * 2. Cold start vs post-connect drift. The bridge answers the first - * `tools/list` locally from TOOL_DEFINITIONS, then emits - * `notifications/tools/list_changed` and the client re-lists against the - * relayer. The two lists disagreed in exactly the fields under test, so - * the answer depended on when you looked. + * 2. Cold start vs post-connect drift. The stdio server answers + * `tools/list` locally from TOOL_DEFINITIONS. There is no second + * upstream list. A signed-in session and a signed-out session differ + * only in wording and whether `memwal_logout` is advertised. * 3. Reading the chat UI instead of the wire. A tool card cannot tell * "never called" from "called and failed". * @@ -84,11 +83,10 @@ function connect() { send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); } else if (m.id === 2 && m.result) { out.cold = m.result.tools; - } else if (m.method === "notifications/tools/list_changed") { - out.changed = true; - send({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }); - } else if (m.id === 3 && m.result) { + // stdio serves the full tool list locally. There is no + // second "upstream" list from an SSE session. out.upstream = m.result.tools; + out.changed = false; clearTimeout(timer); finish(); } @@ -115,8 +113,8 @@ const up = byName(r.upstream); // Distinguish the two ways `upstream` can be missing. Conflating them sends a // tester to re-login when the real problem is a relayer that is down or // redeploying — which is exactly what a mid-deploy 502 looks like here. -const hasCredentials = (r.init?.instructions || "").length > 500; -const signedIn = Boolean(r.upstream); +const signedIn = /RECALL: before answering/i.test(r.init?.instructions || ""); +const hasCredentials = signedIn; // --- 1. initialize carries instructions ------------------------------------- const instructions = r.init?.instructions || ""; @@ -144,14 +142,14 @@ if (signedIn) { ); } -// --- 2. the client reached the relayer -------------------------------------- +// --- 2. signed-in clients get the proactive list locally -------------------- check( - "relayer connected and client re-listed", + "signed-in tools/list is local (no SSE re-list)", signedIn, signedIn - ? "tools/list_changed received" + ? "stdio served the signed-in tool list" : hasCredentials - ? "credentials FOUND but relayer never connected — relayer down or redeploying?" + ? "credentials FOUND but initialize did not look signed-in" : "no credentials — sign in, then re-run" ); diff --git a/packages/mcp/test/health-relayer-annotation.test.mjs b/packages/mcp/test/health-relayer-annotation.test.mjs index 9d8e7fc81..bd5da9926 100644 --- a/packages/mcp/test/health-relayer-annotation.test.mjs +++ b/packages/mcp/test/health-relayer-annotation.test.mjs @@ -1,12 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { annotateHealthResult } from "../dist/bridge.js"; - -// The relayer can only name an origin its deployment published, and says -// nothing on a self-hosted or local one — the sidecar there knows only the -// loopback address it dials. The bridge always knows the URL it connected to, -// which is exactly what `--prod` / `--relayer` / MEMWAL_SERVER_URL selected. +import { annotateHealthResult } from "../dist/format.js"; const DEV = "https://relayer.dev.memwal.ai"; @@ -16,7 +11,6 @@ test("names the dialled relayer when the reply carries none", () => { const result = healthResult("Walrus Memory is reachable. status=ok version=1.2.3"); annotateHealthResult(result, DEV); assert.ok(result.content[0].text.includes(`relayer=${DEV}`)); - // Existing fields must survive. assert.ok(result.content[0].text.includes("status=ok")); assert.ok(result.content[0].text.includes("version=1.2.3")); }); @@ -30,12 +24,10 @@ test("replaces the relayer the reply already carried rather than adding a second assert.equal(text.match(/relayer=/g).length, 1, `two relayer fields:\n${text}`); assert.ok(text.includes(`relayer=${DEV}`)); assert.ok(!text.includes("stale.example")); - // The field after it must not be eaten by the replacement. assert.ok(text.includes("write_ready=true")); }); test("leaves a failed health call alone", () => { - // Naming a relayer beside an error reads as though that relayer answered. const result = { ...healthResult("relayer unreachable"), isError: true }; annotateHealthResult(result, DEV); assert.equal(result.content[0].text, "relayer unreachable"); diff --git a/packages/mcp/test/initialize-id-reuse.test.mjs b/packages/mcp/test/initialize-id-reuse.test.mjs deleted file mode 100644 index 571d215ab..000000000 --- a/packages/mcp/test/initialize-id-reuse.test.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Regression test for GH #415 round-3 finding T2 — suppression of the upstream - * `initialize` reply must be EXACTLY ONE reply, not a permanent id filter. - * - * Bug being guarded against: the bridge answers `initialize` locally and forwards - * it upstream, suppressing the upstream reply. An earlier fix kept the initialize - * id in a suppress set for the whole session AND dropped ANY error on that id — so - * a client that (out of spec, but defensively supported) reuses the initialize id - * for a later real request had that request's reply (result OR error) silently - * swallowed, hanging the call. The fix makes suppression a one-shot count. - * - * This test reuses id=1 for a real tools/call after initialize and asserts the - * real reply comes through — for both a success and an error response. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer that answers immediately. It replies to initialize with an - * upstream initialize result (to be suppressed), a memwal_recall call with a - * success, and a memwal_restore call with a JSON-RPC error. */ -function startRelayer() { - const sessions = new Map(); - let sseGetCount = 0; - const server = http.createServer((req, res) => { - const u = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && u.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ apiVersion: "1.0.0", relayerVersion: "1.0.0", minSupportedSdk: { mcp: "0.0.1" } })); - return; - } - if (req.method === "GET" && u.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }); - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, { res }); - const hb = setInterval(() => { if (!res.writableEnded) res.write(":\n\n"); else clearInterval(hb); }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - return; - } - if (req.method === "POST" && u.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { res.writeHead(401); res.end(); return; } - const session = sessions.get(u.searchParams.get("sessionId")); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - let msg; - try { msg = JSON.parse(body); } catch { res.writeHead(202); res.end(); return; } - if (!session) { res.writeHead(404); res.end(); return; } - res.writeHead(202); res.end(); - const name = msg.params?.name; - if (msg.method === "initialize") { - // Upstream initialize reply — must be suppressed by the bridge. - session.res.write(`event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", id: msg.id, - result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "memwal-upstream", version: "9.9.9" } }, - })}\n\n`); - return; - } - if (msg.method === "tools/call" && name === "memwal_recall") { - session.res.write(`event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", id: msg.id, - result: { content: [{ type: "text", text: "REUSED_ID_RESULT" }], isError: false }, - })}\n\n`); - return; - } - if (msg.method === "tools/call" && name === "memwal_restore") { - // A genuine JSON-RPC ERROR on a (reused) id — must reach the client. - session.res.write(`event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", id: msg.id, - error: { code: -32000, message: "REUSED_ID_ERROR" }, - })}\n\n`); - return; - } - }); - return; - } - res.writeHead(404); res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => res({ server, base: `http://127.0.0.1:${server.address().port}` })); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, packageId: "0x" + "4".repeat(64), - relayerUrl, label: "IdReuse Test", createdAt: new Date(0).toISOString(), version: 1, - }; -} - -test("reusing the initialize id for a real request still gets that request's reply (result and error)", async (t) => { - const mock = await startRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-idreuse-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; try { msg = JSON.parse(line); } catch { continue; } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { listeners.delete(l); rej(new Error(`timeout\n${stderrBuf}\n${received.map((m) => JSON.stringify(m)).join("\n")}`)); }, ms); - const l = (m) => { if (pred(m)) { clearTimeout(timer); listeners.delete(l); res(m); } }; - listeners.add(l); - }); - }; - - t.after(() => { child.kill("SIGKILL"); mock.server.close(); rmSync(home, { recursive: true, force: true }); }); - - // initialize (id=1) → local reply; upstream reply suppressed (one-shot). - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 5_000); - assert.equal(init.result.serverInfo.name, "memwal"); // local, not "memwal-upstream" - - // Let the connect settle so the request goes to the live session. - await new Promise((r) => setTimeout(r, 500)); - - // REUSE id=1 for a real tools/call with a SUCCESS reply — must come through. - send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memwal_recall", arguments: { query: "x" } } }); - const okReply = await waitFor((m) => m.id === 1 && m.result && JSON.stringify(m.result).includes("REUSED_ID_RESULT"), 8_000); - assert.ok(okReply, "reused-id success reply was suppressed (should pass through)"); - - // REUSE id=1 again for a request whose upstream reply is an ERROR — must come through. - send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memwal_restore", arguments: { namespace: "n" } } }); - const errReply = await waitFor((m) => m.id === 1 && m.error && m.error.message === "REUSED_ID_ERROR", 8_000); - assert.ok(errReply, "reused-id error reply was suppressed (should pass through)"); -}); diff --git a/packages/mcp/test/live-login-credentials.test.mjs b/packages/mcp/test/live-login-credentials.test.mjs index 88c50bd43..ae76955e0 100644 --- a/packages/mcp/test/live-login-credentials.test.mjs +++ b/packages/mcp/test/live-login-credentials.test.mjs @@ -1,8 +1,11 @@ +/** + * In-session `memwal_login` replaces credentials.json. The next tool call + * reloads the file — no SSE reconnect. + */ import { test } from "node:test"; import assert from "node:assert/strict"; -import http from "node:http"; import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,6 +17,7 @@ const ACCOUNT_A = `0x${"1".repeat(64)}`; const ACCOUNT_B = `0x${"2".repeat(64)}`; const WALLET = `0x${"3".repeat(64)}`; const PACKAGE = `0x${"4".repeat(64)}`; +const WEB = "http://127.0.0.1:9"; function makeCreds(relayerUrl) { return { @@ -30,181 +34,47 @@ function makeCreds(relayerUrl) { }; } -function startMockRelayer() { - const sessions = new Map(); - const handshakes = []; - const posts = []; - let nextSession = 1; - let delayNextHandshake = false; - let delayedHandshake = null; - - function establishSession(sessionId, res) { - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - const heartbeat = setInterval(() => res.write(": keepalive\n\n"), 250); - heartbeat.unref?.(); - res.on("close", () => clearInterval(heartbeat)); - } - - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - const bearer = req.headers.authorization; - const accountId = req.headers["x-memwal-account-id"]; - const sessionId = `session-${nextSession++}`; - handshakes.push({ bearer, accountId, sessionId }); - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.flushHeaders(); - sessions.set(sessionId, { res, bearer, accountId }); - if (delayNextHandshake) { - delayNextHandshake = false; - delayedHandshake = { sessionId, res }; - } else { - establishSession(sessionId, res); - } - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - const session = sessions.get(url.searchParams.get("sessionId")); - if (!session) { - res.writeHead(404); - res.end(); - return; - } - if ( - req.headers.authorization !== session.bearer || - req.headers["x-memwal-account-id"] !== session.accountId - ) { - res.writeHead(401); - res.end(); - return; - } - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - res.writeHead(202); - res.end(); - const msg = JSON.parse(body); - posts.push({ - sessionId: url.searchParams.get("sessionId"), - bearer: req.headers.authorization, - accountId: req.headers["x-memwal-account-id"], - method: msg.method, - id: msg.id ?? null, - name: msg.params?.name ?? null, - }); - const result = - msg.method === "initialize" - ? { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - } - : { - content: [ - { - type: "text", - text: `RECALL_OK account=${session.accountId} bearer=${session.bearer}`, - }, - ], - isError: false, - }; - session.res.write( - `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })}\n\n`, - ); - }); - return; - } - res.writeHead(404); - res.end(); - }); - - return new Promise((resolveStart) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - resolveStart({ - server, - base: `http://127.0.0.1:${port}`, - handshakes, - posts, - delayNextHandshake() { - delayNextHandshake = true; - }, - closeSession(sessionId) { - sessions.get(sessionId)?.res.end(); - }, - releaseDelayedHandshake() { - assert.ok(delayedHandshake, "expected a delayed SSE handshake"); - establishSession(delayedHandshake.sessionId, delayedHandshake.res); - delayedHandshake = null; - }, - }); - }); - }); -} - -function collectMessages(child) { +function attachStdio(child) { const received = []; const listeners = new Set(); - let stdout = ""; - let stderr = ""; - child.stderr.on("data", (data) => (stderr += data.toString())); - child.stdout.on("data", (data) => { - stdout += data.toString(); - let newline; - while ((newline = stdout.indexOf("\n")) >= 0) { - const line = stdout.slice(0, newline); - stdout = stdout.slice(newline + 1); + let buf = ""; + child.stdout.on("data", (d) => { + buf += d.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); if (!line.trim()) continue; + let msg; try { - const message = JSON.parse(line); - received.push(message); - for (const listener of [...listeners]) listener(message); + msg = JSON.parse(line); } catch { - // Ignore non-JSON diagnostics. + continue; } + received.push(msg); + for (const l of [...listeners]) l(msg); } }); - - return { - received, - send: (message) => child.stdin.write(`${JSON.stringify(message)}\n`), - waitFor(predicate, timeoutMs = 10_000) { - const existing = received.find(predicate); - if (existing) return Promise.resolve(existing); - return new Promise((resolveWait, reject) => { - const listener = (message) => { - if (!predicate(message)) return; + const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); + const waitFor = (pred, ms = 15000) => { + const hit = received.find(pred); + if (hit) return Promise.resolve(hit); + return new Promise((res, rej) => { + const timer = setTimeout(() => { + listeners.delete(l); + rej(new Error("timed out waiting for message")); + }, ms); + const l = (m) => { + if (pred(m)) { clearTimeout(timer); - listeners.delete(listener); - resolveWait(message); - }; - const timer = setTimeout(() => { - listeners.delete(listener); - reject( - new Error( - `timed out waiting for bridge message\n--- stderr ---\n${stderr}\n--- received ---\n${received.map((message) => JSON.stringify(message)).join("\n")}`, - ), - ); - }, timeoutMs); - listeners.add(listener); - }); - }, + listeners.delete(l); + res(m); + } + }; + listeners.add(l); + }); }; + return { send, waitFor }; } async function completeLogin(connectUrl, accountId) { @@ -213,8 +83,7 @@ async function completeLogin(connectUrl, accountId) { const state = url.searchParams.get("connectState"); const publicKey = url.searchParams.get("publicKey"); const relayer = url.searchParams.get("relayer"); - const origin = url.origin; - const headers = { origin, "content-type": "application/json" }; + const headers = { origin: url.origin, "content-type": "application/json" }; const preflight = await fetch(`${callbackBase}/preflight`, { method: "POST", @@ -231,244 +100,46 @@ async function completeLogin(connectUrl, accountId) { assert.equal(callback.status, 200); } -async function waitUntil(predicate, timeoutMs = 10_000) { - const started = Date.now(); - while (!predicate()) { - if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for condition"); - await new Promise((resolveWait) => setTimeout(resolveWait, 25)); - } -} - -test("in-session memwal_login reconnects the bridge with the new credentials", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-live-login-test-")); - const credentialsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credentialsPath), { recursive: true }); - writeFileSync(credentialsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - const bridge = collectMessages(child); - - t.after(() => { - child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await bridge.waitFor((message) => message.id === 1 && message.result); - // `initialize` is answered locally and the relayer connect runs in the - // background, so the SSE handshake lands shortly AFTER the init reply rather - // than synchronously with it — wait for it before asserting. - await waitUntil(() => mock.handshakes.length >= 1); - assert.equal(mock.handshakes[0].bearer, `Bearer ${INITIAL_BEARER}`); - assert.equal(mock.handshakes[0].accountId, ACCOUNT_A); - - // Force a reconnect whose SSE endpoint event is delayed. Login completes - // while this old-credential handshake is in progress, reproducing the race - // where a shared reconnect promise previously published a stale session. - mock.delayNextHandshake(); - mock.closeSession(mock.handshakes[0].sessionId); - await waitUntil(() => mock.handshakes.length >= 2); - assert.equal(mock.handshakes[1].bearer, `Bearer ${INITIAL_BEARER}`); - assert.equal(mock.handshakes[1].accountId, ACCOUNT_A); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, - }); - const login = await bridge.waitFor((message) => message.id === 2); - const text = login.result.content[0].text; - const connectUrl = text.match(/\*\*URL:\*\* (http[^\n]+)/)?.[1]; - assert.ok(connectUrl, "memwal_login should return the browser URL"); - - await completeLogin(connectUrl, ACCOUNT_B); - mock.releaseDelayedHandshake(); - await waitUntil(() => mock.handshakes.some((entry) => entry.accountId === ACCOUNT_B)); - - const updatedHandshake = mock.handshakes.find((entry) => entry.accountId === ACCOUNT_B); - assert.notEqual(updatedHandshake.bearer, `Bearer ${INITIAL_BEARER}`); - const saved = JSON.parse(readFileSync(credentialsPath, "utf8")); - assert.equal(updatedHandshake.bearer, `Bearer ${saved.delegatePrivateKey}`); - - bridge.send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "new account" } }, - }); - const recall = await bridge.waitFor((message) => message.id === 3); - assert.notEqual(recall.result?.isError, true); - assert.match(recall.result.content[0].text, new RegExp(`account=${ACCOUNT_B}`)); - assert.match(recall.result.content[0].text, new RegExp(`bearer=${updatedHandshake.bearer}`)); -}); - -test("a request buffered during cold start is NOT flushed to a new account after an in-session login", async (t) => { - // Merge-composition regression (#415 × #597): a request buffered while the - // relayer is still cold-starting (sse not yet up) must not survive an - // account-change login and be flushed to the NEW account's session. It must - // be failed with the -32001 account-change error, and the new account must - // never receive it. - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-coldstart-login-test-")); - const credentialsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credentialsPath), { recursive: true }); - writeFileSync(credentialsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - // Delay the FIRST handshake so the initial connect never completes before - // the login — the bridge stays in cold start, buffering into pendingForward. - mock.delayNextHandshake(); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, +test("in-session memwal_login replaces credentials.json with the new account", async (t) => { + const credsDir = mkdtempSync(join(tmpdir(), "memwal-live-login-")); + const credsPath = join(credsDir, "credentials.json"); + writeFileSync(credsPath, JSON.stringify(makeCreds(WEB)), { mode: 0o600 }); + + const child = spawn(process.execPath, [BIN, "--relayer", WEB, "--web-url", WEB], { + env: { + ...process.env, + MEMWAL_CREDS_DIR: credsDir, + HOME: credsDir, + USERPROFILE: credsDir, + MEMWAL_MCP_LOGIN_TIMEOUT_MS: "15000", + }, stdio: ["pipe", "pipe", "pipe"], }); - const bridge = collectMessages(child); - t.after(() => { - child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - // initialize is answered locally; the connect is delayed (handshake held). - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await bridge.waitFor((message) => message.id === 1 && message.result); - await waitUntil(() => mock.handshakes.length >= 1); // the (delayed) GET arrived - assert.equal(mock.handshakes[0].accountId, ACCOUNT_A); - - // A recall for account A — buffered into pendingForward (connect not up). - bridge.send({ - jsonrpc: "2.0", - id: 5, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "account A secret" } }, - }); - - // Log into a DIFFERENT account (B) while id=5 sits buffered. - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, - }); - const login = await bridge.waitFor((message) => message.id === 2); - const connectUrl = login.result.content[0].text.match(/\*\*URL:\*\* (http[^\n]+)/)?.[1]; - assert.ok(connectUrl, "memwal_login should return the browser URL"); - await completeLogin(connectUrl, ACCOUNT_B); - - // Now release the held handshake so the connect can proceed / reconnect. - mock.releaseDelayedHandshake(); - - // The buffered account-A recall must come back as the -32001 account-change - // error — NOT an account-B recall result. - const recallReply = await bridge.waitFor((message) => message.id === 5); - assert.equal(recallReply.error?.code, -32001, `id=5 must be the account-change error, got ${JSON.stringify(recallReply)}`); - assert.equal(recallReply.result, undefined, "id=5 must not carry a recall result"); - - // Give the bridge time to (wrongly) flush id=5 if the bug were present. - await new Promise((r) => setTimeout(r, 500)); + const { send, waitFor } = attachStdio(child); - // No account-B session may have received the account-A recall (id=5), and - // id=5 must have been answered exactly once (no double response). - const id1Replies = bridge.received.filter((m) => m.id === 1); - const id5Replies = bridge.received.filter((m) => m.id === 5); - assert.equal(id1Replies.length, 1, `initialize answered exactly once, saw ${id1Replies.length}`); - assert.equal(id5Replies.length, 1, `id=5 answered exactly once, saw ${id5Replies.length}: ${JSON.stringify(id5Replies)}`); - const bAccountRecall = bridge.received.find( - (m) => m.id === 5 && m.result && /account A secret/.test(JSON.stringify(m)), - ); - assert.ok(!bAccountRecall, "the account-A recall must never be served by account B"); -}); - -test("same-account login during cold start delivers each buffered call once on the new bearer", async (t) => { - // Same-account memwal_login must not let connectInBackground flush - // pendingForward after reconnect() already replayed inFlight. - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-same-account-coldstart-")); - const credentialsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credentialsPath), { recursive: true }); - writeFileSync(credentialsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - mock.delayNextHandshake(); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - const bridge = collectMessages(child); t.after(() => { child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await bridge.waitFor((message) => message.id === 1 && message.result); - await waitUntil(() => mock.handshakes.length >= 1); - - bridge.send({ - jsonrpc: "2.0", - id: 5, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "same account" } }, - }); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, + rmSync(credsDir, { recursive: true, force: true }); }); - const login = await bridge.waitFor((message) => message.id === 2); - const connectUrl = login.result.content[0].text.match(/\*\*URL:\*\* (http[^\n]+)/)?.[1]; - assert.ok(connectUrl, "memwal_login should return the browser URL"); - await completeLogin(connectUrl, ACCOUNT_A); - mock.releaseDelayedHandshake(); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }); + await waitFor((m) => m.id === 1 && m.result); - const recall = await bridge.waitFor((message) => message.id === 5); - assert.notEqual(recall.result?.isError, true); - const saved = JSON.parse(readFileSync(credentialsPath, "utf8")); - assert.match(recall.result.content[0].text, new RegExp(`account=${ACCOUNT_A}`)); - assert.match( - recall.result.content[0].text, - new RegExp(`bearer=Bearer ${saved.delegatePrivateKey}`), - ); + send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_login" } }); + const login = await waitFor((m) => m.id === 2 && m.result); + assert.equal(login.result.isError, false); + const match = login.result.content[0].text.match(/http:\/\/127\.0\.0\.1:\d+\/connect\/mcp\?\S+/); + assert.ok(match, "login should return a connect URL"); - await new Promise((r) => setTimeout(r, 400)); - const id1 = bridge.received.filter((m) => m.id === 1); - const id5 = bridge.received.filter((m) => m.id === 5); - assert.equal(id1.length, 1, `initialize answered once, saw ${id1.length}`); - assert.equal(id5.length, 1, `recall answered once, saw ${id5.length}`); + await completeLogin(match[0].replace(/[)`\s]+$/, ""), ACCOUNT_B); - const recallPosts = mock.posts.filter((p) => p.name === "memwal_recall" && p.id === 5); - assert.equal( - recallPosts.length, - 1, - `recall POSTed once, saw ${recallPosts.length}: ${JSON.stringify(recallPosts)}`, + await waitFor( + (m) => + m.method === "notifications/message" && + String(m.params?.data).includes("sign-in complete"), ); - assert.equal(recallPosts[0].bearer, `Bearer ${saved.delegatePrivateKey}`); - assert.equal(recallPosts[0].accountId, ACCOUNT_A); - - const initPosts = mock.posts.filter((p) => p.method === "initialize"); - assert.equal(initPosts.length, 1, `initialize POSTed once, saw ${JSON.stringify(initPosts)}`); - assert.equal(initPosts[0].bearer, `Bearer ${saved.delegatePrivateKey}`); - bridge.send({ - jsonrpc: "2.0", - id: 6, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "after login" } }, - }); - const followUp = await bridge.waitFor((message) => message.id === 6); - assert.notEqual(followUp.result?.isError, true); - assert.match( - followUp.result.content[0].text, - new RegExp(`bearer=Bearer ${saved.delegatePrivateKey}`), - ); + const saved = JSON.parse(readFileSync(credsPath, "utf8")); + assert.equal(saved.accountId, ACCOUNT_B); + assert.notEqual(saved.delegatePrivateKey, INITIAL_BEARER); }); diff --git a/packages/mcp/test/login-failure-notice.test.mjs b/packages/mcp/test/login-failure-notice.test.mjs index 05c6cc8b9..382029621 100644 --- a/packages/mcp/test/login-failure-notice.test.mjs +++ b/packages/mcp/test/login-failure-notice.test.mjs @@ -1,90 +1,17 @@ /** * A background `memwal_login` that never completes must not stay silent. - * - * The tool call returns the sign-in URL immediately, so by the time the flow - * fails there is no pending response left to turn into an error. The next - * memory tool call is the first chance to say so, and this asserts it takes it. */ import { test } from "node:test"; import assert from "node:assert/strict"; -import http from "node:http"; import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); - -/** Answers the /version probe only — the sign-in is meant to time out. */ -function startMockRelayer() { - const server = http.createServer((req, res) => { - if (new URL(req.url, "http://127.0.0.1").pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ server, base: `http://127.0.0.1:${server.address().port}` }); - }); - }); -} - -/** Version probe plus SSE so a signed-in process can boot the bridge. */ -function startBridgeRelayer() { - let sseRes = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - res.writeHead(202); - res.end(); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ - server, - base: `http://127.0.0.1:${server.address().port}`, - closeSse: () => sseRes?.end(), - }); - }); - }); -} +const WEB = "http://127.0.0.1:9"; function makeCreds(relayerUrl) { return { @@ -145,15 +72,14 @@ function attachStdio(child) { } test("a sign-in that never completes is reported on the next tool call", async (t) => { - const { server, base } = await startMockRelayer(); const home = mkdtempSync(join(tmpdir(), "memwal-test-")); - const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { + const child = spawn(process.execPath, [BIN, "--relayer", WEB, "--web-url", WEB], { env: { ...process.env, HOME: home, USERPROFILE: home, - // Nobody opens the URL, so the listener closes almost at once. + MEMWAL_CREDS_DIR: home, MEMWAL_MCP_LOGIN_TIMEOUT_MS: "600", }, stdio: ["pipe", "pipe", "pipe"], @@ -162,14 +88,12 @@ test("a sign-in that never completes is reported on the next tool call", async ( t.after(() => { child.kill("SIGKILL"); - server.close(); rmSync(home, { recursive: true, force: true }); }); send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }); await waitFor((m) => m.id === 1 && m.result); - // Baseline: before any sign-in attempt the error carries no failure notice. send({ jsonrpc: "2.0", id: 2, @@ -186,7 +110,6 @@ test("a sign-in that never completes is reported on the next tool call", async ( send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "memwal_login" } }); await waitFor((m) => m.id === 3 && m.result); - // The flow now times out in the background with nobody listening for it. const warned = await waitFor( (m) => m.method === "notifications/message" && @@ -209,22 +132,19 @@ test("a sign-in that never completes is reported on the next tool call", async ( assert.match(text, /left running through the/); assert.doesNotMatch(text, /usually works/); assert.match(text, /already be registered on your account/); - // Still tells them how to sign in, rather than replacing the instruction. assert.match(text, /memwal_login/); }); -test("a signed-in memwal_login timeout warns through the bridge", async (t) => { - const { server, base, closeSse } = await startBridgeRelayer(); +test("a signed-in memwal_login timeout still warns", async (t) => { const home = mkdtempSync(join(tmpdir(), "memwal-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(base)), { mode: 0o600 }); + writeFileSync(join(home, "credentials.json"), JSON.stringify(makeCreds(WEB)), { mode: 0o600 }); - const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { + const child = spawn(process.execPath, [BIN, "--relayer", WEB, "--web-url", WEB], { env: { ...process.env, HOME: home, USERPROFILE: home, + MEMWAL_CREDS_DIR: home, MEMWAL_MCP_LOGIN_TIMEOUT_MS: "600", }, stdio: ["pipe", "pipe", "pipe"], @@ -233,8 +153,6 @@ test("a signed-in memwal_login timeout warns through the bridge", async (t) => { t.after(() => { child.kill("SIGKILL"); - closeSse(); - server.close(); rmSync(home, { recursive: true, force: true }); }); diff --git a/packages/mcp/test/login-handoff-stdin.test.mjs b/packages/mcp/test/login-handoff-stdin.test.mjs deleted file mode 100644 index 9a1367e70..000000000 --- a/packages/mcp/test/login-handoff-stdin.test.mjs +++ /dev/null @@ -1,244 +0,0 @@ -/** - * After an in-session `memwal_login`, the bridge must keep serving stdin. - * - * The auth-required stub hands off by detaching its own listeners and calling - * `process.stdin.pause()`. An explicitly paused stream does NOT resume just - * because a new `data` listener is attached, so the bridge's reader has to ask - * for it. Without that, the ONLY request served after signing in was the one - * replayed from `pendingLines` — every later call was read by nobody and hung - * until the client timed it out. - * - * That is WALM-394's "the next call timed out": the sign-in genuinely worked, - * and the connection was deaf from the second call onward. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); - -/** - * Version probe + SSE + a relayer that actually ANSWERS forwarded calls. - * - * The failure-path fixtures never need a reply (nothing gets that far), but - * the banner rides on a real `tools/call` result, so this one has to complete - * the round-trip: read the POSTed request, push a matching JSON-RPC result - * back down the SSE stream. - */ -function startAnsweringRelayer() { - let sseRes = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (d) => { - body += d; - }); - req.on("end", () => { - res.writeHead(202); - res.end(); - - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.id === undefined || msg.id === null) return; - - // Distinguishable payload so the assertion proves the banner - // was prefixed onto a REAL upstream result, not substituted - // for one. - const result = - msg.method === "initialize" - ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "mock", version: "1.0.0" } } - : { content: [{ type: "text", text: "UPSTREAM_RECALL_RESULT" }], isError: false }; - - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })}\n\n`, - ); - }); - return; - } - - res.writeHead(404); - res.end(); - }); - - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ - server, - base: `http://127.0.0.1:${server.address().port}`, - closeSse: () => sseRes?.end(), - }); - }); - }); -} - -function attachStdio(child) { - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - const seen = received - .map((m) => (m.id !== undefined ? `id=${m.id}` : m.method)) - .join(", "); - rej(new Error(`timed out waiting for message; received: [${seen}]`)); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - return { send, waitFor }; -} - -/** - * Drive the browser half of the sign-in: the same preflight-then-callback - * handshake a real wallet approval performs (pinned by login-preflight), so - * no browser or on-chain transaction is involved. - */ -async function completeSignIn(loginText, webUrl) { - const match = loginText.match(/http:\/\/127\.0\.0\.1:\d+\/connect\/mcp\?\S+/); - assert.ok(match, `login result should carry a connect URL, got: ${loginText.slice(0, 300)}`); - const connectUrl = new URL(match[0].replace(/[)`\s]+$/, "")); - - const port = connectUrl.searchParams.get("port"); - const publicKey = connectUrl.searchParams.get("publicKey"); - const state = connectUrl.searchParams.get("connectState"); - assert.match(port ?? "", /^\d+$/); - - const post = (path, body) => - fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "application/json", origin: webUrl }, - body: JSON.stringify(body), - }); - - const preflight = await post("/preflight", { state, publicKey, relayer: webUrl }); - assert.equal(preflight.status, 200, "preflight should be accepted"); - - const callback = await post("/callback", { - state, - accountId: `0x${"1".repeat(64)}`, - walletAddress: `0x${"2".repeat(64)}`, - packageId: `0x${"3".repeat(64)}`, - label: "Test MCP", - }); - assert.equal(callback.status, 200, "callback should be accepted"); -} - -function spawnSignedOut(base, credsDir) { - return spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { - env: { - ...process.env, - // MEMWAL_CREDS_DIR rather than HOME alone: os.homedir() ignores - // HOME on Windows, which once let this suite overwrite a real - // credentials.json (see CHANGELOG #705). - MEMWAL_CREDS_DIR: credsDir, - HOME: credsDir, - USERPROFILE: credsDir, - MEMWAL_MCP_LOGIN_TIMEOUT_MS: "15000", - }, - stdio: ["pipe", "pipe", "pipe"], - }); -} - -test("the bridge keeps reading stdin after an in-session sign-in", async (t) => { - const { server, base, closeSse } = await startAnsweringRelayer(); - const credsDir = mkdtempSync(join(tmpdir(), "memwal-handoff-stdin-")); - const child = spawnSignedOut(base, credsDir); - const { send, waitFor } = attachStdio(child); - - t.after(() => { - child.kill("SIGKILL"); - closeSse(); - server.close(); - rmSync(credsDir, { recursive: true, force: true }); - }); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }); - await waitFor((m) => m.id === 1 && m.result); - - send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_login" } }); - const login = await waitFor((m) => m.id === 2 && m.result); - await completeSignIn(login.result.content[0].text, base); - - // Served from `pendingLines` — this one worked even with stdin paused. - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "replayed" } }, - }); - await waitFor((m) => m.id === 3 && m.result); - - // Read from the live stream. This is the one that used to hang forever. - send({ - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "live" } }, - }); - const live = await waitFor((m) => m.id === 4 && m.result); - assert.match(live.result.content[0].text, /UPSTREAM_RECALL_RESULT/); -}); diff --git a/packages/mcp/test/login-handoff.test.mjs b/packages/mcp/test/login-handoff.test.mjs index d5a3a200f..91059237c 100644 --- a/packages/mcp/test/login-handoff.test.mjs +++ b/packages/mcp/test/login-handoff.test.mjs @@ -1,24 +1,11 @@ /** - * Integration test for auth-required → bridge hot-handoff (no second restart). - * - * Scenario (mirrors the "double reboot" bug): - * 1. Spawn memwal-mcp with an EMPTY ~/.memwal (HOME pointed at a temp dir) so - * it boots in auth-required mode. - * 2. `initialize` is answered locally; `memwal_recall` returns the - * not-signed-in instruction. - * 3. Write a valid credentials.json mid-process (what `memwal_login`'s - * browser callback does). - * 4. Call `memwal_recall` again — WITHOUT restarting the process — and assert - * it is served for real (forwarded to the relayer, real result back). - * - * A tiny mock relayer stands in for relayer.memory.walrus.xyz: it answers the - * `/version` compatibility probe and speaks the SSE transport the bridge needs. + * Signed-out discovery + mid-session credential pickup without a client restart. + * Memory-tool success after pickup is covered by sdk-stdio.test.mjs (SDK stub). */ import { test } from "node:test"; import assert from "node:assert/strict"; -import http from "node:http"; import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,92 +13,11 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -/** Minimal relayer: /version probe + SSE transport that echoes a recall reply. */ -function startMockRelayer() { - let sseRes = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - // Tell the bridge where to POST outbound messages. - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "tools/call" && msg.params?.name === "memwal_recall") { - const reply = { - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: "RECALL_OK: montreal trip" }], - isError: false, - }, - }; - sseRes?.write(`event: message\ndata: ${JSON.stringify(reply)}\n\n`); - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ server, base: `http://127.0.0.1:${port}` }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: "a".repeat(64), - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: "0x" + "3".repeat(64), - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Integration Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -test("auth-required mode picks up credentials mid-session without a restart", async (t) => { - const { server, base } = await startMockRelayer(); +test("auth-required tools/list exposes safety metadata and recall is denied", async (t) => { const home = mkdtempSync(join(tmpdir(), "memwal-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, + const child = spawn(process.execPath, [BIN, "--relayer", "http://127.0.0.1:9", "--web-url", "http://127.0.0.1:9"], { + env: { ...process.env, HOME: home, USERPROFILE: home, MEMWAL_CREDS_DIR: home }, stdio: ["pipe", "pipe", "pipe"], }); @@ -158,17 +64,13 @@ test("auth-required mode picks up credentials mid-session without a restart", as t.after(() => { child.kill("SIGKILL"); - server.close(); rmSync(home, { recursive: true, force: true }); }); - // 1. initialize — answered locally by the auth-required server. send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); const init = await waitFor((m) => m.id === 1 && m.result); assert.equal(init.result.serverInfo.name, "memwal"); - // Pre-login discovery must expose the same safety metadata clients will - // receive after the bridge hands off to the remote relayer. send({ jsonrpc: "2.0", id: 10, method: "tools/list", params: {} }); const listed = await waitFor((m) => m.id === 10 && m.result); const metadata = Object.fromEntries( @@ -205,7 +107,6 @@ test("auth-required mode picks up credentials mid-session without a restart", as }, }); - // 2. recall before login → not-signed-in instruction. send({ jsonrpc: "2.0", id: 2, @@ -219,23 +120,4 @@ test("auth-required mode picks up credentials mid-session without a restart", as /isn't signed in|not signed in/i, "should nudge the user to log in", ); - - // 3. Login completes: write credentials into the same process's HOME. - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(base)), { mode: 0o600 }); - - // 4. recall again, same process, no restart → served for real via the relayer. - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "montreal" } }, - }); - const after = await waitFor((m) => m.id === 3); - assert.notEqual(after.result.isError, true, "recall should succeed after login"); - assert.match( - JSON.stringify(after.result), - /RECALL_OK/, - "recall result should come from the relayer, not the login stub", - ); }); diff --git a/packages/mcp/test/login-success-notice.test.mjs b/packages/mcp/test/login-success-notice.test.mjs index 526d2ec8b..cc86779b6 100644 --- a/packages/mcp/test/login-success-notice.test.mjs +++ b/packages/mcp/test/login-success-notice.test.mjs @@ -1,110 +1,19 @@ /** * A sign-in that DOES complete must say so. * - * The failure path already reports itself twice — a `notifications/message` - * and a notice prefixed onto the next tool call (see login-failure-notice). - * Success reported nothing at all: it only wrote to the log file, so the user - * who approved in the browser had no way to tell the credentials landed, the - * bridge adopted them, or the retry would work. This pins the confirmation. - * - * The banner is a ONE-SHOT. Success is an event, not a state, so unlike the - * failure notice it is consumed by the call that shows it and never repeats. + * The banner is a ONE-SHOT. Success is an event, not a state. */ import { test } from "node:test"; import assert from "node:assert/strict"; -import http from "node:http"; import { spawn } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -/** - * Version probe + SSE + a relayer that actually ANSWERS forwarded calls. - * - * The failure-path fixtures never need a reply (nothing gets that far), but - * the banner rides on a real `tools/call` result, so this one has to complete - * the round-trip: read the POSTed request, push a matching JSON-RPC result - * back down the SSE stream. - */ -function startAnsweringRelayer() { - let sseRes = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); - sseRes = res; - return; - } - - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - let body = ""; - req.on("data", (d) => { - body += d; - }); - req.on("end", () => { - res.writeHead(202); - res.end(); - - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.id === undefined || msg.id === null) return; - - // Distinguishable payload so the assertion proves the banner - // was prefixed onto a REAL upstream result, not substituted - // for one. - const result = - msg.method === "initialize" - ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "mock", version: "1.0.0" } } - : { content: [{ type: "text", text: "UPSTREAM_RECALL_RESULT" }], isError: false }; - - sseRes?.write( - `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })}\n\n`, - ); - }); - return; - } - - res.writeHead(404); - res.end(); - }); - - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ - server, - base: `http://127.0.0.1:${server.address().port}`, - closeSse: () => sseRes?.end(), - }); - }); - }); -} - function attachStdio(child) { const received = []; const listeners = new Set(); @@ -151,11 +60,6 @@ function attachStdio(child) { return { send, waitFor }; } -/** - * Drive the browser half of the sign-in: the same preflight-then-callback - * handshake a real wallet approval performs (pinned by login-preflight), so - * no browser or on-chain transaction is involved. - */ async function completeSignIn(loginText, webUrl) { const match = loginText.match(/http:\/\/127\.0\.0\.1:\d+\/connect\/mcp\?\S+/); assert.ok(match, `login result should carry a connect URL, got: ${loginText.slice(0, 300)}`); @@ -186,13 +90,10 @@ async function completeSignIn(loginText, webUrl) { assert.equal(callback.status, 200, "callback should be accepted"); } -function spawnSignedOut(base, credsDir) { - return spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { +function spawnSignedOut(webUrl, credsDir) { + return spawn(process.execPath, [BIN, "--relayer", webUrl, "--web-url", webUrl], { env: { ...process.env, - // MEMWAL_CREDS_DIR rather than HOME alone: os.homedir() ignores - // HOME on Windows, which once let this suite overwrite a real - // credentials.json (see CHANGELOG #705). MEMWAL_CREDS_DIR: credsDir, HOME: credsDir, USERPROFILE: credsDir, @@ -202,12 +103,7 @@ function spawnSignedOut(base, credsDir) { }); } -/** Credentials on disk, so the real bridge runs instead of the auth-required - * stub. Same account the callback below reports, keeping this a plain key - * rotation rather than an account switch. */ function seedCreds(credsDir, relayerUrl) { - // Flat, not `.memwal/` — `spawnSignedOut` sets MEMWAL_CREDS_DIR, which the - // CLI uses as the credentials directory itself. const path = join(credsDir, "credentials.json"); mkdirSync(credsDir, { recursive: true }); writeFileSync( @@ -228,23 +124,16 @@ function seedCreds(credsDir, relayerUrl) { ); } -/** - * The re-login path: already signed in, so `memwal_login` is answered by the - * bridge's `handleLocalLogin` and the callback lands in `adoptCredentials` — - * a different pair of surfaces from the signed-out hand-off the tests above - * drive. A regression that dropped either one would pass every one of them. - */ +const WEB = "http://127.0.0.1:9"; + test("re-signing in while already signed in is confirmed on both surfaces", async (t) => { - const { server, base, closeSse } = await startAnsweringRelayer(); const credsDir = mkdtempSync(join(tmpdir(), "memwal-success-relogin-")); - seedCreds(credsDir, base); - const child = spawnSignedOut(base, credsDir); + seedCreds(credsDir, WEB); + const child = spawnSignedOut(WEB, credsDir); const { send, waitFor } = attachStdio(child); t.after(() => { child.kill("SIGKILL"); - closeSse(); - server.close(); rmSync(credsDir, { recursive: true, force: true }); }); @@ -254,16 +143,13 @@ test("re-signing in while already signed in is confirmed on both surfaces", asyn send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_login" } }); const login = await waitFor((m) => m.id === 2 && m.result); assert.equal(login.result.isError, false); - - // Credentials really are on disk here, so this is the one case where the - // replacement warning is true and must appear. assert.match( login.result.content[0].text, /already signed in/i, "a stored key IS about to be replaced; the prompt has to say so", ); - await completeSignIn(login.result.content[0].text, base); + await completeSignIn(login.result.content[0].text, WEB); const announced = await waitFor( (m) => @@ -276,18 +162,17 @@ test("re-signing in while already signed in is confirmed on both surfaces", asyn jsonrpc: "2.0", id: 3, method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "after re-login" } }, + params: { name: "memwal_health", arguments: {} }, }); const after = await waitFor((m) => m.id === 3 && m.result); const text = after.result.content[0].text; assert.match(text, /Signed in to Walrus Memory/, "the re-login should carry the banner too"); - assert.match(text, /UPSTREAM_RECALL_RESULT/, "prefixed onto the real result, not instead of it"); send({ jsonrpc: "2.0", id: 4, method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "one banner only" } }, + params: { name: "memwal_health", arguments: {} }, }); const second = await waitFor((m) => m.id === 4 && m.result); assert.doesNotMatch( @@ -298,15 +183,12 @@ test("re-signing in while already signed in is confirmed on both surfaces", asyn }); test("a completed sign-in is confirmed on the next tool call", async (t) => { - const { server, base, closeSse } = await startAnsweringRelayer(); const credsDir = mkdtempSync(join(tmpdir(), "memwal-success-")); - const child = spawnSignedOut(base, credsDir); + const child = spawnSignedOut(WEB, credsDir); const { send, waitFor } = attachStdio(child); t.after(() => { child.kill("SIGKILL"); - closeSse(); - server.close(); rmSync(credsDir, { recursive: true, force: true }); }); @@ -317,10 +199,8 @@ test("a completed sign-in is confirmed on the next tool call", async (t) => { const login = await waitFor((m) => m.id === 2 && m.result); assert.equal(login.result.isError, false); - await completeSignIn(login.result.content[0].text, base); + await completeSignIn(login.result.content[0].text, WEB); - // The callback landed with nobody awaiting it, exactly as a real browser - // approval does. This is the moment that used to be silent. const announced = await waitFor( (m) => m.method === "notifications/message" && @@ -332,7 +212,7 @@ test("a completed sign-in is confirmed on the next tool call", async (t) => { jsonrpc: "2.0", id: 3, method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, + params: { name: "memwal_health", arguments: {} }, }); const after = await waitFor((m) => m.id === 3 && m.result); const text = after.result.content[0].text; @@ -341,21 +221,15 @@ test("a completed sign-in is confirmed on the next tool call", async (t) => { assert.match(text, /0x1{4}/, "banner should name the account"); assert.match(text, /credentials\.json/, "banner should name where credentials landed"); assert.match(text, /no client restart needed/i); - // Prefixed onto the real result, never in place of it. - assert.match(text, /UPSTREAM_RECALL_RESULT/); - assert.equal(after.result.isError, false); }); test("the sign-in confirmation is not repeated on later calls", async (t) => { - const { server, base, closeSse } = await startAnsweringRelayer(); const credsDir = mkdtempSync(join(tmpdir(), "memwal-success-once-")); - const child = spawnSignedOut(base, credsDir); + const child = spawnSignedOut(WEB, credsDir); const { send, waitFor } = attachStdio(child); t.after(() => { child.kill("SIGKILL"); - closeSse(); - server.close(); rmSync(credsDir, { recursive: true, force: true }); }); @@ -364,7 +238,7 @@ test("the sign-in confirmation is not repeated on later calls", async (t) => { send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_login" } }); const login = await waitFor((m) => m.id === 2 && m.result); - await completeSignIn(login.result.content[0].text, base); + await completeSignIn(login.result.content[0].text, WEB); await waitFor( (m) => m.method === "notifications/message" && @@ -375,7 +249,7 @@ test("the sign-in confirmation is not repeated on later calls", async (t) => { jsonrpc: "2.0", id: 3, method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "first" } }, + params: { name: "memwal_health", arguments: {} }, }); const first = await waitFor((m) => m.id === 3 && m.result); assert.match( @@ -388,15 +262,12 @@ test("the sign-in confirmation is not repeated on later calls", async (t) => { jsonrpc: "2.0", id: 4, method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "second" } }, + params: { name: "memwal_health", arguments: {} }, }); const second = await waitFor((m) => m.id === 4 && m.result); - const text = second.result.content[0].text; - assert.doesNotMatch( - text, + second.result.content[0].text, /Signed in to Walrus Memory/, "the banner is consumed by the call that shows it — a signed-in session must not repeat it", ); - assert.match(text, /UPSTREAM_RECALL_RESULT/, "the real result still comes through"); }); diff --git a/packages/mcp/test/logout-invalidation.test.mjs b/packages/mcp/test/logout-invalidation.test.mjs deleted file mode 100644 index e8b7020f5..000000000 --- a/packages/mcp/test/logout-invalidation.test.mjs +++ /dev/null @@ -1,677 +0,0 @@ -/** - * Regression test for `memwal_logout` session invalidation (bridge.ts). - * - * Bug being guarded against (GH #616): `memwal_logout` only deleted the local - * credentials *file*. The running bridge kept the in-memory `creds` object and - * the open SSE session, so every later `memwal_recall` / `memwal_remember` was - * still forwarded to the relayer and executed with the user's delegate key. - * `memwal_logout` is the only revocation path in bridge mode, so a user (or a - * prompt-injected agent) calling it believed access was cut off while writes - * kept landing under the real key. - * - * Repro: - * - Mock relayer is healthy throughout and counts how many memory-tool calls - * actually reach it. - * - Bridge does `memwal_recall` (reaches the relayer), then `memwal_logout`, - * then `memwal_recall` again. - * - Before the fix the second recall was served normally and the relayer's - * counter reached 2. After the fix it is rejected locally and the counter - * stays at 1. - * - * The relayer-side counter is the assertion that matters: an `isError` reply - * alone would not prove the request never ran under the delegate key. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "5".repeat(64); - -const WALLET = "0x" + "7".repeat(64); -const PACKAGE = "0x" + "8".repeat(64); -const ACCOUNT_B = "0x" + "9".repeat(64); - -/** Accept any well-formed delegate bearer rather than pinning one: signing back - * in mints a fresh key, and these tests assert on whether requests arrive at - * all, not which key signed them. Handshakes are recorded so a test that does - * care can check. */ -function hasBridgeAuth(req) { - return ( - /^Bearer [0-9a-f]{64}$/.test(req.headers.authorization ?? "") && - typeof req.headers["x-memwal-account-id"] === "string" - ); -} - -/** Drive `memwal_login`'s browser callback to completion, exactly as the real - * web flow does. Mirrors the helper in live-login-credentials.test.mjs. */ -async function completeLogin(connectUrl, accountId) { - const url = new URL(connectUrl); - const callbackBase = `http://127.0.0.1:${url.searchParams.get("port")}`; - const state = url.searchParams.get("connectState"); - const publicKey = url.searchParams.get("publicKey"); - const relayer = url.searchParams.get("relayer"); - const headers = { origin: url.origin, "content-type": "application/json" }; - - const preflight = await fetch(`${callbackBase}/preflight`, { - method: "POST", - headers, - body: JSON.stringify({ state, publicKey, relayer }), - }); - assert.equal(preflight.status, 200); - - const callback = await fetch(`${callbackBase}/callback`, { - method: "POST", - headers, - body: JSON.stringify({ state, accountId, walletAddress: WALLET, packageId: PACKAGE }), - }); - assert.equal(callback.status, 200); -} - -async function waitUntil(predicate, timeoutMs = 10_000) { - const started = Date.now(); - while (!predicate()) { - if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for condition"); - await new Promise((r) => setTimeout(r, 25)); - } -} - -/** Healthy mock relayer. Unlike the watchdog test's mock, every session here - * behaves normally — the only thing under test is whether the bridge stops - * talking to it after logout. Counts memory-tool calls that reach the relayer - * and tracks SSE stream closes so the test can prove the session was torn - * down rather than merely ignored. */ -function startMockRelayer({ delayFirstHandshakeMs = 0 } = {}) { - const sessions = new Map(); // sessionId -> res - const handshakes = []; - let sseGetCount = 0; - let closedSessions = 0; - let recallCount = 0; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - handshakes.push({ - bearer: req.headers.authorization, - accountId: req.headers["x-memwal-account-id"], - }); - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - // Optionally stall the endpoint event on the FIRST handshake only, - // so a test can act while the bridge's initial connect is still in - // flight (openSseStream resolves on this event). - const openDelay = sseGetCount === 1 ? delayFirstHandshakeMs : 0; - const emitEndpoint = () => { - if (res.writableEnded) return; - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, res); - }; - if (openDelay > 0) { - setTimeout(emitEndpoint, openDelay).unref?.(); - } else { - emitEndpoint(); - } - const hb = setInterval(() => { - if (res.writableEnded) { - clearInterval(hb); - return; - } - res.write(":\n\n"); - }, 200); - hb.unref?.(); - res.on("close", () => { - clearInterval(hb); - closedSessions += 1; - sessions.delete(sessionId); - }); - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - const sessionId = url.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - if (!session) { - res.writeHead(404); - res.end(); - return; - } - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - const reply = (result) => - session.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result, - })}\n\n`, - ); - if (msg.method === "initialize") { - reply({ - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - }); - return; - } - if (msg.method === "tools/list") { - reply({ - tools: [ - { - name: "memwal_recall", - description: "Recall memories.", - inputSchema: { type: "object", properties: {} }, - }, - ], - }); - return; - } - if (msg.method === "tools/call" && msg.params?.name === "memwal_recall") { - recallCount += 1; - reply({ - content: [{ type: "text", text: `RECALL_OK: served ${recallCount}` }], - isError: false, - }); - return; - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getRecallCount: () => recallCount, - getSseGetCount: () => sseGetCount, - getClosedSessions: () => closedSessions, - getHandshakes: () => handshakes, - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Logout Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -/** Spawn the bridge against `mock` with a sandboxed HOME and return a small - * stdio driver. Mirrors the harness in sse-idle-watchdog.test.mjs. */ -function startBridge(t, mock, home) { - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => child.kill("SIGKILL")); - return { send, waitFor }; -} - -test("memwal_logout stops the live bridge from serving memory tools with the deleted delegate key", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-logout-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - t.after(() => { - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - const { send, waitFor } = startBridge(t, mock, home); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 10_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - // Baseline: while logged in, a recall reaches the relayer and succeeds. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "before logout" } }, - }); - const before = await waitFor((m) => m.id === 2, 10_000); - assert.notEqual(before.result?.isError, true); - assert.match(JSON.stringify(before.result), /RECALL_OK/); - assert.equal(mock.getRecallCount(), 1, "baseline recall should reach the relayer"); - - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_logout", arguments: {} }, - }); - const logout = await waitFor((m) => m.id === 3, 10_000); - assert.notEqual(logout.result?.isError, true, "logout itself should succeed"); - assert.equal(existsSync(credsPath), false, "logout should remove the credentials file"); - - // The bug: this second recall was still forwarded and served with the - // delegate key the user just deleted. - send({ - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "after logout" } }, - }); - const after = await waitFor((m) => m.id === 4, 10_000); - - assert.equal( - after.result?.isError, - true, - "a memory tool call after logout must be rejected, not served", - ); - assert.equal( - mock.getRecallCount(), - 1, - "a memory tool call after logout must never reach the relayer", - ); -}); - -test("memwal_logout tears down the open SSE session instead of leaving it connected", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-logout-sse-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - t.after(() => { - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - const { send, waitFor } = startBridge(t, mock, home); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 10_000); - - // Force the SSE stream open — initialize alone may be answered locally. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "open the stream" } }, - }); - await waitFor((m) => m.id === 2, 10_000); - assert.ok(mock.getSseGetCount() >= 1, "expected an SSE session to be open before logout"); - - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_logout", arguments: {} }, - }); - await waitFor((m) => m.id === 3, 10_000); - - // Poll briefly: the abort propagates to the server as a stream close. - const deadline = Date.now() + 5000; - while (mock.getClosedSessions() < 1 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 50)); - } - assert.ok( - mock.getClosedSessions() >= 1, - `expected the SSE session to be closed on logout, saw ${mock.getClosedSessions()} closes`, - ); -}); - -/** - * Tearing the session down must not be a one-way door. `memwal_logout` stops - * the server pump's stream; if the pump exits outright, a later `memwal_login` - * reconnects a session with nothing draining it and the client hangs forever - * on its next tool call — a worse failure than the bug being fixed. The pump - * has to park and resume instead. - */ -test("signing back in after logout restores memory tools without a client restart", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-logout-relogin-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - t.after(() => { - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - const { send, waitFor } = startBridge(t, mock, home); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 10_000); - - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "before logout" } }, - }); - await waitFor((m) => m.id === 2, 10_000); - assert.equal(mock.getRecallCount(), 1); - - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_logout", arguments: {} }, - }); - await waitFor((m) => m.id === 3, 10_000); - - send({ - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "while signed out" } }, - }); - const refused = await waitFor((m) => m.id === 4, 10_000); - assert.equal(refused.result?.isError, true, "still signed out at this point"); - - send({ - jsonrpc: "2.0", - id: 5, - method: "tools/call", - params: { name: "memwal_login", arguments: {} }, - }); - const login = await waitFor((m) => m.id === 5, 10_000); - const prompt = login.result?.content?.[0]?.text ?? ""; - const connectUrl = prompt.match(/\*\*URL:\*\* (http[^\n]+)/)?.[1]; - assert.ok(connectUrl, "memwal_login should return the browser URL"); - - // The bridge used to hardcode `signedIn: true` on the assumption that it - // only ever runs with credentials. Logout deletes them, and login is - // intercepted before the signed-out guard, so the prompt claimed the user - // was still signed in and that a stored key would be replaced — which - // reads as though the logout they just performed did not take. - assert.doesNotMatch( - prompt, - /already signed in/i, - "the credentials were just deleted; this prompt must not claim otherwise", - ); - assert.doesNotMatch( - prompt, - /replaces the stored delegate key/i, - "there is no stored key left to replace after logout", - ); - - await completeLogin(connectUrl, ACCOUNT_B); - await waitUntil(() => mock.getHandshakes().some((h) => h.accountId === ACCOUNT_B)); - - // Signing back in is a completed sign-in like any other, so it is announced - // on both surfaces. Without this the notification could be dropped from the - // re-login path and every assertion below would still pass. - const announced = await waitFor( - (m) => - m.method === "notifications/message" && - String(m.params?.data).includes("sign-in complete"), - 10_000, - ); - // Shortened for readability by `shortId`, so match the head, not the whole id. - assert.ok( - String(announced.params.data).includes(ACCOUNT_B.slice(0, 10)), - `should name the new account, got: ${announced.params.data}`, - ); - - // The real assertion: a memory tool works again, end to end, on the new - // session. Without a resumable pump this reply never reaches stdout and the - // wait below times out. - send({ - jsonrpc: "2.0", - id: 6, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "after signing back in" } }, - }); - const after = await waitFor((m) => m.id === 6, 10_000); - assert.notEqual(after.result?.isError, true, "memory tools should work again after re-login"); - assert.match(JSON.stringify(after.result), /RECALL_OK/); - assert.equal(mock.getRecallCount(), 2, "the post-login recall should reach the relayer"); - - // Prefixed onto the real result rather than replacing it. This assertion is - // what would catch `adoptCredentials` dropping its banner queue: RECALL_OK - // above passes with or without the confirmation. - const afterText = after.result?.content?.[0]?.text ?? ""; - assert.match(afterText, /Signed in to Walrus Memory/, "the re-login should be confirmed"); - assert.match(afterText, /RECALL_OK/); - - send({ - jsonrpc: "2.0", - id: 7, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "one banner only" } }, - }); - const second = await waitFor((m) => m.id === 7, 10_000); - assert.doesNotMatch( - second.result?.content?.[0]?.text ?? "", - /Signed in to Walrus Memory/, - "the banner is a one-shot — it must not repeat on later calls", - ); -}); - -/** - * Logging out while the very first handshake is still in flight must not leave - * an authenticated stream open. The connect loop only re-checks its guards at - * the top of each iteration, so a session whose endpoint event lands *after* - * logout would otherwise be published — an open, authorized SSE stream holding - * the delegate key the user just deleted. - */ -test("logging out mid-handshake does not publish the in-flight session", async (t) => { - const mock = await startMockRelayer({ delayFirstHandshakeMs: 1500 }); - const home = mkdtempSync(join(tmpdir(), "memwal-logout-midhandshake-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - t.after(() => { - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - const { send, waitFor } = startBridge(t, mock, home); - - // initialize is answered locally, so this returns while the relayer - // handshake is still stalled on its endpoint event. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 10_000); - await waitUntil(() => mock.getSseGetCount() >= 1, 5_000); - assert.equal(mock.getClosedSessions(), 0, "handshake should still be in flight"); - - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_logout", arguments: {} }, - }); - await waitFor((m) => m.id === 2, 10_000); - - // Let the stalled endpoint event fire and the bridge react to it. - await new Promise((r) => setTimeout(r, 2500)); - - assert.equal( - mock.getClosedSessions(), - 1, - "the session that completed after logout must be aborted, not published", - ); -}); - -/** - * Review follow-up (PR #699): the signed-out refusal originally sat INSIDE the - * `msg.method === "tools/call"` branch, so it only covered memory tool calls. - * Any other id-bearing request arriving after logout fell through to the - * forwarding path, where `sse` is null and `reconnect()` no-ops while signed - * out — so it landed in `pendingForward` with nothing left to drain it. - * - * The baseline recall below is load-bearing, not decoration. It forces the - * initial `connectInBackground` to succeed and RETURN. Without it that loop is - * often still in flight when logout lands, hits its own `loggedOut` break, and - * drains `pendingForward` on the way out — which masks the bug behind a - * misleading "relayer unavailable: connection not established before shutdown" - * reply. Once the connect has returned, nothing drains that queue and the - * request is never answered at all. - * - * `ping` is the cheap case to prove it with: a plain MCP request with an id, - * not intercepted locally, that a correct bridge owes an answer. - */ -test("a non-tool request after logout is answered locally instead of hanging", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-logout-nontool-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - t.after(() => { - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - const { send, waitFor } = startBridge(t, mock, home); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 10_000); - - // Drives the initial connect to completion — see the note above. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "before logout" } }, - }); - await waitFor((m) => m.id === 2, 10_000); - assert.equal(mock.getRecallCount(), 1, "baseline recall should reach the relayer"); - - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_logout", arguments: {} }, - }); - const logout = await waitFor((m) => m.id === 3, 10_000); - assert.notEqual(logout.result?.isError, true, "logout itself should succeed"); - - // Short timeout on purpose: the bug is an unanswered request, so a generous - // window would only make the failure slow instead of clear. - send({ jsonrpc: "2.0", id: 4, method: "ping" }); - const pong = await waitFor((m) => m.id === 4, 5_000); - - // Shape matters as much as the fact of a reply. `ping` is not a tool call, - // so the refusal must be a JSON-RPC error — answering it with a tool-result - // envelope would be a protocol violation the client cannot interpret. - assert.ok(pong.error, "a non-tool request must be refused with a JSON-RPC error"); - assert.equal(pong.result, undefined, "must not answer `ping` with a tool result"); - assert.match(pong.error.message, /Signed out/i); - - // `tools/list` is a separate path: it is answered locally whenever `sse` is - // null, which logout makes true — so it must still work, and must still - // advertise `memwal_login` as the way back in. - send({ jsonrpc: "2.0", id: 5, method: "tools/list", params: {} }); - const list = await waitFor((m) => m.id === 5, 5_000); - assert.ok(list.result?.tools, "tools/list must still be served while signed out"); - assert.ok( - list.result.tools.some((tool) => tool.name === "memwal_login"), - "the signed-out tool list must still offer the way back in", - ); - - assert.equal( - mock.getRecallCount(), - 1, - "nothing after logout should have reached the relayer", - ); -}); diff --git a/packages/mcp/test/memory-tools.test.mjs b/packages/mcp/test/memory-tools.test.mjs new file mode 100644 index 000000000..622a5f787 --- /dev/null +++ b/packages/mcp/test/memory-tools.test.mjs @@ -0,0 +1,171 @@ +/** + * Memory tools call the SDK, not an SSE session. + * + * A stub client stands in for MemWal: no HTTP, no /api/mcp/sse mock. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatToolError, UNAUTHORIZED_TEXT } from "../dist/format.js"; +import { runMemoryTool } from "../dist/tools.js"; + +function stub(overrides = {}) { + const calls = []; + const client = { + calls, + rememberAndWait: async (text, namespace) => { + calls.push(["rememberAndWait", text, namespace]); + return { blob_id: "blob-1", namespace: namespace ?? "default" }; + }, + rememberBulkAndWait: async (items) => { + calls.push(["rememberBulkAndWait", items]); + return { + results: items.map(() => ({ status: "done", blob_id: "blob-b" })), + succeeded: items.length, + total: items.length, + failed: 0, + }; + }, + recall: async (params) => { + calls.push(["recall", params]); + return { + results: [ + { + text: "montreal trip", + distance: 0.1, + created_at: "2026-09-01T12:00:00Z", + }, + ], + }; + }, + analyzeAndWait: async (text, namespace) => { + calls.push(["analyzeAndWait", text, namespace]); + return { + facts: [{ text: "likes coffee" }], + results: [{ status: "done", blob_id: "blob-a" }], + succeeded: 1, + failed: 0, + }; + }, + restore: async (namespace, limit) => { + calls.push(["restore", namespace, limit]); + return { + namespace, + total: 3, + restored: 2, + skipped: 1, + failed: 0, + truncated: false, + }; + }, + health: async () => { + calls.push(["health"]); + return { status: "ok", version: "1.2.3", write_ready: true }; + }, + destroy() {}, + ...overrides, + }; + return client; +} + +const RELAYER = "https://relayer.dev.memwal.ai"; + +test("memwal_remember goes through rememberAndWait and never mentions SSE", async () => { + const client = stub(); + const result = await runMemoryTool( + "memwal_remember", + { text: "I use pnpm" }, + "work", + client, + RELAYER, + ); + assert.equal(result.isError, false); + assert.match(result.text, /blob_id=blob-1/); + assert.match(result.text, /namespace=work/); + assert.doesNotMatch(result.text, /sse/i); + assert.deepEqual(client.calls[0], ["rememberAndWait", "I use pnpm", "work"]); +}); + +test("memwal_recall goes through recall with maxDistance and formats hits", async () => { + const client = stub(); + const result = await runMemoryTool( + "memwal_recall", + { query: "trip", limit: 5, maxDistance: 0.4 }, + undefined, + client, + RELAYER, + ); + assert.equal(result.isError, false); + assert.match(result.text, /montreal trip/); + assert.match(result.text, /score=0.900/); + assert.match(result.text, /written=2026-09-01/); + assert.deepEqual(client.calls[0][1], { + query: "trip", + limit: 5, + namespace: undefined, + maxDistance: 0.4, + }); +}); + +test("memwal_remember_bulk maps facts onto rememberBulkAndWait", async () => { + const client = stub(); + const result = await runMemoryTool( + "memwal_remember_bulk", + { facts: ["a", "b"] }, + "ns", + client, + RELAYER, + ); + assert.equal(result.isError, false); + assert.match(result.text, /Saved 2\/2/); + assert.equal(client.calls[0][0], "rememberBulkAndWait"); + assert.deepEqual(client.calls[0][1], [ + { text: "a", namespace: "ns" }, + { text: "b", namespace: "ns" }, + ]); +}); + +test("memwal_health names the dialled relayer", async () => { + const client = stub(); + const result = await runMemoryTool("memwal_health", {}, undefined, client, RELAYER); + assert.equal(result.isError, false); + assert.match(result.text, /status=ok/); + assert.match(result.text, /write_ready=true/); + assert.match(result.text, new RegExp(`relayer=${RELAYER}`)); +}); + +test("memwal_restore reports counts from restore()", async () => { + const client = stub(); + const result = await runMemoryTool( + "memwal_restore", + { namespace: "work" }, + undefined, + client, + RELAYER, + ); + assert.equal(result.isError, false); + assert.match(result.text, /restored=2/); + assert.match(result.text, /truncated=false/); +}); + +test("a 401 from the SDK is not a creds wipe — it names login", async () => { + const err = Object.assign(new Error("unauthorized"), { status: 401 }); + const formatted = formatToolError(err); + assert.equal(formatted.isError, true); + assert.equal(formatted.text, UNAUTHORIZED_TEXT); + + const client = stub({ + recall: async () => { + throw Object.assign(new Error("nope"), { status: 401 }); + }, + }); + const result = await runMemoryTool( + "memwal_recall", + { query: "x" }, + undefined, + client, + RELAYER, + ); + assert.equal(result.isError, true); + assert.equal(result.text, UNAUTHORIZED_TEXT); +}); diff --git a/packages/mcp/test/no-sse.test.mjs b/packages/mcp/test/no-sse.test.mjs new file mode 100644 index 000000000..04eb69571 --- /dev/null +++ b/packages/mcp/test/no-sse.test.mjs @@ -0,0 +1,128 @@ +/** + * The stdio process must never open /api/mcp/sse. + * + * memwal_health is unsigned GET /health on the SDK — a mock relayer that + * 500s SSE and serves /health is enough to prove the path. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); + +function makeCreds(relayerUrl) { + return { + delegatePrivateKey: "a".repeat(64), + delegatePublicKeyHex: "b".repeat(64), + delegateAddress: "0x" + "1".repeat(64), + walletAddress: "0x" + "2".repeat(64), + accountId: "0x" + "3".repeat(64), + packageId: "0x" + "4".repeat(64), + relayerUrl, + label: "no-sse", + createdAt: new Date(0).toISOString(), + version: 1, + }; +} + +function startMockRelayer() { + const hits = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + hits.push(`${req.method} ${url.pathname}`); + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok", version: "1.2.3", write_ready: true })); + return; + } + if (url.pathname.startsWith("/api/mcp")) { + res.writeHead(410, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "sse deprecated in this test" })); + return; + } + res.writeHead(404); + res.end(); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + server, + base: `http://127.0.0.1:${port}`, + hits, + }); + }); + }); +} + +test("memwal_health uses GET /health and never opens /api/mcp/sse", async (t) => { + const { server, base, hits } = await startMockRelayer(); + const credsDir = mkdtempSync(join(tmpdir(), "memwal-no-sse-")); + writeFileSync(join(credsDir, "credentials.json"), JSON.stringify(makeCreds(base)), { + mode: 0o600, + }); + + const child = spawn(process.execPath, [BIN, "--relayer", base], { + env: { ...process.env, MEMWAL_CREDS_DIR: credsDir, HOME: credsDir, USERPROFILE: credsDir }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const received = []; + let buf = ""; + child.stdout.on("data", (d) => { + buf += d.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line.trim()) continue; + try { + received.push(JSON.parse(line)); + } catch { + /* ignore */ + } + } + }); + const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); + const waitFor = (pred, ms = 8_000) => { + const hit = received.find(pred); + if (hit) return Promise.resolve(hit); + return new Promise((res, rej) => { + const started = Date.now(); + const tick = () => { + const found = received.find(pred); + if (found) return res(found); + if (Date.now() - started > ms) return rej(new Error(`timed out; hits=${hits.join(",")}`)); + setTimeout(tick, 20); + }; + tick(); + }); + }; + + t.after(() => { + child.kill("SIGKILL"); + server.close(); + rmSync(credsDir, { recursive: true, force: true }); + }); + + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }); + await waitFor((m) => m.id === 1 && m.result); + + send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "memwal_health", arguments: {} } }); + const health = await waitFor((m) => m.id === 2 && m.result); + assert.equal(health.result.isError, false); + assert.match(health.result.content[0].text, /status=ok/); + assert.match(health.result.content[0].text, new RegExp(`relayer=${base}`)); + assert.ok(hits.includes("GET /health")); + assert.equal( + hits.filter((h) => h.includes("/api/mcp")).length, + 0, + `stdio must not touch MCP SSE/messages; hits=${hits.join(",")}`, + ); +}); diff --git a/packages/mcp/test/orphaned-call.test.mjs b/packages/mcp/test/orphaned-call.test.mjs deleted file mode 100644 index 79a304496..000000000 --- a/packages/mcp/test/orphaned-call.test.mjs +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Regression test for the per-call deadline (bridge.ts). - * - * Bug being guarded against (WALM-328): the idle watchdog only notices a silent - * SSE *stream*. The relayer sends a keepalive every 3s, so a stream whose - * heartbeats keep flowing looks perfectly healthy even when one response frame - * has gone missing. That request then sits in `inFlight` forever — no watchdog, - * therefore no reconnect, therefore no replay — and the caller can only report a - * bare "timeout" with nothing to act on. - * - * Repro: - * - Mock relayer keeps the SSE session alive and heartbeating throughout. - * - It answers `initialize` normally, so the bridge is fully connected. - * - It accepts the `memwal_remember` POST with 202 and then never emits the - * matching response frame. - * - With MEMWAL_MCP_CALL_TIMEOUT_MS=2000 the sweeper closes the call out with - * an explicit, retryable error instead of leaving it hanging. - * - * The "only one SSE handshake" assertion is what proves this is the new code - * path: if the watchdog had fired we would see a reconnect, and the test would - * be passing for the wrong reason. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer whose SSE session stays healthy for the whole test. It answers - * `initialize`, but swallows `memwal_remember` — the POST is accepted and no - * response frame is ever written. `releaseSwallowed()` lets the test emit that - * withheld reply afterwards, to prove a late arrival is not written a second - * time for an id already closed out. */ -function startMockRelayer() { - const sessions = new Map(); - let sseGetCount = 0; - let swallowed = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write( - `event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`, - ); - sessions.set(sessionId, { res }); - // Heartbeat far faster than the idle timeout so the stream is never - // idle. This is the condition the watchdog cannot help with. - const hb = setInterval(() => { - if (res.writableEnded) { - clearInterval(hb); - return; - } - res.write(":keepalive\n\n"); - }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - const sessionId = url.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - if (!session) { - res.writeHead(404); - res.end(); - return; - } - res.writeHead(202); - res.end(); - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "initialize") { - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - }, - })}\n\n`, - ); - return; - } - if (msg.method === "tools/call" && msg.params?.name === "memwal_remember") { - // Accepted, executed server-side as far as the client knows, - // and the reply never comes back. - swallowed = { session, id: msg.id }; - return; - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - releaseSwallowed: () => { - if (!swallowed) return false; - swallowed.session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: swallowed.id, - result: { - content: [{ type: "text", text: "LATE_REPLY" }], - isError: false, - }, - })}\n\n`, - ); - return true; - }, - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Orphan Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -test("a sent write whose reply never arrives is closed out without inviting a duplicate", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-orphan-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - // Well above the call deadline: the stream must never be judged idle, - // so the watchdog cannot be what rescues this call. - MEMWAL_MCP_SSE_IDLE_MS: "30000", - MEMWAL_MCP_CALL_TIMEOUT_MS: "2000", - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => { - child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 10_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - // The relayer accepts this and never answers it. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_remember", arguments: { text: "orphan me" } }, - }); - - // Before the fix this never resolved. - const orphaned = await waitFor((m) => m.id === 2, 10_000); - assert.equal(orphaned.result?.isError, true, "expected a tool-result error envelope"); - const orphanedText = JSON.stringify(orphaned.result); - - // `memwal_remember` is a write, and this one WAS sent — the relayer - // accepted it and answered 202 before finishing in a durable queue, so the - // lost reply says nothing about whether it landed. The message must not - // invite a blind repeat: `/api/remember/bulk` carries no idempotency key, - // so repeating a batch that already landed buys a second paid blob. - assert.match( - orphanedText, - /may have completed|does not cancel it/i, - "a sent write must say it may already have landed", - ); - assert.match( - orphanedText, - /memwal_recall/, - "a sent write must point at recall as the way to check before re-saving", - ); - // Careful with the negative: the message deliberately says it "does not - // mean nothing was stored", which is the opposite of claiming it. What must - // never appear is the instruction to repeat the call. - assert.doesNotMatch( - orphanedText, - /please retry/i, - "a sent write must not invite a blind retry", - ); - assert.doesNotMatch( - orphanedText, - /relayer unavailable/i, - "the relayer was healthy — saying otherwise sends debugging the wrong way", - ); - - // The stream stayed healthy throughout, so no reconnect should have happened. - // If this fails, the watchdog rescued the call and the deadline was never - // exercised. - assert.equal( - mock.getSseGetCount(), - 1, - `expected exactly 1 SSE handshake, saw ${mock.getSseGetCount()}`, - ); - - // A late genuine reply for an id already closed out must be dropped, or the - // client would see two responses for the same id. - assert.ok(mock.releaseSwallowed(), "mock should have had a withheld reply"); - await new Promise((r) => setTimeout(r, 1000)); - const repliesForId2 = received.filter((m) => m.id === 2); - assert.equal( - repliesForId2.length, - 1, - `expected exactly one reply for id 2, got ${repliesForId2.length}`, - ); - assert.doesNotMatch(JSON.stringify(repliesForId2), /LATE_REPLY/); -}); diff --git a/packages/mcp/test/pending-forward-stalled.test.mjs b/packages/mcp/test/pending-forward-stalled.test.mjs deleted file mode 100644 index ca978408d..000000000 --- a/packages/mcp/test/pending-forward-stalled.test.mjs +++ /dev/null @@ -1,605 +0,0 @@ -/** - * Regression test for WALM-618 — a tool call that expires while still buffered - * must be explained as what it is: a call that never left this process. - * - * Repro (the shape Dio hit: 15 session opens, 6 calls that ever reached the - * relayer, minutes of silence in between): - * - Mock relayer answers GET /version, then 503s every SSE handshake, the - * way the real relayer does when the on-chain delegate verify cannot - * reach a throttled fullnode. - * - A `tools/call` arrives before any session exists, so it lands in - * `pendingForward` and waits there while the bridge retries. - * - * The orphan sweeper did already bound this wait. What it got wrong was the - * answer: every expiry was reported as "the connection to the relayer dropped - * before the result came back", which points the user at the relayer — or at a - * possibly half-written memory — when in fact nothing was ever sent and the - * handshake was the thing failing. - * - * Asserts: - * - `initialize` is still answered locally, exactly once. - * - the buffered call is NOT eager-failed between retries (the property - * `coldstart-timeout.test.mjs` locks down — this stays a deadline, not a - * per-attempt failure). - * - once the deadline passes it is answered as a tool error naming the - * failing connection, saying nothing was stored, and carrying the last - * handshake error. - * - the process stays alive throughout. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -/** The deadline under test: the short one that applies only to a call which - * never left the bridge while no connection has existed. Short enough to run, - * long enough that several connect-retry cycles fit inside it — otherwise - * "not eager-failed between retries" would pass for the wrong reason. */ -const STALLED_HANDSHAKE_MS = 3_000; - -/** Deliberately far larger, so an answer arriving near STALLED_HANDSHAKE_MS - * proves the stalled-handshake deadline fired and not the ordinary call - * timeout, which is what used to leave the user waiting ~4 minutes. */ -const CALL_TIMEOUT_MS = 60_000; -const CONNECT_TIMEOUT_MS = 400; - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer that 503s every SSE handshake until `heal()` is called — an - * infrastructure failure, not an auth rejection, which is exactly what a - * throttled fullnode produces via the relayer's `upstream_unavailable()`. - * Once healed it behaves like a normal session, and records every JSON-RPC - * envelope it is posted so a test can prove what did (and did not) reach it. */ -function startUnavailableRelayer() { - let sseGetCount = 0; - let healthy = false; - const sessions = new Map(); - const posted = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - if (!healthy) { - res.writeHead(503, { "content-type": "text/plain" }); - res.end("Account resolution unavailable: on-chain re-verify unavailable"); - return; - } - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, { res }); - const hb = setInterval(() => { - if (res.writableEnded) { - clearInterval(hb); - return; - } - res.write(":\n\n"); - }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - const session = sessions.get(url.searchParams.get("sessionId")); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - if (!session) { - res.writeHead(404); - res.end(); - return; - } - try { - posted.push(JSON.parse(body)); - } catch { - /* not JSON — not something this test asserts on */ - } - res.writeHead(202); - res.end(); - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - getPosted: () => posted, - heal: () => { - healthy = true; - }, - closeStreams: () => - sessions.forEach((s) => { - if (!s.res.writableEnded) s.res.end(); - }), - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Pending Forward Stalled Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -test("a call buffered behind a failing handshake is answered, and says why", async (t) => { - const mock = await startUnavailableRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-pending-stalled-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - MEMWAL_MCP_CONNECT_TIMEOUT_MS: String(CONNECT_TIMEOUT_MS), - MEMWAL_MCP_CALL_TIMEOUT_MS: String(CALL_TIMEOUT_MS), - MEMWAL_MCP_STALLED_HANDSHAKE_MS: String(STALLED_HANDSHAKE_MS), - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15_000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => { - child.kill("SIGKILL"); - mock.closeStreams(); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 5_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - const sentAt = Date.now(); - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_remember", arguments: { text: "anything" } }, - }); - - // Half the deadline in, several connect attempts have already failed and - // the call must still be waiting — the fix adds a deadline, it does not - // eager-fail a call the next attempt might serve. - await new Promise((r) => setTimeout(r, STALLED_HANDSHAKE_MS / 2)); - assert.ok( - mock.getSseGetCount() >= 2, - `expected the handshake to have been retried by now, saw ${mock.getSseGetCount()} attempts`, - ); - assert.ok( - !received.some((m) => m.id === 2), - `id=2 must still be buffered mid-deadline, got: ${JSON.stringify(received.find((m) => m.id === 2))}`, - ); - - // Past the deadline it is answered rather than left hanging forever. - const reply = await waitFor((m) => m.id === 2, 15_000); - const waitedMs = Date.now() - sentAt; - assert.ok( - waitedMs >= STALLED_HANDSHAKE_MS, - `must not be answered before its deadline; waited only ${waitedMs}ms`, - ); - assert.ok( - waitedMs < CALL_TIMEOUT_MS, - `must be answered on the stalled-handshake deadline, not the ordinary ${CALL_TIMEOUT_MS}ms ` + - `call timeout — that long wait with no feedback is the reported bug; waited ${waitedMs}ms`, - ); - - assert.equal( - reply.result?.isError, - true, - `expected a tool-error envelope, got ${JSON.stringify(reply)}`, - ); - const text = JSON.stringify(reply.result); - assert.match( - text, - /could not reach the relayer/i, - `the answer must name the failing connection, got ${text}`, - ); - assert.match( - text, - /nothing was\\?\s*stored/i, - `the answer must say the call never ran, got ${text}`, - ); - assert.match( - text, - /503/, - `the answer must carry the handshake error the call was stuck behind, got ${text}`, - ); - - assert.equal(child.exitCode, null, "bridge should still be running, not exited"); - - // initialize answered exactly once — the sweep must never write a second - // envelope for an id that was answered locally. - const initReplies = received.filter((m) => m.id === 1 && (m.result || m.error)); - assert.equal( - initReplies.length, - 1, - `initialize (id=1) must be answered exactly once; saw ${initReplies.length}`, - ); - - // The hazard the expiry has to close: the answered call is still a plain - // object sitting in `pendingForward`, and the flush that follows the next - // successful connect forwards whatever it finds there. Left in, a - // `remember` we just reported as never having run would run for real — - // after the agent was told nothing was stored, so it may well have retried - // by then. Let the relayer recover and prove the call is gone. - mock.heal(); - const connectedAt = Date.now(); - while (!stderrBuf.includes("Connected. Bridging") && Date.now() - connectedAt < 15_000) { - await new Promise((r) => setTimeout(r, 100)); - } - assert.ok( - stderrBuf.includes("Connected. Bridging"), - `the relayer must recover for this assertion to mean anything; stderr:\n${stderrBuf}`, - ); - - // A fresh call proves the session really is carrying traffic, so "id=2 - // never posted" below is evidence rather than an artefact of a dead link. - send({ - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "memwal_remember", arguments: { text: "after recovery" } }, - }); - const postedAt = Date.now(); - while ( - !mock.getPosted().some((m) => m.id === 3) && - Date.now() - postedAt < 10_000 - ) { - await new Promise((r) => setTimeout(r, 100)); - } - assert.ok( - mock.getPosted().some((m) => m.id === 3), - `a call sent after recovery must reach the relayer; posted: ${JSON.stringify(mock.getPosted())}`, - ); - - assert.ok( - !mock.getPosted().some((m) => m.id === 2), - `the expired call must never reach the relayer after being answered, but saw: ${JSON.stringify( - mock.getPosted().filter((m) => m.id === 2), - )}`, - ); - const callReplies = received.filter((m) => m.id === 2); - assert.equal( - callReplies.length, - 1, - `id=2 must be answered exactly once; saw ${callReplies.length}: ${JSON.stringify(callReplies)}`, - ); - - // `initialize` is buffered, not answered upstream — the expiry must leave - // it in place so the recovered session still negotiates capabilities. - assert.ok( - mock.getPosted().some((m) => m.method === "initialize"), - `initialize must still be forwarded once the session comes up; posted: ${JSON.stringify( - mock.getPosted().map((m) => m.method ?? m.id), - )}`, - ); -}); - -/** Serves exactly one healthy session, then refuses every reconnect and 404s - * any POST against the dead session — the way the real relayer does. The - * sibling mock above fails from the first handshake, which lands the request - * in `pendingForward`; that path is already covered and cannot reach the - * mid-session case. */ -function startHealthyThenDeadRelayer() { - let sseGetCount = 0; - let postCount = 0; - let sessionAlive = false; - let liveSession = null; - let liveHeartbeat = null; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - if (sseGetCount > 1) { - res.writeHead(503, { "content-type": "text/plain" }); - res.end("upstream unavailable"); - return; - } - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=session-1\n\n"); - liveSession = res; - sessionAlive = true; - liveHeartbeat = setInterval(() => { - if (!res.writableEnded) res.write(":\n\n"); - }, 200); - liveHeartbeat.unref?.(); - res.on("close", () => clearInterval(liveHeartbeat)); - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - postCount += 1; - // A POST against a session that no longer exists is a 404 here, as - // it is on the relayer. Answering 202 would let a stray post look - // delivered and quietly flip the request to "sent". - res.writeHead(!hasBridgeAuth(req) ? 401 : sessionAlive ? 202 : 404); - res.end(); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((ready) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - ready({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - getPostCount: () => postCount, - killSession: () => { - sessionAlive = false; - if (liveHeartbeat) clearInterval(liveHeartbeat); - if (liveSession && !liveSession.writableEnded) liveSession.end(); - }, - closeStreams: () => { - sessionAlive = false; - if (liveHeartbeat) clearInterval(liveHeartbeat); - if (liveSession && !liveSession.writableEnded) liveSession.end(); - }, - }); - }); - }); -} - -test("a call issued after the session dies is answered on the stalled deadline", async (t) => { - // `neverSent` used to be read from `pendingForward` membership, and nothing - // refills that buffer once `firstConnectDone` is set — so in the reported - // shape (the bridge worked, then the relayer stopped answering) the call - // looked sent, kept the full call timeout, and blamed a dropped connection - // for a request that never left the process. - // - // Every step below is confirmed from the bridge's own stderr before the - // next one runs. Two earlier attempts at this test raced an internal state - // transition the mock cannot see, and failed in ways the output could not - // explain; if this one fails, the assertion says which precondition broke. - const mock = await startHealthyThenDeadRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-midsession-stalled-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - MEMWAL_MCP_CONNECT_TIMEOUT_MS: String(CONNECT_TIMEOUT_MS), - MEMWAL_MCP_CALL_TIMEOUT_MS: String(CALL_TIMEOUT_MS), - MEMWAL_MCP_STALLED_HANDSHAKE_MS: String(STALLED_HANDSHAKE_MS), - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const dump = (what) => - `${what}\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`; - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms, what) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej(new Error(dump(`timed out waiting for ${what}`))); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - /** Poll a condition, and fail with the full bridge output naming it. */ - const until = async (pred, ms, what) => { - const deadline = Date.now() + ms; - while (Date.now() < deadline) { - if (pred()) return; - await new Promise((r) => setTimeout(r, 50)); - } - throw new Error(dump(`precondition never held: ${what}`)); - }; - - t.after(() => { - child.kill("SIGKILL"); - mock.closeStreams(); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - // 1. initialize is answered locally. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await waitFor((m) => m.id === 1 && m.result, 10_000, "the local initialize reply"); - - // 2. the first session is genuinely up, so `firstConnectDone` is set and - // this cannot degenerate into the cold-start case. - await until(() => /"event":"bridge\.connected"/.test(stderrBuf), 15_000, "bridge.connected"); - - // 3. the relayer goes away and refuses every reconnect. - mock.killSession(); - await until( - () => /"event":"bridge\.reconnect_failed"/.test(stderrBuf), - 20_000, - "bridge.reconnect_failed (so sse is null and the handshake is on record as failing)", - ); - - // 4. only now is the call issued — it must take the never-sent path. - const sentAt = Date.now(); - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_remember", arguments: { text: "anything" } }, - }); - - const reply = await waitFor( - (m) => m.id === 2, - Math.floor(CALL_TIMEOUT_MS * 0.6), - "the tool-call answer on the stalled-handshake deadline", - ); - const waitedMs = Date.now() - sentAt; - - // `sse` is NOT cleared on server-pump EOF — it keeps pointing at the dead - // session until a reconnect succeeds — so the call does take the POST - // path and is 404'd by the relayer. That 404 is what returns it to - // never-sent: the session did not exist, so the message was discarded - // rather than routed, and it provably did not run. - assert.match( - stderrBuf, - /"event":"bridge\.session_stale"/, - dump("expected the stale-session 404 that returns the call to never-sent"), - ); - assert.ok( - waitedMs < CALL_TIMEOUT_MS, - `must expire on the ${STALLED_HANDSHAKE_MS}ms stalled deadline, not the ${CALL_TIMEOUT_MS}ms ` + - `call timeout — waiting out the latter with no feedback is the reported bug; waited ${waitedMs}ms`, - ); - assert.equal(reply.result?.isError, true, dump("expected a tool-error envelope")); - const text = JSON.stringify(reply.result); - assert.match(text, /could not reach the relayer/i, dump("must name the failing connection")); - assert.match(text, /nothing was\\?\s*stored/i, dump("must say the call never ran")); - assert.equal(child.exitCode, null, "bridge should still be running, not exited"); -}); diff --git a/packages/mcp/test/sdk-stdio.test.mjs b/packages/mcp/test/sdk-stdio.test.mjs new file mode 100644 index 000000000..6bf741cd5 --- /dev/null +++ b/packages/mcp/test/sdk-stdio.test.mjs @@ -0,0 +1,297 @@ +/** + * stdio MCP server talks to a MemWal stub — no SSE mock. + * + * Covers remember/recall on the JSON-RPC loop, mid-session login pickup, + * and logout dropping the in-process client (GH #616). + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { SIGNED_OUT_TEXT } from "../dist/format.js"; +import { resetServerState, runStdioServer } from "../dist/server.js"; +import { dropClient, setClientFactory } from "../dist/session.js"; + +function makeCreds(relayerUrl) { + return { + delegatePrivateKey: "a".repeat(64), + delegatePublicKeyHex: "b".repeat(64), + delegateAddress: "0x" + "1".repeat(64), + walletAddress: "0x" + "2".repeat(64), + accountId: "0x" + "3".repeat(64), + packageId: "0x" + "4".repeat(64), + relayerUrl, + label: "Test", + createdAt: new Date(0).toISOString(), + version: 1, + }; +} + +function startSession({ credsDir, createClient, namespace } = {}) { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding("utf8"); + let buf = ""; + const received = []; + const pending = []; + stdout.on("data", (chunk) => { + buf += chunk; + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line.trim()) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + received.push(msg); + const waiter = pending.find((w) => w.match(msg)); + if (waiter) { + pending.splice(pending.indexOf(waiter), 1); + waiter.resolve(msg); + } + } + }); + + const waitFor = (match, timeoutMs = 5_000) => { + const hit = received.find(match); + if (hit) return Promise.resolve(hit); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("timed out waiting for MCP message")), + timeoutMs, + ); + pending.push({ + match, + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + }); + }); + }; + + const send = (msg) => stdin.write(JSON.stringify(msg) + "\n"); + + resetServerState(); + dropClient(); + if (createClient) setClientFactory(createClient); + else setClientFactory(undefined); + + const envCreds = process.env.MEMWAL_CREDS_DIR; + if (credsDir) process.env.MEMWAL_CREDS_DIR = credsDir; + + const done = runStdioServer( + { + relayerUrl: "http://127.0.0.1:9", + webUrl: "http://127.0.0.1:9", + label: "Test", + namespace, + }, + { stdin, stdout }, + ); + + return { + send, + waitFor, + waitId: (id) => waitFor((m) => m.id === id), + close: async () => { + stdin.end(); + await done; + setClientFactory(undefined); + dropClient(); + resetServerState(); + if (credsDir) { + if (envCreds === undefined) delete process.env.MEMWAL_CREDS_DIR; + else process.env.MEMWAL_CREDS_DIR = envCreds; + } + }, + }; +} + +function stubClient() { + const calls = []; + return { + calls, + rememberAndWait: async (text, namespace) => { + calls.push(["rememberAndWait", text, namespace]); + return { blob_id: "blob-1", namespace: namespace ?? "default" }; + }, + rememberBulkAndWait: async () => { + throw new Error("not used"); + }, + recall: async (params) => { + calls.push(["recall", params]); + return { results: [{ text: "montreal trip", distance: 0.2 }] }; + }, + analyzeAndWait: async () => { + throw new Error("not used"); + }, + restore: async () => { + throw new Error("not used"); + }, + health: async () => ({ status: "ok", version: "1.2.3" }), + destroy() { + calls.push(["destroy"]); + }, + }; +} + +test.describe("stdio SDK server", { concurrency: 1 }, () => { +test("remember and recall run through the SDK stub with no SSE", async () => { + const dir = mkdtempSync(join(tmpdir(), "memwal-sdk-stdio-")); + writeFileSync(join(dir, "credentials.json"), JSON.stringify(makeCreds("http://relayer.test"))); + const stub = stubClient(); + const session = startSession({ + credsDir: dir, + createClient: () => stub, + namespace: "work", + }); + try { + session.send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test" } }, + }); + const init = await session.waitId(1); + assert.match(init.result.instructions, /RECALL: before answering/); + + session.send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const listed = await session.waitId(2); + const names = listed.result.tools.map((t) => t.name); + assert.ok(names.includes("memwal_remember")); + assert.ok(names.includes("memwal_logout")); + + session.send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "memwal_remember", arguments: { text: "I use pnpm" } }, + }); + const remembered = await session.waitId(3); + assert.equal(remembered.result.isError, false); + assert.match(remembered.result.content[0].text, /blob_id=blob-1/); + assert.deepEqual(stub.calls[0], ["rememberAndWait", "I use pnpm", "work"]); + + session.send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "package manager" } }, + }); + const recalled = await session.waitId(4); + assert.equal(recalled.result.isError, false); + assert.match(recalled.result.content[0].text, /montreal trip/); + assert.equal(stub.calls[1][0], "recall"); + } finally { + await session.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("writing credentials mid-session lets the next recall hit the SDK without restart", async () => { + const dir = mkdtempSync(join(tmpdir(), "memwal-sdk-handoff-")); + const stub = stubClient(); + const session = startSession({ + credsDir: dir, + createClient: () => stub, + }); + try { + session.send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {} }, + }); + await session.waitId(1); + + session.send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "trip" } }, + }); + const denied = await session.waitId(2); + assert.equal(denied.result.isError, true); + assert.match(denied.result.content[0].text, /isn't signed in/); + assert.equal(stub.calls.length, 0); + + writeFileSync(join(dir, "credentials.json"), JSON.stringify(makeCreds("http://relayer.test"))); + + session.send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "trip" } }, + }); + const ok = await session.waitId(3); + assert.equal(ok.result.isError, false); + assert.match(ok.result.content[0].text, /montreal trip/); + assert.equal(stub.calls[0][0], "recall"); + } finally { + await session.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("logout drops the in-process client so a later recall never reaches the SDK", async () => { + const dir = mkdtempSync(join(tmpdir(), "memwal-sdk-logout-")); + writeFileSync(join(dir, "credentials.json"), JSON.stringify(makeCreds("http://relayer.test"))); + const stub = stubClient(); + const session = startSession({ + credsDir: dir, + createClient: () => stub, + }); + try { + session.send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {} }, + }); + await session.waitId(1); + + session.send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "trip" } }, + }); + const before = await session.waitId(2); + assert.equal(before.result.isError, false); + assert.equal(stub.calls.filter((c) => c[0] === "recall").length, 1); + + session.send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "memwal_logout", arguments: {} }, + }); + const loggedOut = await session.waitId(3); + assert.equal(loggedOut.result.isError, false); + assert.match(loggedOut.result.content[0].text, /Signed out/); + assert.ok(stub.calls.some((c) => c[0] === "destroy")); + + session.send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "trip" } }, + }); + const after = await session.waitId(4); + assert.equal(after.result.isError, true); + assert.equal(after.result.content[0].text, SIGNED_OUT_TEXT); + assert.equal(stub.calls.filter((c) => c[0] === "recall").length, 1); + } finally { + await session.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); +}); diff --git a/packages/mcp/test/sse-handshake-429.test.mjs b/packages/mcp/test/sse-handshake-429.test.mjs deleted file mode 100644 index 30651153c..000000000 --- a/packages/mcp/test/sse-handshake-429.test.mjs +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Regression test for WALM-386 — a 429 on the SSE handshake must be honoured as - * a THROTTLE, not retried blind. - * - * Bug being guarded against: `openSseStream` read the `retry-after` header and - * then interpolated it into an Error *message string*, so nothing - * machine-readable survived. Both retry loops (`connectInBackground` and - * `reconnect`) fell back to the generic geometric backoff, i.e. the first retry - * after a 429 landed ~500ms later — well inside the window the relayer had just - * asked for, and pure noise against `ip_active_cap`, which is a CONCURRENT cap - * that only clears when some other session closes. - * - * Repro: - * - Mock relayer answers GET /version, then 429s the SSE GET for the first N - * attempts (with or without a `retry-after` header), then serves a real - * event-stream. Every SSE GET is timestamped. - * - * Asserts: - * - a `retry-after: 2` is actually waited out (gap between attempts ≈ 2s, not - * 500ms), and the attempt COUNT over the throttle window stays low; - * - a 429 with NO `retry-after` (the `ip_active_cap` shape) falls back to the - * throttle floor rather than the sub-second geometric backoff; - * - the bridge does not exit and `initialize` is still answered locally - * exactly once; - * - a tool call buffered during the throttle is served for real once the - * relayer stops throttling (the buffering path is untouched); - * - stderr says "rate-limiting … not a bad config or bad credentials", so the - * user can tell a throttle from a misconfiguration. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** - * Mock relayer that 429s the SSE handshake `throttleCount` times, then serves a - * working stream. `retryAfterSeconds: null` reproduces the header-less - * `ip_active_cap` denial the real relayer sends for a concurrent cap. - */ -function startThrottlingRelayer({ throttleCount, retryAfterSeconds }) { - /** ms-since-start of every SSE GET, so the test can measure the gaps. */ - const sseGetAt = []; - const startedAt = Date.now(); - const sessions = new Map(); - let sseGetCount = 0; - const openStreams = []; - - const server = http.createServer((req, res) => { - const u = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && u.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && u.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - sseGetAt.push(Date.now() - startedAt); - if (sseGetCount <= throttleCount) { - // Same envelope the relayer's `rateLimitDeny` sends. - const headers = { "content-type": "application/json" }; - if (retryAfterSeconds != null) { - headers["retry-after"] = String(retryAfterSeconds); - } - res.writeHead(429, headers); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32000, - message: - retryAfterSeconds == null - ? "MCP rate limit: ip_active_cap. Close another MCP session, then retry." - : `MCP rate limit: ip_burst_cap. Try again in ${retryAfterSeconds}s.`, - }, - id: null, - }), - ); - return; - } - const sessionId = `session-${sseGetCount}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write(`event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`); - sessions.set(sessionId, { res }); - openStreams.push(res); - const hb = setInterval(() => { - if (!res.writableEnded) res.write(":\n\n"); - else clearInterval(hb); - }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - return; - } - if (req.method === "POST" && u.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - const session = sessions.get(u.searchParams.get("sessionId")); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - let msg; - try { - msg = JSON.parse(body); - } catch { - res.writeHead(202); - res.end(); - return; - } - if (!session) { - res.writeHead(404); - res.end(); - return; - } - res.writeHead(202); - res.end(); - if (msg.method === "initialize") return; // suppressed by the bridge - if (msg.method === "tools/call") { - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: "RECALLED" }], - isError: false, - }, - })}\n\n`, - ); - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - res({ - server, - base: `http://127.0.0.1:${server.address().port}`, - sseGetAt, - getSseGetCount: () => sseGetCount, - closeStreams: () => openStreams.forEach((r) => r.end()), - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "SSE 429 Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -/** Spawn the bridge against `mock`, wired with the usual line-splitter. */ -function startBridge(t, mock, env = {}) { - const home = mkdtempSync(join(tmpdir(), "memwal-sse-429-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { ...process.env, HOME: home, USERPROFILE: home, ...env }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - t.after(() => { - child.kill("SIGKILL"); - mock.closeStreams(); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - return { - child, - received, - send: (obj) => child.stdin.write(JSON.stringify(obj) + "\n"), - stderr: () => stderrBuf, - waitFor: (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }, - }; -} - -test("a 429 with Retry-After is waited out, not retried after 500ms", async (t) => { - const mock = await startThrottlingRelayer({ throttleCount: 2, retryAfterSeconds: 2 }); - // Floor set well BELOW the advertised interval so a passing gap can only - // come from the header, never from the no-header fallback. - const bridge = startBridge(t, mock, { MEMWAL_MCP_THROTTLE_FLOOR_MS: "250" }); - - // initialize is answered locally even while the relayer is throttling — the - // whole reason a throttled bridge should not look like a broken one. - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await bridge.waitFor((m) => m.id === 1 && m.result, 5_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - // Buffered during the throttle; must be served for real after recovery. - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - - // 2 denials × 2s ≈ 4s before the third attempt succeeds. - const recall = await bridge.waitFor((m) => m.id === 2, 20_000); - - // The regression guard. Pre-fix the gaps were ~500ms / ~1s (geometric). - const gaps = mock.sseGetAt.slice(1).map((t2, i) => t2 - mock.sseGetAt[i]); - assert.ok( - gaps.length >= 2, - `expected at least 3 SSE attempts, saw ${mock.sseGetAt.length}: ${JSON.stringify(mock.sseGetAt)}`, - ); - for (const [i, gap] of gaps.entries()) { - assert.ok( - gap >= 1_600, - `attempt ${i + 2} came ${gap}ms after attempt ${i + 1}; a retry-after of 2s must be honoured (gaps: ${JSON.stringify(gaps)})`, - ); - } - // Attempt count is the flake-resistant half of the same signal: a 500ms - // geometric backoff would have burned ~7 attempts by the time this lands. - assert.ok( - mock.getSseGetCount() <= 4, - `expected the throttle to be respected, but the bridge made ${mock.getSseGetCount()} SSE attempts`, - ); - - // Recovery: the buffered call is served, not error-enveloped. - assert.equal( - recall.result?.isError, - false, - `buffered call should be served after the throttle clears, got ${JSON.stringify(recall)}`, - ); - - // Still alive, and initialize answered exactly once. - assert.equal(bridge.child.exitCode, null, "bridge should still be running, not exited"); - const initReplies = bridge.received.filter((m) => m.id === 1 && (m.result || m.error)); - assert.equal( - initReplies.length, - 1, - `initialize (id=1) must be answered exactly once; saw ${initReplies.length}`, - ); - - // The user must be able to tell "throttled" from "misconfigured". - const stderr = bridge.stderr(); - assert.match(stderr, /rate-limiting new MCP sessions \(HTTP 429\)/); - assert.match(stderr, /not a bad config or bad credentials/); - assert.doesNotMatch( - stderr, - /rejected credentials \(HTTP 401\)/, - "a throttle must not be reported as a credential problem", - ); -}); - -test("a 429 with Retry-After: 0 falls back to the floor, not to 500ms", async (t) => { - // `0` parses, so it used to satisfy `advised ?? floor` and set the wait to - // zero — the backoff collapsed to the ~500ms geometric retry this whole - // feature exists to remove, and `serverAdvised` stayed true, suppressing - // the concurrent-cap hint as well. It is only reachable in production - // since the relayer started forwarding `retry-after` at all. - const mock = await startThrottlingRelayer({ throttleCount: 1, retryAfterSeconds: 0 }); - const bridge = startBridge(t, mock, { MEMWAL_MCP_THROTTLE_FLOOR_MS: "2500" }); - - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await bridge.waitFor((m) => m.id === 1 && m.result, 5_000); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - await bridge.waitFor((m) => m.id === 2, 20_000); - - assert.ok( - mock.sseGetAt.length >= 2, - `expected a retry after the 429, saw ${mock.sseGetAt.length} attempts`, - ); - const gap = mock.sseGetAt[1] - mock.sseGetAt[0]; - assert.ok( - gap >= 2_000, - `a zero Retry-After must be ignored in favour of the floor; retry came after ${gap}ms`, - ); - - assert.equal(bridge.child.exitCode, null, "bridge should still be running, not exited"); - // Treating it as no usable header also restores `serverAdvised: false`, - // so the user still gets the one remediation that clears a live cap. - assert.match(bridge.stderr(), /closing another\s+MCP client/); -}); - -test("a 429 with no Retry-After falls back to the throttle floor", async (t) => { - // The ip_active_cap shape: a concurrent cap, so the relayer deliberately - // sends no header — there is no honest ETA to give. - const mock = await startThrottlingRelayer({ throttleCount: 1, retryAfterSeconds: null }); - const bridge = startBridge(t, mock, { MEMWAL_MCP_THROTTLE_FLOOR_MS: "2500" }); - - bridge.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - await bridge.waitFor((m) => m.id === 1 && m.result, 5_000); - - bridge.send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - await bridge.waitFor((m) => m.id === 2, 20_000); - - assert.ok( - mock.sseGetAt.length >= 2, - `expected a retry after the 429, saw ${mock.sseGetAt.length} attempts`, - ); - const gap = mock.sseGetAt[1] - mock.sseGetAt[0]; - assert.ok( - gap >= 2_000, - `header-less 429 must fall back to the throttle floor; retry came after ${gap}ms`, - ); - - assert.equal(bridge.child.exitCode, null, "bridge should still be running, not exited"); - // The no-ETA branch tells the user what actually clears a concurrent cap. - assert.match(bridge.stderr(), /closing another\s+MCP client/); -}); diff --git a/packages/mcp/test/sse-idle-watchdog.test.mjs b/packages/mcp/test/sse-idle-watchdog.test.mjs deleted file mode 100644 index 56702a8b1..000000000 --- a/packages/mcp/test/sse-idle-watchdog.test.mjs +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Regression test for the SSE heartbeat watchdog (bridge.ts). - * - * Bug being guarded against: when the relayer-side session silently goes dead - * (TCP socket alive, but no events ever arrive on the SSE stream), the bridge - * waits forever for a response that will never come. Reported as: `memwal_recall` - * hangs indefinitely after a long-running Claude Code session, with the MCP - * wrapper still reporting "Connected". - * - * Repro: - * - Mock relayer accepts the first GET /api/mcp/sse, sends the endpoint - * event, then GOES SILENT — no heartbeats, no responses to POSTs. - * - Bridge sends `memwal_recall`. Without the watchdog it would block forever. - * - With the watchdog: after MEMWAL_MCP_SSE_IDLE_MS, the bridge aborts the - * dead session, opens a fresh one (the mock answers normally on the second - * GET /api/mcp/sse), and replays the in-flight recall. The client sees a - * real response. - * - * We use MEMWAL_MCP_SSE_IDLE_MS=2000 to keep the test fast. - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { spawn } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); -const EXPECTED_BEARER = "a".repeat(64); -const EXPECTED_ACCOUNT_ID = "0x" + "3".repeat(64); - -function hasBridgeAuth(req) { - return ( - req.headers.authorization === `Bearer ${EXPECTED_BEARER}` && - req.headers["x-memwal-account-id"] === EXPECTED_ACCOUNT_ID - ); -} - -/** Mock relayer. The first SSE session is born dead — it emits the endpoint - * event so the bridge thinks it's up, then never sends another byte. The - * second SSE session behaves normally. POSTs land in /api/mcp/messages and - * are routed to whichever session their sessionId points at. */ -function startMockRelayer() { - const sessions = new Map(); // sessionId -> { res, alive } - let sseGetCount = 0; - const server = http.createServer((req, res) => { - const url = new URL(req.url, "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - apiVersion: "1.0.0", - relayerVersion: "1.0.0", - minSupportedSdk: { mcp: "0.0.1" }, - }), - ); - return; - } - if (req.method === "GET" && url.pathname === "/api/mcp/sse") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - sseGetCount += 1; - const sessionId = `session-${sseGetCount}`; - const alive = sseGetCount !== 1; // first session is born dead - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write( - `event: endpoint\ndata: /api/mcp/messages?sessionId=${sessionId}\n\n`, - ); - sessions.set(sessionId, { res, alive }); - if (alive) { - // Send a heartbeat every 200ms while the session is open so a - // healthy session never trips the watchdog. The dead session - // stays silent on purpose — that's the whole point of the test. - const hb = setInterval(() => { - if (res.writableEnded) { - clearInterval(hb); - return; - } - res.write(":\n\n"); - }, 200); - hb.unref?.(); - res.on("close", () => clearInterval(hb)); - } - return; - } - if (req.method === "POST" && url.pathname === "/api/mcp/messages") { - if (!hasBridgeAuth(req)) { - res.writeHead(401); - res.end(); - return; - } - const sessionId = url.searchParams.get("sessionId"); - const session = sessions.get(sessionId); - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - if (!session) { - res.writeHead(404); - res.end(); - return; - } - res.writeHead(202); - res.end(); - if (!session.alive) return; // dead session drops POSTs silently - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (msg.method === "initialize") { - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - }, - })}\n\n`, - ); - return; - } - if (msg.method === "tools/call" && msg.params?.name === "memwal_recall") { - session.res.write( - `event: message\ndata: ${JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { - content: [{ type: "text", text: "RECALL_OK: recovered after reconnect" }], - isError: false, - }, - })}\n\n`, - ); - return; - } - }); - return; - } - res.writeHead(404); - res.end(); - }); - return new Promise((res) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address(); - res({ - server, - base: `http://127.0.0.1:${port}`, - getSseGetCount: () => sseGetCount, - }); - }); - }); -} - -function makeCreds(relayerUrl) { - return { - delegatePrivateKey: EXPECTED_BEARER, - delegatePublicKeyHex: "b".repeat(64), - delegateAddress: "0x" + "1".repeat(64), - walletAddress: "0x" + "2".repeat(64), - accountId: EXPECTED_ACCOUNT_ID, - packageId: "0x" + "4".repeat(64), - relayerUrl, - label: "Watchdog Test", - createdAt: new Date(0).toISOString(), - version: 1, - }; -} - -test("SSE idle watchdog reconnects after a dead session and replays in-flight requests", async (t) => { - const mock = await startMockRelayer(); - const home = mkdtempSync(join(tmpdir(), "memwal-watchdog-test-")); - const credsPath = join(home, ".memwal", "credentials.json"); - mkdirSync(dirname(credsPath), { recursive: true }); - writeFileSync(credsPath, JSON.stringify(makeCreds(mock.base)), { mode: 0o600 }); - - const child = spawn(process.execPath, [BIN, "--relayer", mock.base, "--web-url", mock.base], { - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - MEMWAL_MCP_SSE_IDLE_MS: "1500", - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - const received = []; - const listeners = new Set(); - let buf = ""; - child.stdout.on("data", (d) => { - buf += d.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.trim()) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - received.push(msg); - for (const l of [...listeners]) l(msg); - } - }); - // Drain stderr so the child never blocks on a full pipe; surface it on test - // failure for easier debugging. - let stderrBuf = ""; - child.stderr.on("data", (d) => (stderrBuf += d.toString())); - - const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); - const waitFor = (pred, ms = 15000) => { - const hit = received.find(pred); - if (hit) return Promise.resolve(hit); - return new Promise((res, rej) => { - const timer = setTimeout(() => { - listeners.delete(l); - rej( - new Error( - `timed out waiting for message\n--- stderr ---\n${stderrBuf}\n--- received ---\n${received.map((m) => JSON.stringify(m)).join("\n")}`, - ), - ); - }, ms); - const l = (m) => { - if (pred(m)) { - clearTimeout(timer); - listeners.delete(l); - res(m); - } - }; - listeners.add(l); - }); - }; - - t.after(() => { - child.kill("SIGKILL"); - mock.server.close(); - rmSync(home, { recursive: true, force: true }); - }); - - // Send initialize while the first (dead) session is the only one open. - // Without the watchdog this hangs forever. With it: ~1.5s of silence - // triggers a reconnect; the second session answers initialize properly. - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); - const init = await waitFor((m) => m.id === 1 && m.result, 10_000); - assert.equal(init.result.serverInfo.name, "memwal"); - - // recall (forwarded straight to the relayer on the recovered session) — - // confirms POSTs route to the new session correctly. - send({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "memwal_recall", arguments: { query: "anything" } }, - }); - const recall = await waitFor((m) => m.id === 2, 5_000); - assert.notEqual(recall.result?.isError, true); - assert.match(JSON.stringify(recall.result), /RECALL_OK/); - - // Verify the bridge actually reconnected (i.e. we observed 2 GET /sse - // calls, not 1). If this assertion fails the test "passed" for the wrong - // reason — e.g. some other code path served the response. - assert.ok( - mock.getSseGetCount() >= 2, - `expected at least 2 SSE handshakes, saw ${mock.getSseGetCount()}`, - ); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71e65283e..264b121be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1084,9 +1084,15 @@ importers: packages/mcp: dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(zod@4.3.6) + '@mysten-incubation/memwal': + specifier: workspace:* + version: link:../sdk + '@mysten/seal': + specifier: ^1.1.0 + version: 1.1.1(@mysten/sui@2.20.3(typescript@5.9.3)) + '@mysten/sui': + specifier: ^2.20.3 + version: 2.20.3(typescript@5.9.3) '@noble/ed25519': specifier: 2.3.0 version: 2.3.0 @@ -1817,7 +1823,7 @@ packages: '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -3362,6 +3368,7 @@ packages: '@mysten/dapp-kit@1.0.4': resolution: {integrity: sha512-RybvMIT3R+GNWNH7tOWXnsLk0gKLtpY/GOefp/Mzsqb78JD3QzKzlQmrm/Bc5bEisI/SuykU3qYzKDLQivZy0Q==} + deprecated: 'This package only supports the deprecated Sui JSON-RPC API and will not receive further updates. See the migration guide: https://sdk.mystenlabs.com/sui/migrations/sui-2.0/dapp-kit' peerDependencies: '@mysten/sui': ^2.8.0 '@tanstack/react-query': ^5.0.0 @@ -14267,28 +14274,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': - dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 - jose: 6.2.1 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - supports-color - '@monogrid/gainmap-js@3.4.0(three@0.183.2)': dependencies: promise-worker-transferable: 1.0.4 @@ -14422,6 +14407,13 @@ snapshots: '@mysten/ledgerjs-hw-app-sui@0.7.1': {} + '@mysten/seal@1.1.1(@mysten/sui@2.20.3(typescript@5.9.3))': + dependencies: + '@mysten/bcs': 2.0.3 + '@mysten/sui': 2.20.3(typescript@5.9.3) + '@noble/curves': 2.0.1 + '@noble/hashes': 2.2.0 + '@mysten/seal@1.1.1(@mysten/sui@2.8.0(typescript@5.9.3))': dependencies: '@mysten/bcs': 2.0.3 @@ -17524,7 +17516,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/expect@4.1.10': dependencies: @@ -19279,7 +19271,7 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) @@ -19302,7 +19294,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -19317,13 +19309,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -19338,7 +19330,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -25251,10 +25243,6 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.1(zod@4.3.6): - dependencies: - zod: 4.3.6 - zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 From 20279829700de19b4d8a1dc1bfbdeba1eeb8a691 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:30:03 +0700 Subject: [PATCH 2/2] fix(mcp): honour --relayer for this process without rewriting credentials The CLI still cloned credentials when --relayer/--dev disagreed with the file, but the clone never reached MemWal.create. Memory tools always dialled credentials.json, so a prod file plus --local still signed against prod. Pass the flag through as an in-memory override, same as the old bridge, and keep the saved URL untouched. --- packages/mcp/README.md | 4 +-- packages/mcp/src/index.ts | 16 ++++++++-- packages/mcp/src/server.ts | 7 +++-- packages/mcp/src/session.ts | 16 +++++++--- packages/mcp/test/sdk-stdio.test.mjs | 46 ++++++++++++++++++++++++++-- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 06b0691a4..37327766e 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -115,8 +115,8 @@ the monorepo). To verify manually: (e.g. `other`) — it should **not** return the fact, proving the per-call value overrode the default. -The injection itself is the pure, exported `applyDefaultNamespace(msg, ns)` -function in `src/bridge.ts` if you want to assert it directly. +The injection itself is the pure, exported `applyDefaultNamespace` in +`src/namespace.ts` if you want to assert it directly. ## Environment Presets diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a36623481..7c345f3c6 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -230,7 +230,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { toolResult(stdout, id, applyPendingLoginSuccess(result.text), result.isError); }); diff --git a/packages/mcp/src/session.ts b/packages/mcp/src/session.ts index 291914689..eb84caf2a 100644 --- a/packages/mcp/src/session.ts +++ b/packages/mcp/src/session.ts @@ -101,17 +101,25 @@ export function dropClient(): void { * Return a live SDK client for the credentials currently on disk, or null * when the file is missing. Recreates the client when the file's key, * account, or relayer URL changes. + * + * `relayerOverride` is the CLI `--relayer` / `--dev` / `--local` URL. It + * applies to THIS process only and is never written back to the file + * (a flag in a pasted config must not retarget the saved seed). */ -export function getClient(): MemoryClient | null { +export function getClient(relayerOverride?: string): MemoryClient | null { const creds = loadCreds(); if (!creds) { dropClient(); return null; } - if (cached && sameCreds(cached.creds, creds)) return cached.client; + const effective = + relayerOverride && relayerOverride !== creds.relayerUrl + ? { ...creds, relayerUrl: relayerOverride } + : creds; + if (cached && sameCreds(cached.creds, effective)) return cached.client; dropClient(); - const client = factory(creds); - cached = { creds, client }; + const client = factory(effective); + cached = { creds: effective, client }; return client; } diff --git a/packages/mcp/test/sdk-stdio.test.mjs b/packages/mcp/test/sdk-stdio.test.mjs index 6bf741cd5..efd615242 100644 --- a/packages/mcp/test/sdk-stdio.test.mjs +++ b/packages/mcp/test/sdk-stdio.test.mjs @@ -5,7 +5,7 @@ * and logout dropping the in-process client (GH #616). */ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; @@ -30,7 +30,7 @@ function makeCreds(relayerUrl) { }; } -function startSession({ credsDir, createClient, namespace } = {}) { +function startSession({ credsDir, createClient, namespace, relayerOverride } = {}) { const stdin = new PassThrough(); const stdout = new PassThrough(); stdout.setEncoding("utf8"); @@ -93,6 +93,7 @@ function startSession({ credsDir, createClient, namespace } = {}) { webUrl: "http://127.0.0.1:9", label: "Test", namespace, + relayerOverride, }, { stdin, stdout }, ); @@ -294,4 +295,45 @@ test("logout drops the in-process client so a later recall never reaches the SDK rmSync(dir, { recursive: true, force: true }); } }); + +test("--relayer override is used for this process and not written to the file", async () => { + const dir = mkdtempSync(join(tmpdir(), "memwal-sdk-override-")); + const saved = "https://relayer.memory.walrus.xyz"; + const override = "http://127.0.0.1:8000"; + writeFileSync(join(dir, "credentials.json"), JSON.stringify(makeCreds(saved))); + const seen = []; + const stub = stubClient(); + const session = startSession({ + credsDir: dir, + relayerOverride: override, + createClient: (creds) => { + seen.push(creds.relayerUrl); + return stub; + }, + }); + try { + session.send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {} }, + }); + await session.waitId(1); + session.send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "memwal_health", arguments: {} }, + }); + const health = await session.waitId(2); + assert.equal(health.result.isError, false); + assert.deepEqual(seen, [override]); + assert.match(health.result.content[0].text, new RegExp(`relayer=${override}`)); + const onDisk = JSON.parse(readFileSync(join(dir, "credentials.json"), "utf8")); + assert.equal(onDisk.relayerUrl, saved); + } finally { + await session.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); });