diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index fa71a75b..920ec448 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -45,6 +45,11 @@ 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. + - name: Test MCP package + run: pnpm --filter @mysten-incubation/memwal-mcp test + # ── main branch → changeset version + stable release (latest) ── - name: Apply changesets (update version & CHANGELOG) if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c881cd6a..a210204d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,34 @@ jobs: - name: Check compatibility contract run: node scripts/check-compatibility-contract.mjs + mcp-tests: + name: MCP / Integration (login handoff) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + # Builds the package (tsc) then runs the node:test integration suite: + # spawns the real stdio MCP with empty creds, writes credentials + # mid-process, and asserts memwal_recall is served against a mock relayer + # without a restart (auth-required -> bridge hot-handoff). + - name: MCP integration tests + run: pnpm --filter @mysten-incubation/memwal-mcp test + chatbot-e2e: name: Chatbot / Playwright E2E runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3149a573..4f07b2f8 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,7 @@ data they can trust. ## For AI Agents - **Single-file guide**: Read [`SKILL.md`](SKILL.md) for a complete integration reference (install, configure, API surface, troubleshooting) -- **LLM-friendly docs**: [`llms.txt`](https://docs.wal.app/walrus-memory/llms.txt) — structured overview following the [llmstxt.org](https://llmstxt.org) standard -- **Full context**: [`llms-full.txt`](https://docs.wal.app/walrus-memory/llms-full.txt) — expanded version with inlined page content +- **LLM-friendly docs**: [`llms.txt`](https://docs.wal.app/llms.txt) — structured overview following the [llmstxt.org](https://llmstxt.org) standard ## Install diff --git a/apps/app/public/skills/setup b/apps/app/public/skills/setup index 69d8d56a..9ea9d19d 100644 --- a/apps/app/public/skills/setup +++ b/apps/app/public/skills/setup @@ -1,146 +1,100 @@ ---- -name: setup -description: >- - Set up Walrus Memory for normal users in AI clients such as Claude Desktop, - Cursor, Codex, Claude Code, ChatGPT, Antigravity, or another agent interface. - Covers local MCP clients, remote MCP clients, wallet sign-in, restart, and - verification. -license: Apache-2.0 -metadata: - author: Walrus Memory - version: "1.0" - requires: Node.js 20+ for local stdio clients; a Sui-compatible wallet ---- - -# Walrus Memory - One-Prompt Setup - -## Purpose - -Set up Walrus Memory for a normal user inside their AI client. This flow is -for Claude Desktop, Cursor, Codex, Claude Code, ChatGPT, Antigravity, and other -agent interfaces that can use MCP or remote MCP-style tools. - -When invoked through a one-click install prompt: +# Walrus Memory MCP Setup -```text -Run `curl -sL https://memory.walrus.xyz/skills/setup` and use the -returned instructions to connect Walrus Memory to this AI client. -``` - -Fetch this file, choose the right setup mode for the user's AI client, configure -Walrus Memory, sign in with their Sui wallet, restart the client when needed, -and verify memory works. - -## Scope - -Do this: +This page is meant to be read by an AI client. -- connect Walrus Memory MCP to the user's AI client -- help the user sign in through `memwal_login` or the MCP package login command -- verify `memwal_remember` and `memwal_recall` +If you are reading it yourself, paste this into your AI client instead: -Do not do this in this flow: - -- configure MCP only because a code project exists -- install the Walrus Memory SDK or edit app source code +```text +Run `curl -sL https://memory.walrus.xyz/skills/setup` and use the returned +instructions to connect Walrus Memory to this AI client. +``` -If the user explicitly asks for SDK or app-code integration, stop and say this -one-prompt setup is for using Walrus Memory inside an AI client. Point them to -the SDK docs or ask whether they want a separate developer integration. +The AI client should either configure Walrus Memory automatically, or give you +the exact config and terminal commands to run. -## Rules +## Agent Goal -1. Treat MCP client setup as the primary goal. -2. Ask which AI client to configure if the user did not name one. -3. Detect whether this AI client can execute local shell commands and edit - local user config files on the user's machine. -4. If this AI client cannot access the user's local terminal or filesystem - (common in Claude Desktop and other desktop chats), do not pretend setup is - automatic. Give the exact command/config for the user to run or paste - manually, then wait for them to report the result. -5. If local shell/file access is available, show the command or config file - before changing user-level MCP config. -6. Merge config into existing `mcpServers`; do not remove other MCP servers. -7. Never commit or print real delegate private keys from - `~/.memwal/credentials.json`. -8. Prefer local stdio MCP for clients that support `command + args`. -9. Use remote Streamable HTTP only when the client clearly supports remote MCP - servers with custom headers. -10. ChatGPT custom MCP apps use a remote MCP flow. Do not invent a local `npx` - ChatGPT Desktop config. -11. After config changes, tell the user to fully quit and reopen the AI client. -12. Stop at the first failure and report the exact blocker. +Connect Walrus Memory MCP to the user's AI client, sign the user in, and verify +that memory tools work. -## Execution modes +Do not install the Walrus Memory SDK or edit application source code unless the +user explicitly asks for developer integration. -### Guided mode +## Setup Rules -Use guided mode when the AI client cannot directly run terminal commands or -write local files on the user's machine. This is common for Claude Desktop, -ChatGPT, and other desktop/web chat clients. +1. First decide whether you have local shell and filesystem access. +2. If you can edit local user config files, do the setup yourself after showing + the config you will add. +3. If you cannot edit local files, give the user exact commands/config to paste + and wait for their result. +4. Preserve existing MCP servers. Merge the `memwal` server; do not replace the + whole config. +5. Never print or expose values from `~/.memwal/credentials.json`. +6. Tell the user to fully quit and reopen the AI client after config changes. +7. Stop at the first real blocker and report the exact failure. +8. Keep the final status short. Do not repeat full config blocks or terminal + commands after setup succeeds unless the user must copy/paste them manually. -In guided mode: +## Requirements -- tell the user which config file to open -- show the exact JSON/TOML block to paste -- show the exact terminal command to run, if needed -- ask the user to confirm what happened before continuing -- do not say that you installed or configured anything yourself +For local MCP clients, check Node.js: -### Agentic mode +```bash +node -v +``` -Use agentic mode only when this session has explicit local shell and filesystem -access on the user's machine. +Walrus Memory MCP requires Node.js 20 or newer. If Node is missing or older +than 20, ask the user to install Node.js 20+ from https://nodejs.org/ and stop. -In agentic mode: +The user signs in through the browser with Google/zkLogin or a Sui-compatible +wallet. The sign-in flow writes persistent credentials to: -- inspect existing config before editing it -- preserve other MCP servers -- show a short summary of the edit -- still ask the user to restart the AI client manually +```text +~/.memwal/credentials.json +``` -## Step 1 - Check Node.js +Those credentials are what survive MCP server restarts. -First decide whether you are in guided mode or agentic mode. +## Recommended Login -For local stdio clients, Node.js must be checked on the user's machine. In -guided mode, ask the user to run: +When you can run a real terminal command, prefer this first. Run it from the +user's home directory when possible, especially if the current project is a +Node monorepo: ```bash -node -v +npx -y @mysten-incubation/memwal-mcp login --prod ``` -In agentic mode, run it yourself. Walrus Memory MCP requires Node.js 20 or -newer. If Node.js is missing or older than 20, tell the user to install Node.js -20+ from https://nodejs.org/ and stop. - -The user also needs a Sui-compatible wallet available in the browser for login. - -## Step 2 - Pick the client +This opens the browser login flow and saves `~/.memwal/credentials.json`. -If the user did not name a client, ask: +If terminal login is not possible yet, configure the MCP server first, restart +the AI client, then ask the client to run: ```text -Which AI client should I connect to Walrus Memory: Claude Desktop, Cursor, -Codex, Claude Code, ChatGPT, or another MCP client? +Use memwal_login to sign me in to Walrus Memory. ``` -Use local stdio config for: +If `memwal_login` gives a link, show the link to the user and tell them to sign +in with Google/zkLogin or a Sui-compatible wallet. After the browser shows +Connected, retry the original memory action. + +Do not call `memwal_login` again just because a later login URL uses a new +localhost port. A new callback port is normal for each login attempt. What +matters is whether `~/.memwal/credentials.json` was written. -- Claude Desktop -- Cursor -- Codex -- Claude Code -- Antigravity or another local-command MCP host +If the user says the browser shows Connected but memory tools still act +unsigned-in, use the terminal login fallback: -Use remote MCP guidance for: +```bash +npx -y @mysten-incubation/memwal-mcp login --prod +``` -- ChatGPT custom MCP apps -- clients that do not support local `command + args` +Then retry the memory tool. If the client still does not see credentials, fully +quit and reopen the AI client. -## Step 3 - Configure local stdio MCP +## Local MCP Server -Use this MCP server command: +Use this stdio MCP command for local MCP clients: ```bash npx -y @mysten-incubation/memwal-mcp @@ -183,12 +137,22 @@ Merge: ### Codex -Edit `~/.codex/config.toml` and append: +Edit `~/.codex/config.toml` and merge: + +```toml +[mcp_servers.memwal] +command = "npx" +args = ["-y", "@mysten-incubation/memwal-mcp"] +``` + +If Codex starts MCP servers from a project directory and `npx` resolves the +wrong package, set the server working directory to the user's home directory: ```toml [mcp_servers.memwal] command = "npx" args = ["-y", "@mysten-incubation/memwal-mcp"] +cwd = "/Users/YOUR_USERNAME" ``` ### Claude Code @@ -205,42 +169,21 @@ Verify: claude mcp list ``` -### Other local MCP clients +### Other Local MCP Clients -Add: +Add an MCP server with: - name: `memwal` - command: `npx` - args: `["-y", "@mysten-incubation/memwal-mcp"]` -## Step 4 - Sign in +## Restart -Prefer inline login after the client is configured and restarted: +Fully quit and reopen the AI client after editing MCP config. On macOS, use +`Cmd+Q`; closing the window is not enough. The first launch may take 5-10 +seconds while `npx` fetches the package. -```text -Use memwal_login to sign me in to Walrus Memory. -``` - -The tool opens a browser flow. The user connects their Sui wallet and approves -the delegate key. Credentials are saved locally at: - -```text -~/.memwal/credentials.json -``` - -If inline login is not available yet, run manual login in a real terminal: - -```bash -npx -y @mysten-incubation/memwal-mcp login --prod -``` - -## Step 5 - Restart the client - -Fully quit and reopen the AI client. On macOS, use `Cmd+Q`; closing the window -is not enough. The first start may take 5-10 seconds while `npx` fetches the -package. - -## Step 6 - Verify tools +## Verify Tools Ask the AI client: @@ -257,55 +200,35 @@ Expected Walrus Memory tools: - `memwal_login` - `memwal_logout` -If only `memwal_login` works, credentials are missing. Run `memwal_login` or -the manual login command again. - -## Step 7 - Verify memory - -Ask: +If only `memwal_login` works, credentials are missing or stale. Run +`memwal_login`, or run the terminal login command again: -```text -Use memwal_remember to save: "My favorite programming language is Rust and I drink black coffee in the mornings." -``` - -Wait a few seconds, then ask: - -```text -Use memwal_recall to search for: "what is my favorite language?" +```bash +npx -y @mysten-incubation/memwal-mcp login --prod ``` -The client should retrieve the saved Rust memory. - -## ChatGPT - -ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. +## Verify Memory -If the user's ChatGPT account supports custom MCP apps / developer mode: - -1. Confirm they can create a custom MCP app in ChatGPT settings. -2. Use this remote MCP endpoint: +Use a neutral setup check rather than saving a fake personal preference: ```text -https://relayer.memory.walrus.xyz/api/mcp +Use memwal_remember to save: "Walrus Memory setup verification succeeded." ``` -3. Authenticate with headers: +Wait a few seconds, then ask: ```text -Authorization: Bearer -x-memwal-account-id: +Use memwal_recall to search for: "setup verification succeeded" ``` -The values come from `~/.memwal/credentials.json` after a successful -`memwal-mcp login --prod`. +If the tools support a `namespace` argument, use a setup-only namespace such as +`setup-verification`. -If ChatGPT cannot attach the required headers or the user's plan/workspace does -not allow custom MCP apps, explain the blocker and offer Claude Desktop, -Cursor, Codex, or Claude Code as the local MCP path. +## ChatGPT and Remote MCP -## Remote MCP / Streamable HTTP +ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. -Use this only when a client supports remote MCP servers with custom headers: +Use remote MCP only when the client supports custom headers: ```json { @@ -321,23 +244,46 @@ Use this only when a client supports remote MCP servers with custom headers: } ``` -Treat the bearer token like an API key. Do not save it in a repo file. +The values come from `~/.memwal/credentials.json` after login. Treat the bearer +token like an API key. Do not save it in a repo file or print it in chat. + +If the client cannot attach required headers, explain the blocker and suggest a +local MCP client such as Cursor, Claude Desktop, Claude Code, or Codex. ## Troubleshooting | Symptom | Fix | | --- | --- | | `node: command not found` | Install Node.js 20+ from https://nodejs.org/. | -| `npx` fails | Confirm Node/npm is installed and internet access works. | +| `npx` fails | Confirm Node/npm and internet access. If inside a Node monorepo, run from the home directory or set MCP `cwd` to the home directory. | | No Walrus Memory tools after restart | Check the MCP config path and fully restart the client. | | Only `memwal_login` works | Credentials are missing. Run `memwal_login` or `npx -y @mysten-incubation/memwal-mcp login --prod`. | | Memory tools return 401 | The delegate key may be stale or revoked. Run `npx -y @mysten-incubation/memwal-mcp login --prod` again. | -| User wants to sign out | Run `npx -y @mysten-incubation/memwal-mcp --logout`. This removes local credentials but does not revoke the on-chain delegate key. | +| Sign out | Run `npx -y @mysten-incubation/memwal-mcp --logout`. This removes local credentials but does not revoke the delegate key. | + +## Final Report + +Keep the final response short and make the restart the first thing the user +sees whenever a restart is required. + +If setup changed local MCP config successfully, use this shape: + +```text +Walrus Memory is configured. + +Next: fully quit and reopen now. On macOS, use Cmd+Q; closing the +window is not enough. + +Login: succeeded. +Config: . +After reopening, ask: "What MCP tools do you have available?" +``` -Finish by reporting: +Rules for the final response: -- AI client configured -- config file changed or command run -- whether login succeeded -- whether `memwal_remember` / `memwal_recall` passed -- any restart or follow-up the user still needs +- Put the restart instruction before verification details. +- Keep it under 8 lines if there is no blocker. +- Do not include the full TOML/JSON config again after it has already been + applied. +- Do not include long verification prompts before the restart. After restart, + the simplest check is: `What MCP tools do you have available?` diff --git a/docs/mcp/changelog.mdx b/docs/mcp/changelog.mdx index 10e08794..4694b822 100644 --- a/docs/mcp/changelog.mdx +++ b/docs/mcp/changelog.mdx @@ -3,6 +3,13 @@ title: "Changelog" description: "Release history for the Walrus Memory MCP package." --- +## 0.0.4 + +### Fixed + +- Accept HTTPS dashboard sign-in callbacks to the local `127.0.0.1` MCP listener. +- Reload credentials after `memwal_login` so memory tools work without restarting the MCP client. + ## 0.0.3 ### Changed diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index aa3c3c01..42efb750 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @mysten-incubation/memwal-mcp +## 0.0.4 + +### Fixed + +- Accept HTTPS dashboard sign-in callbacks to the local `127.0.0.1` MCP listener. +- Reload credentials after `memwal_login` so memory tools work without restarting the MCP client. + ## 0.0.3 ### Changed diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e5decc8d..4132bbf2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mysten-incubation/memwal-mcp", - "version": "0.0.3", + "version": "0.0.4", "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.", "type": "module", "engines": { @@ -21,6 +21,7 @@ "build": "tsc", "dev": "tsc --watch", "typecheck": "tsc --noEmit", + "test": "tsc && node --test \"test/**/*.test.mjs\"", "start": "node ./dist/bin/memwal-mcp.js" }, "dependencies": { diff --git a/packages/mcp/src/auth-required.ts b/packages/mcp/src/auth-required.ts index e1d935bc..64bdbaca 100644 --- a/packages/mcp/src/auth-required.ts +++ b/packages/mcp/src/auth-required.ts @@ -21,6 +21,7 @@ * MCP spec 2025-06 — see ENG-1750. The two paths cover different surfaces * and coexist. */ +import { loadCreds, type MemWalCredentials } from "./auth.js"; import { log } from "./logger.js"; import { loginFlow } from "./login.js"; @@ -133,24 +134,6 @@ function writeStdoutMessage(msg: RpcMessage): void { process.stdout.write(JSON.stringify(msg) + "\n"); } -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()); - }); -} - /** 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 @@ -302,107 +285,208 @@ async function handleLoginToolCall( }; } +/** 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") { + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "memwal", version: "0.0.1" }, + }, + }); + 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: 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) return { creds }; + + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: 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. Returns when stdin closes. + * 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. + * `memwal_login` would fall back to prod defaults and open the wrong dashboard. */ -export async function runAuthRequiredServer(config: AuthRequiredConfig): Promise { +export async function runAuthRequiredServer( + config: AuthRequiredConfig, +): Promise { log.info("auth_required_server.started", { webUrl: config.webUrl, relayerUrl: config.relayerUrl, }); - await readStdinLines((line) => { - let req: RpcMessage; - try { - req = JSON.parse(line) as RpcMessage; - } catch { - return; - } + return await new Promise((resolve) => { + let buf = ""; + let settled = false; - // Notifications don't need a response. - if (req.id == null && typeof req.method === "string") { - return; - } + const detach = (): void => { + process.stdin.removeListener("data", onData); + process.stdin.removeListener("end", onEnd); + process.stdin.removeListener("close", onEnd); + }; - const id = req.id ?? null; - const method = req.method; - - if (method === "initialize") { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - }, - }); - return; - } + 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; - if (method === "tools/list") { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { tools: TOOL_DEFINITIONS }, - }); - return; - } + 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(); - 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, - }, - }); + 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; } + }; - // Any other tool — fall through to the generic LOGIN_INSTRUCTION - // error so the agent knows it must call memwal_login first. - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - content: [{ type: "text", text: LOGIN_INSTRUCTION }], - isError: true, - }, - }); - return; - } + const onEnd = (): void => { + if (settled) return; + settled = true; + detach(); + log.info("auth_required_server.closed", {}); + resolve(undefined); + }; - // Anything else — return Method not found per JSON-RPC. - writeStdoutMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Method not found: ${method ?? "(missing)"}`, - }, - }); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", onData); + process.stdin.on("end", onEnd); + process.stdin.on("close", onEnd); }); - - log.info("auth_required_server.closed", {}); } diff --git a/packages/mcp/src/bridge.ts b/packages/mcp/src/bridge.ts index 2c6be5cd..c4275ec0 100644 --- a/packages/mcp/src/bridge.ts +++ b/packages/mcp/src/bridge.ts @@ -438,7 +438,16 @@ function handleLocalLogout(): { text: string; isError: boolean } { * 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(creds: MemWalCredentials, config: BridgeConfig): Promise { +export async function runBridge( + creds: 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 ${creds.relayerUrl}...`); log.info("bridge.connecting", { relayer: creds.relayerUrl, @@ -561,7 +570,7 @@ export async function runBridge(creds: MemWalCredentials, config: BridgeConfig): // 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 clientPump = readStdinLines((line) => { + const handleClientLine = (line: string): void => { void (async () => { try { const msg = JSON.parse(line) as RpcMessage; @@ -630,7 +639,18 @@ export async function runBridge(creds: MemWalCredentials, config: BridgeConfig): log.warn("bridge.stdin_parse_failed", { line: line.slice(0, 120) }); } })(); - }).then(() => { + }; + + // Replay anything the auth-required server handed off (the tool call that + // triggered the hot-handoff, plus anything buffered behind it) now that the + // SSE stream is connected. Without this the triggering request is dropped in + // the mode switch and the user has to retry / restart. + if (pendingLines.length > 0) { + log.info("bridge.replaying_handoff", { count: pendingLines.length }); + for (const line of pendingLines) handleClientLine(line); + } + + const clientPump = readStdinLines(handleClientLine).then(() => { stdinClosed = true; sse.abort(); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 74fc2e99..ca50aef8 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -177,7 +177,22 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { + 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(); + 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 }, + 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 }); + }); + + // 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"); + + // 2. recall before login → not-signed-in instruction. + send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "montreal" } }, + }); + const before = await waitFor((m) => m.id === 2); + assert.equal(before.result.isError, true, "should be an error before login"); + assert.match( + JSON.stringify(before.result), + /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/services/server/scripts/__tests__/enoki-retry.test.ts b/services/server/scripts/__tests__/enoki-retry.test.ts index 1417bd0f..988656b8 100644 --- a/services/server/scripts/__tests__/enoki-retry.test.ts +++ b/services/server/scripts/__tests__/enoki-retry.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getEnokiRetryDelayMs, + isSponsoredTransactionInvalidatedMessage, isTransientEnokiStatus, parseRetryAfterMs, parseTryAgainBodyDelayMs, @@ -74,3 +75,24 @@ test("retries transport errors with exponential fallback", () => { transportError: true, }), 5_000); }); + +test("detects invalidated sponsored transaction responses", () => { + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (400): {"errors":[{"code":"expired","message":"Sponsored transaction has expired"}]}', + ), + true, + ); + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (404): {"errors":[{"code":"not_found","message":"Sponsored transaction not found"}]}', + ), + true, + ); + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (400): {"errors":[{"code":"dry_run_failed"}]}', + ), + false, + ); +}); diff --git a/services/server/scripts/enoki-retry.ts b/services/server/scripts/enoki-retry.ts index 9ab55dff..61c5f6ba 100644 --- a/services/server/scripts/enoki-retry.ts +++ b/services/server/scripts/enoki-retry.ts @@ -47,6 +47,14 @@ export function parseTryAgainBodyDelayMs(body: string | undefined): number | nul return Math.round(amount * multiplier); } +export function isSponsoredTransactionInvalidatedMessage(message: string | undefined): boolean { + if (!message) return false; + return /sponsored transaction has expired/i.test(message) + || /"code"\s*:\s*"expired"/i.test(message) + || /sponsored transaction not found/i.test(message) + || /"code"\s*:\s*"not_found"/i.test(message); +} + export function getEnokiRetryDelayMs(input: EnokiRetryInput): number | null { if (input.attempt >= input.maxAttempts) return null; diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index 9d84dcba..00f75ba3 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -21,7 +21,7 @@ import { SealClient, SessionKey, EncryptedObject } from "@mysten/seal"; import { WalrusClient } from "@mysten/walrus"; import { mountMcpRoutes, shutdownMcpSessions } from "./mcp/index.js"; import { getSealServerConfigsFromEnv, getSealThresholdFromEnv } from "./seal-config.js"; -import { getEnokiRetryDelayMs } from "./enoki-retry.js"; +import { getEnokiRetryDelayMs, isSponsoredTransactionInvalidatedMessage } from "./enoki-retry.js"; import { isWalrusBlobObjectMissingFromEffects, isWalrusObjectLockEquivocation, @@ -80,13 +80,33 @@ const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( : "https://upload-relay.mainnet.walrus.space" ); -const MAX_WALRUS_EPOCHS = 5; +const MAX_WALRUS_EPOCHS = 15; +const DEFAULT_TESTNET_WALRUS_EPOCHS = 5; +const NETWORK_DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "mainnet" ? 3 : DEFAULT_TESTNET_WALRUS_EPOCHS; const DEFAULT_WALRUS_EPOCHS = (() => { - const parsed = Number.parseInt(process.env.WALRUS_STORAGE_EPOCHS || "", 10); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.min(parsed, MAX_WALRUS_EPOCHS); + const raw = process.env.WALRUS_STORAGE_EPOCHS?.trim(); + if (!raw) { + return NETWORK_DEFAULT_WALRUS_EPOCHS; } - return SUI_NETWORK === "mainnet" ? 3 : MAX_WALRUS_EPOCHS; + + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_WALRUS_EPOCHS) { + return parsed; + } + + if (Number.isFinite(parsed) && parsed > MAX_WALRUS_EPOCHS) { + console.warn( + `[sidecar] WALRUS_STORAGE_EPOCHS=${raw} exceeds max ${MAX_WALRUS_EPOCHS}; ` + + `using network default ${NETWORK_DEFAULT_WALRUS_EPOCHS}`, + ); + } else { + console.warn( + `[sidecar] ignoring invalid WALRUS_STORAGE_EPOCHS=${raw}; ` + + `using network default ${NETWORK_DEFAULT_WALRUS_EPOCHS}`, + ); + } + + return NETWORK_DEFAULT_WALRUS_EPOCHS; })(); const SUI_RPC_URL = getJsonRpcFullnodeUrl(SUI_NETWORK); @@ -316,7 +336,7 @@ const WALRUS_UPLOAD_ACQUIRE_TIMEOUT_MS = parsePositiveIntEnv( 1_000, 180_000, ); -const WALRUS_UPLOAD_EFFECTS_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000] as const; +const WALRUS_UPLOAD_EFFECTS_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000, 40_000] as const; const walrusUploadGlobalLimiter = new AsyncSemaphore(WALRUS_UPLOAD_MAX_CONCURRENCY); const walrusUploadWalletLimiters = new Map(); @@ -626,7 +646,7 @@ async function callEnoki(path: string, payload: unknown): Promise { } for (let attempt = 1; ; attempt += 1) { - let resp: Response; + let resp: globalThis.Response; try { resp = await fetch(`${ENOKI_API_BASE_URL}${path}`, { method: "POST", @@ -689,12 +709,6 @@ async function callEnoki(path: string, payload: unknown): Promise { } } -function isSponsoredTransactionExpired(err: unknown): boolean { - const msg = errorMessage(err); - return /sponsored transaction has expired/i.test(msg) - || /"code"\s*:\s*"expired"/i.test(msg); -} - async function executeSponsoredTransactionOnce( tx: Transaction, signer: Ed25519Keypair, @@ -747,8 +761,8 @@ async function executeWithEnokiSponsor(tx: Transaction, signer: Ed25519Keypair, try { return await executeSponsoredTransactionOnce(tx, signer, allowedAddresses); } catch (err: any) { - if (isSponsoredTransactionExpired(err)) { - console.warn(`[enoki-sponsor] sponsored tx expired; retrying sponsor/execute once: ${err?.message || err}`); + if (isSponsoredTransactionInvalidatedMessage(errorMessage(err))) { + console.warn(`[enoki-sponsor] sponsored tx invalidated; retrying sponsor/execute once: ${err?.message || err}`); try { return await executeSponsoredTransactionOnce(tx, signer, allowedAddresses); } catch (retryErr: any) { diff --git a/services/server/src/storage/walrus.rs b/services/server/src/storage/walrus.rs index 5b977f74..6b9bedb1 100644 --- a/services/server/src/storage/walrus.rs +++ b/services/server/src/storage/walrus.rs @@ -3,7 +3,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use futures::stream::{FuturesUnordered, StreamExt}; use std::time::Duration; -const SIDECAR_WALRUS_TIMEOUT: Duration = Duration::from_secs(180); +const SIDECAR_WALRUS_TIMEOUT: Duration = Duration::from_secs(300); const WALRUS_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15); /// Result of a Walrus blob upload diff --git a/services/server/src/types.rs b/services/server/src/types.rs index fd56c2d6..6b181f8c 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -27,13 +27,14 @@ pub const DEFAULT_BLOB_CACHE_MAX_BYTES: usize = 512 * 1024; /// Default max age for Redis-cached recall query embeddings. pub const DEFAULT_EMBEDDING_CACHE_TTL_SECS: u64 = 10 * 60; -/// Sidecar caps Walrus storage purchases to avoid accidental large spends. -pub const MAX_WALRUS_STORAGE_EPOCHS: u32 = 5; +/// Upper bound for explicit Walrus storage purchases. +pub const MAX_WALRUS_STORAGE_EPOCHS: u32 = 15; +pub const DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS: u32 = 5; pub(crate) fn default_walrus_storage_epochs_for_network(network: &str) -> u32 { match network { "mainnet" => 3, - _ => MAX_WALRUS_STORAGE_EPOCHS, + _ => DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS, } } @@ -43,7 +44,17 @@ pub(crate) fn configured_walrus_storage_epochs(network: &str) -> u32 { .ok() .and_then(|raw| raw.trim().parse::().ok()) { - Some(epochs) if epochs > 0 => epochs.min(MAX_WALRUS_STORAGE_EPOCHS), + Some(epochs) if epochs > 0 && epochs <= MAX_WALRUS_STORAGE_EPOCHS => epochs, + Some(epochs) if epochs > MAX_WALRUS_STORAGE_EPOCHS => { + tracing::warn!( + "WALRUS_STORAGE_EPOCHS={} exceeds max {}; using default {} for {}", + epochs, + MAX_WALRUS_STORAGE_EPOCHS, + default, + network, + ); + default + } _ => default, } } @@ -1104,6 +1115,28 @@ pub struct SidecarError { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static WALRUS_STORAGE_EPOCHS_ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_walrus_storage_epochs_env(value: Option<&str>, test: impl FnOnce() -> R) -> R { + let _guard = WALRUS_STORAGE_EPOCHS_ENV_LOCK.lock().unwrap(); + let previous = std::env::var("WALRUS_STORAGE_EPOCHS").ok(); + + match value { + Some(value) => std::env::set_var("WALRUS_STORAGE_EPOCHS", value), + None => std::env::remove_var("WALRUS_STORAGE_EPOCHS"), + } + + let result = test(); + + match previous { + Some(value) => std::env::set_var("WALRUS_STORAGE_EPOCHS", value), + None => std::env::remove_var("WALRUS_STORAGE_EPOCHS"), + } + + result + } // ── AuthInfo Debug redacts delegate_key ─────────────────────── @@ -1342,10 +1375,33 @@ mod tests { assert_eq!(default_walrus_storage_epochs_for_network("mainnet"), 3); assert_eq!( default_walrus_storage_epochs_for_network("testnet"), - MAX_WALRUS_STORAGE_EPOCHS + DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS ); } + #[test] + fn configured_walrus_storage_epochs_uses_valid_env_value() { + with_walrus_storage_epochs_env(Some("4"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 4); + }); + } + + #[test] + fn configured_walrus_storage_epochs_honors_explicit_fifteen() { + with_walrus_storage_epochs_env(Some("15"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 15); + assert_eq!(configured_walrus_storage_epochs("testnet"), 15); + }); + } + + #[test] + fn configured_walrus_storage_epochs_falls_back_when_env_exceeds_cap() { + with_walrus_storage_epochs_env(Some("16"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 3); + assert_eq!(configured_walrus_storage_epochs("testnet"), 5); + }); + } + // ── AppError Display implementations ──────────────────────────────── #[test]