diff --git a/apps/app/public/skills/mcp-setup b/apps/app/public/skills/mcp-setup new file mode 100644 index 00000000..395d6f4a --- /dev/null +++ b/apps/app/public/skills/mcp-setup @@ -0,0 +1,341 @@ +--- +name: mcp-setup +description: >- + Set up Walrus Memory in MCP-aware AI clients such as Claude Desktop, + Cursor, Codex, Claude Code, ChatGPT, Antigravity, or another MCP host. + This is for normal users who want portable memory in their AI client. +license: Apache-2.0 +metadata: + author: Walrus Memory + version: "1.0" + requires: Node.js 20+ for local stdio MCP clients; a Sui-compatible wallet +--- + +# Walrus Memory MCP - 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 +MCP-aware clients. + +When invoked through the dashboard prompt: + +```text +Run `curl -sL https://memory.walrus.xyz/skills/mcp-setup` and use the +returned instructions to connect Walrus Memory to this AI client. +``` + +fetch this file, configure the user's AI client for Walrus Memory MCP, sign in +with their Sui wallet, restart the client, and verify memory works. + +## Scope + +Do this: + +- 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` + +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 + +If the user explicitly asks for SDK or app-code integration, stop and say this +one-prompt setup is for MCP clients. Point them to the SDK docs or ask whether +they want a separate developer integration. + +## Rules + +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. + +## Execution modes + +### Guided mode + +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. + +In guided mode: + +- 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 + +### Agentic mode + +Use agentic mode only when this session has explicit local shell and filesystem +access on the user's machine. + +In agentic mode: + +- 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 + +## Step 1 - Check Node.js + +First decide whether you are in guided mode or agentic mode. + +For local stdio clients, Node.js must be checked on the user's machine. In +guided mode, ask the user to run: + +```bash +node -v +``` + +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 + +If the user did not name a client, ask: + +```text +Which AI client should I connect to Walrus Memory: Claude Desktop, Cursor, +Codex, Claude Code, ChatGPT, or another MCP client? +``` + +Use local stdio config for: + +- Claude Desktop +- Cursor +- Codex +- Claude Code +- Antigravity or another local-command MCP host + +Use remote MCP guidance for: + +- ChatGPT custom MCP apps +- clients that do not support local `command + args` + +## Step 3 - Configure local stdio MCP + +Use this MCP server command: + +```bash +npx -y @mysten-incubation/memwal-mcp +``` + +### Cursor + +Edit `~/.cursor/mcp.json` and merge: + +```json +{ + "mcpServers": { + "memwal": { + "command": "npx", + "args": ["-y", "@mysten-incubation/memwal-mcp"] + } + } +} +``` + +### Claude Desktop + +Edit: + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +Merge: + +```json +{ + "mcpServers": { + "memwal": { + "command": "npx", + "args": ["-y", "@mysten-incubation/memwal-mcp"] + } + } +} +``` + +### Codex + +Edit `~/.codex/config.toml` and append: + +```toml +[mcp_servers.memwal] +command = "npx" +args = ["-y", "@mysten-incubation/memwal-mcp"] +``` + +### Claude Code + +Run: + +```bash +claude mcp add --scope user memwal -- npx -y @mysten-incubation/memwal-mcp +``` + +Verify: + +```bash +claude mcp list +``` + +### Other local MCP clients + +Add: + +- name: `memwal` +- command: `npx` +- args: `["-y", "@mysten-incubation/memwal-mcp"]` + +## Step 4 - Sign in + +Prefer inline login after the client is configured and restarted: + +```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 + +Ask the AI client: + +```text +What MCP tools do you have available? +``` + +Expected Walrus Memory tools: + +- `memwal_remember` +- `memwal_recall` +- `memwal_analyze` +- `memwal_restore` +- `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: + +```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?" +``` + +The client should retrieve the saved Rust memory. + +## ChatGPT + +ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. + +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: + +```text +https://relayer.memory.walrus.xyz/api/mcp +``` + +3. Authenticate with headers: + +```text +Authorization: Bearer +x-memwal-account-id: +``` + +The values come from `~/.memwal/credentials.json` after a successful +`memwal-mcp login --prod`. + +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. + +## Remote MCP / Streamable HTTP + +Use this only when a client supports remote MCP servers with custom headers: + +```json +{ + "mcpServers": { + "memwal": { + "url": "https://relayer.memory.walrus.xyz/api/mcp", + "headers": { + "Authorization": "Bearer ", + "x-memwal-account-id": "" + } + } + } +} +``` + +Treat the bearer token like an API key. Do not save it in a repo file. + +## 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. | +| 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. | + +Finish by reporting: + +- 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 diff --git a/apps/app/public/skills/memwal-install b/apps/app/public/skills/memwal-install new file mode 100644 index 00000000..fefbeb6c --- /dev/null +++ b/apps/app/public/skills/memwal-install @@ -0,0 +1,16 @@ +--- +name: memwal-install +description: Legacy alias for the Walrus Memory MCP setup skill. +license: Apache-2.0 +metadata: + author: Walrus Memory + version: "1.0" +--- + +# Walrus Memory MCP Setup + +This route is a compatibility alias. Use the MCP setup skill instead: + +```text +Run `curl -sL https://memory.walrus.xyz/skills/mcp-setup` and use the returned instructions to connect Walrus Memory to this AI client. +``` diff --git a/apps/app/server.mjs b/apps/app/server.mjs index 30956d8c..2589e0f8 100644 --- a/apps/app/server.mjs +++ b/apps/app/server.mjs @@ -11,6 +11,7 @@ const port = Number(process.env.PORT || 4173) const host = process.env.HOST || '0.0.0.0' const contentTypes = new Map([ + ['', 'text/plain; charset=utf-8'], ['.css', 'text/css; charset=utf-8'], ['.gif', 'image/gif'], ['.html', 'text/html; charset=utf-8'], diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 8240b039..5e0e0586 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -181,7 +181,18 @@ function RegisterEnokiWallets() { const { unregister } = registerEnokiWallets({ apiKey: config.enokiApiKey, providers: { - google: { clientId: config.googleClientId }, + google: { + clientId: config.googleClientId, + // Pin the Google OAuth redirect_uri to the app origin root — a URL + // already registered for this client (the dashboard sign-in uses it, + // which is why dashboard Google login works). Enoki otherwise defaults + // to window.location.href, so signing in from + // /connect/mcp?...&connectState=... would send a redirect_uri with a + // non-registered path + query → Google rejects it (redirect_uri_mismatch). + // The /connect/mcp params survive the round-trip via sessionStorage + // (ConnectMcp persists them; PostAuthRedirect restores them). WALM-86. + redirectUrl: `${window.location.origin}/`, + }, }, client, network, @@ -205,6 +216,28 @@ function RoutePending() { ) } +/** sessionStorage key holding an in-flight /connect/mcp request, so the flow + * can resume after the Google OAuth redirect bounces through the app root. + * Shared with ConnectMcp.tsx (kept as a literal there to avoid a circular import). */ +const MCP_CONNECT_STORAGE_KEY = 'memwal_mcp_connect' + +/** Lands here after a successful sign-in (the OAuth redirect_uri is the app + * root). If a /connect/mcp flow was interrupted by that redirect, resume it + * by restoring the saved query string; otherwise go to the dashboard. */ +function PostAuthRedirect() { + const pending = sessionStorage.getItem(MCP_CONNECT_STORAGE_KEY) + if (pending) { + // Consume once — prevents a redirect loop on later visits to `/`. + sessionStorage.removeItem(MCP_CONNECT_STORAGE_KEY) + try { + const params = JSON.parse(pending) as Record + const qs = new URLSearchParams(params).toString() + if (qs) return + } catch { /* fall through to dashboard */ } + } + return +} + function AppContent() { const currentAccount = useCurrentAccount() const autoConnectStatus = useAutoConnectWallet() @@ -220,7 +253,7 @@ function AppContent() { : - currentAccount ? : + currentAccount ? : } /> )} /> , 'title'> { /** Extra class(es) appended to the base `card` class (e.g. `dashboard-keys-card`, `demo-step`). */ className?: string style?: CSSProperties @@ -29,6 +29,7 @@ export function Card({ leading, leadingRowClassName, children, + ...props }: CardProps) { const titleGroup = title != null || subtitle != null ? (
@@ -47,7 +48,7 @@ export function Card({ const hasHeader = title != null || subtitle != null || action != null || leading != null return ( -
+
{hasHeader && (
{headerLeft} diff --git a/apps/app/src/components/SecretValueInput.tsx b/apps/app/src/components/SecretValueInput.tsx new file mode 100644 index 00000000..b5425d57 --- /dev/null +++ b/apps/app/src/components/SecretValueInput.tsx @@ -0,0 +1,37 @@ +import type { InputHTMLAttributes } from 'react' + +interface SecretValueInputProps extends Omit, 'children' | 'readOnly' | 'type' | 'value'> { + value: string + masked?: boolean + maskLength?: number +} + +export function SecretValueInput({ + value, + masked = false, + maskLength = 48, + className, + onFocus, + placeholder, + ...props +}: SecretValueInputProps) { + return ( + { + if (!masked) event.currentTarget.select() + onFocus?.(event) + }} + /> + ) +} diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 88eb4455..6cd13e1c 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -1222,6 +1222,33 @@ h1, h2, h3 { user-select: all; } +.secret-value-input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + padding: 0; + color: inherit; + font-family: var(--font-mono); + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: 0; + outline: none; + box-shadow: none; +} + +.secret-value-input::placeholder { + color: inherit; + opacity: 1; +} + +.secret-value-input:focus { + outline: 1px solid rgba(232, 255, 117, 0.55); + outline-offset: 4px; + border-radius: 4px; +} + .key-display .key-actions { display: flex; gap: 8px; diff --git a/apps/app/src/pages/ConnectMcp.tsx b/apps/app/src/pages/ConnectMcp.tsx index 90d70b7f..66152f56 100644 --- a/apps/app/src/pages/ConnectMcp.tsx +++ b/apps/app/src/pages/ConnectMcp.tsx @@ -9,6 +9,7 @@ * &delegateAddress=<0x-prefixed Sui address> * &label= * &relayer= + * &connectState=<64-hex CSRF token> (legacy bridges: `state`) * * Flow: * 1. Render consent screen — show requested permissions + key fingerprint. @@ -104,14 +105,22 @@ export default function ConnectMcp() { const publicKey = params.get('publicKey') ?? '' const delegateAddress = params.get('delegateAddress') ?? '' const label = params.get('label') ?? 'Walrus Memory MCP' - const relayer = params.get('relayer') ?? 'https://relayer.memwal.ai' + const relayer = params.get('relayer') ?? config.memwalServerUrl /** * Cryptographic state token from the MCP bridge. Must be echoed verbatim * in the callback POST — the bridge constant-time compares it to defeat * cross-origin CSRF (audit C2). Empty string if absent (older bridge); * the bridge will then reject our callback with 400. + * + * Read from `connectState` (current bridge) with a fallback to the legacy + * `state` param. The bridge renamed this param away from `state` because + * `state` is a reserved OAuth 2.0 response parameter: when this page starts + * Enoki/Google sign-in it reuses the current URL as the OAuth redirect_uri, + * and Google rejects any redirect_uri carrying a reserved param (WALM-86: + * "Access blocked: invalid_request — Invalid redirect_uri contains reserved + * response param state"). We still echo it back in the POST body as `state`. */ - const state = params.get('state') ?? '' + const state = params.get('connectState') ?? params.get('state') ?? '' const [step, setStep] = useState('consent') const [errorMsg, setErrorMsg] = useState('') @@ -228,6 +237,9 @@ export default function ConnectMcp() { setCallbackPayload(payload) setStep('callback') const delivered = await postCallback(payload) + // Flow done — drop the OAuth-resume breadcrumb so a later visit to + // `/` goes to the dashboard instead of looping back here. + sessionStorage.removeItem('memwal_mcp_connect') setStep('success') trackEvent('mcp_connect_complete', { callback_delivered: delivered }) } catch (err) { @@ -253,6 +265,20 @@ export default function ConnectMcp() { trackEvent('mcp_connect_failed', { error_type: 'invalid_request' }) }, [paramsValid]) + // Persist the connect request so it survives the Google OAuth redirect. + // Enoki's redirect_uri is pinned to the app root (App.tsx), so signing in + // with Google leaves this page and returns to `/` — losing the query + // string. App's PostAuthRedirect reads this back and re-opens + // /connect/mcp with the params restored. Keyed identically to the URL + // params (note `connectState`, not `state`). Cleared on success below. + useEffect(() => { + if (!paramsValid) return + sessionStorage.setItem( + 'memwal_mcp_connect', + JSON.stringify({ port, publicKey, delegateAddress, label, relayer, connectState: state }), + ) + }, [paramsValid, port, publicKey, delegateAddress, label, relayer, state]) + // If the wallet popup completes after we asked it to open, auto-proceed. useEffect(() => { if (!walletPickerOpen && currentAccount && step === 'consent') { diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index 1547b740..f9750a66 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -23,6 +23,7 @@ SyntaxHighlighter.registerLanguage('javascript', js) SyntaxHighlighter.registerLanguage('python', python) import { useDelegateKey } from '../App' import { Card } from '../components/Card' +import { SecretValueInput } from '../components/SecretValueInput' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' import { @@ -122,7 +123,6 @@ interface OnChainDelegateKey { const MAX_DELEGATE_KEYS = 20 const MAX_DELEGATE_KEYS_MESSAGE = 'This wallet already has 20 delegate keys. Remove an old key before creating a new delegate key.' -const SDK_DEFAULT_SERVER_URL = 'https://relayer.memwal.ai' const PRIVATE_KEY_ENV = 'MEMWAL_PRIVATE_KEY' const ACCOUNT_ID_ENV = 'MEMWAL_ACCOUNT_ID' const SERVER_URL_ENV = 'MEMWAL_SERVER_URL' @@ -372,9 +372,6 @@ export default function Dashboard({ const activeEnvironmentLabel = config.suiNetwork === 'mainnet' ? 'production / mainnet' : 'staging / testnet' - const expectedRelayerUrl = config.suiNetwork === 'mainnet' - ? 'https://relayer.memwal.ai' - : 'https://relayer.memwal.ai' const normalizedRelayerUrl = config.memwalServerUrl.toLowerCase() const relayerEnvironmentLabel = normalizedRelayerUrl.startsWith('/') ? 'local dev proxy / testnet' @@ -385,6 +382,7 @@ export default function Dashboard({ : normalizedRelayerUrl.includes('dev') ? 'dev / testnet' : 'production / mainnet' + const sdkDefaultServerUrl = config.memwalServerUrl const relayerLooksMismatched = (config.suiNetwork === 'mainnet' && normalizedRelayerUrl.includes('staging')) || (config.suiNetwork !== 'mainnet' && @@ -696,7 +694,7 @@ export default function Dashboard({ const memwal = MemWal.create({ key: process.env.${PRIVATE_KEY_ENV} ?? "${PRIVATE_KEY_PLACEHOLDER}", accountId: process.env.${ACCOUNT_ID_ENV} ?? "${effectiveAccountObjectId ?? ACCOUNT_ID_PLACEHOLDER}", - serverUrl: process.env.${SERVER_URL_ENV} ?? "${SDK_DEFAULT_SERVER_URL}", + serverUrl: process.env.${SERVER_URL_ENV} ?? "${sdkDefaultServerUrl}", }) // Remember something @@ -715,7 +713,7 @@ async def main(): memwal = MemWal.create( key=os.environ["${PRIVATE_KEY_ENV}"], account_id=os.environ["${ACCOUNT_ID_ENV}"], - server_url=os.environ.get("${SERVER_URL_ENV}", "${SDK_DEFAULT_SERVER_URL}"), + server_url=os.environ.get("${SERVER_URL_ENV}", "${sdkDefaultServerUrl}"), ) await memwal.remember_and_wait("I'm allergic to peanuts") @@ -738,7 +736,7 @@ import { openai } from "@ai-sdk/openai" const model = withMemWal(openai("gpt-4o"), { key: process.env.${PRIVATE_KEY_ENV} ?? "${PRIVATE_KEY_PLACEHOLDER}", accountId: process.env.${ACCOUNT_ID_ENV} ?? "${effectiveAccountObjectId ?? ACCOUNT_ID_PLACEHOLDER}", - serverUrl: process.env.${SERVER_URL_ENV} ?? "${SDK_DEFAULT_SERVER_URL}", + serverUrl: process.env.${SERVER_URL_ENV} ?? "${sdkDefaultServerUrl}", }) const result = await generateText({ @@ -916,23 +914,23 @@ const result = await generateText({ className="dashboard-credentials-card" title="SDK credentials" subtitle={`Copy the delegate private key into server env as ${PRIVATE_KEY_ENV}`} + data-analytics-sensitive="sdk-credentials" > -
-