Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"name": "memwal",
"source": "./packages/mcp/plugin",
"description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"version": "0.0.17"
"version": "0.0.18"
}
]
}
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"name": "memwal",
"source": "./packages/mcp/plugin",
"description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"version": "0.0.17"
"version": "0.0.18"
}
]
}
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ jobs:
with:
node-version-file: .nvmrc

- name: Verify changed package versions
if: matrix.script == 'scripts/verify-manual-sdk-release.mjs' && github.event_name == 'pull_request' && github.base_ref == 'main'
env:
RELEASE_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch --no-tags --depth=1 origin "$RELEASE_BASE_SHA"
node scripts/verify-package-version-bumps.mjs --base "$RELEASE_BASE_SHA"

- name: Run check
run: node ${{ matrix.script }}

Expand Down
13 changes: 9 additions & 4 deletions docs/mcp/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,21 @@ questions:
- What changed in the MemWal MCP changelog?
- When was the automatic memory plugin added to MemWal MCP?
answer: >-
The upcoming 0.0.17 release stops a bridge reconnect from replaying `memwal_remember_bulk` as a second paid write (#1045) and pins the plugin launcher to `@mysten-incubation/memwal-mcp@0.0.16`. It is not published yet. The latest MCP package on npm `latest` is 0.0.16, which updates the transport SDK and refuses authenticated SSE redirects and endpoints on another origin. 0.0.15 keeps a project-local credentials file inert until `memwal-mcp approve-project`, refuses a `MEMWAL_CREDS_DIR` inside the project, stores the login write-ahead record outside the repository, and closes the previous listener when `memwal_login` uses `freshKey: true`. 0.0.13 stopped reporting a timed-out write as safe to retry, answers a rejected delegate key with `memwal_login` instead of a dropped-connection story, backs off on handshake 429, and pinned plugin launchers to `@0.0.13` so npx cannot keep a cached 0.0.5.
The prepared 0.0.18 release fixes environment-based auto-save consent overrides, wakes aborted SSE readers for reliable reconnects, and prepares a matching stable launcher pin with a published fixed bootstrap. The published 0.0.17 release prevents duplicate bulk writes during bridge replay. Version 0.0.16 hardened transport dependencies and authenticated redirects.
---

## 0.0.17

Unreleased. Package and plugin manifests are bumped manually to 0.0.17. The launcher installs the published 0.0.16 through `mcpPackageVersion` until 0.0.17 is available on npm; retarget that pin after publishing.
## 0.0.18

### Fixed

- `MEMWAL_AUTO_SAVE` in `process.env` can no longer turn automatic memory on or clear `pendingConsent`. An MCP client populates `env` from `.cursor/mcp.json`, `.vscode/mcp.json`, or `.claude/settings.json` inside the checkout, so a truthy `MEMWAL_AUTO_SAVE` in `env` could flip automatic saving on over a stored `autoSave: false` (reporting `enabled: true` alongside `state: "off"`) or bypass the consent prompt on an unanswered install. Both the MCP server and the plugin hooks now honour only a disabling override (`MEMWAL_AUTO_SAVE=0`) from `env`, keep `state` and `enabled` consistent, and preserve `pendingConsent: true` until a stored answer exists in `~/.memwal/settings.json`. (#995)
- Wake pending SSE readers immediately when the connection is aborted, so an idle session reconnects without depending on the HTTP reader rejecting first.
- Pin the launcher to 0.0.18 with published 0.0.17-dev.3 (which contains the consent fix) as an explicit bootstrap while the stable release is unavailable. Bootstrap is permitted only when npm reports the exact preferred package version missing; cached stable installs keep their existing offline fast path.

## 0.0.17

### Fixed

- `memwal_remember_bulk` no longer registers a second paid Walrus blob when the bridge reconnects and replays the call. The bridge stamps each call with a stable idempotency key before tracking it, and the sidecar sends that key to the relayer, which answers a replayed key with the jobs it already queued. A bulk call the agent retries itself is a new call and gets a new key. (#1045)
- The plugin launcher installs `@mysten-incubation/memwal-mcp@0.0.16` instead of `0.0.15`.

Expand Down
19 changes: 19 additions & 0 deletions docs/relayer/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,22 @@ To exercise the policy database and HTTP tests locally, set
`OWNER_RATE_LIMIT_TEST_DATABASE_URL` to a disposable local PostgreSQL database and
run `cargo test --manifest-path services/server/Cargo.toml --bin memwal-server owner_`.
The tests use isolated temporary schemas and reject non-local database hosts.

### Uploader gas failures in live validation

A successful recall or `write_ready=true` does not prove a durable upload can
finish. If a remember job reports infrastructure capacity failure, inspect the
relayer upload error and the uploader balances in the admin dashboard. The
specific message `Unable to perform gas selection due to insufficient SUI balance`
requires operator maintenance of the configured uploader pool. It is not an
account rate-limit error and must not be addressed by increasing API limits or by
asking the end user to fund their wallet.

For dev, confirm the configured Sui network and replenish the affected uploader
wallets through the approved testnet funding procedure. Address-balance-only
configurations must have spendable address balance, not just owned coin objects.
Do not transfer production funds, change keys or lower E2E assertions as a
workaround. After maintenance, rerun `SDK / E2E (live relayer)` and confirm each
remember job reaches `done`, then recall from the test namespace. A failed or
pending job is not a stored memory. This release does not perform wallet funding
or add balance RPCs to the remember/recall request path.
11 changes: 8 additions & 3 deletions packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# @mysten-incubation/memwal-mcp

## 0.0.17

Unreleased. Package and plugin manifests are bumped manually to 0.0.17. The launcher installs the published 0.0.16 through `mcpPackageVersion` until 0.0.17 is available on npm; retarget that pin after publishing.
## 0.0.18

### Fixed

- `MEMWAL_AUTO_SAVE` in `process.env` can no longer turn automatic memory on or clear `pendingConsent`. An MCP client populates `env` from `.cursor/mcp.json`, `.vscode/mcp.json`, or `.claude/settings.json` inside the checkout, so a truthy `MEMWAL_AUTO_SAVE` in `env` could flip automatic saving on over a stored `autoSave: false` (reporting `enabled: true` alongside `state: "off"`) or bypass the consent prompt on an unanswered install. Both the MCP server and the plugin hooks now honour only a disabling override (`MEMWAL_AUTO_SAVE=0`) from `env`, keep `state` and `enabled` consistent, and preserve `pendingConsent: true` until a stored answer exists in `~/.memwal/settings.json`. (#995)
- Wake pending SSE readers immediately when the connection is aborted, so an idle session reconnects without depending on the HTTP reader rejecting first.
- Pin the launcher to 0.0.18 with published 0.0.17-dev.3 (which contains the consent fix) as an explicit bootstrap while the stable release is unavailable. Bootstrap is permitted only when npm reports the exact preferred package version missing; cached stable installs keep their existing offline fast path.

## 0.0.17

### Fixed

- `memwal_remember_bulk` no longer registers a second paid Walrus blob when the bridge reconnects and replays the call. The bridge stamps each call with a stable idempotency key before tracking it, and the sidecar sends that key to the relayer, which answers a replayed key with the jobs it already queued. A bulk call the agent retries itself is a new call and gets a new key. (#1045)
- The plugin launcher installs `@mysten-incubation/memwal-mcp@0.0.16` instead of `0.0.15`.

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mysten-incubation/memwal-mcp",
"version": "0.0.17",
"version": "0.0.18",
"description": "Walrus Memory MCP client \u2014 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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "memwal",
"version": "0.0.17",
"version": "0.0.18",
"description": "Automatic Walrus Memory for Claude Code \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"author": {
"name": "Mysten Labs"
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "memwal",
"version": "0.0.17",
"version": "0.0.18",
"description": "Persistent Walrus Memory for Codex. Remembers decisions, preferences, and project context across sessions.",
"author": {
"name": "Mysten Labs",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/plugin/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "memwal",
"version": "0.0.17",
"version": "0.0.18",
"description": "Automatic Walrus Memory for Cursor \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"author": {
"name": "Mysten Labs"
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"id": "memwal",
"name": "memwal",
"version": "0.0.17",
"mcpPackageVersion": "0.0.16",
"version": "0.0.18",
"mcpPackageVersion": "0.0.18",
"mcpBootstrapVersion": "0.0.17-dev.3",
"description": "Automatic Walrus Memory for Antigravity \u2014 proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"author": {
"name": "Mysten Labs"
Expand Down
52 changes: 44 additions & 8 deletions packages/mcp/plugin/scripts/lib/mcp-launch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ export function installArguments(spec, staging) {
// otherwise run preinstall/install/postinstall of the whole dependency tree
// as the user, before any of our verification runs.
"--ignore-scripts",
"--json",
`--registry=${MCP_REGISTRY}`,
"--no-audit",
"--no-fund",
Expand All @@ -463,6 +464,25 @@ export function installArguments(spec, staging) {
];
}

/** Only an exact missing-version npm error can use the reviewed bootstrap. */
export function isMissingPinnedVersion(result, version) {
try {
const error = JSON.parse(result.stdout).error;
return error?.code === "ETARGET"
&& error.summary === `No matching version found for ${MCP_PACKAGE_NAME}@${version}.`;
} catch { return false; }
}

/** No fallback on network, integrity, permissions or dependency failures. */
export function resolvePinnedEntry(preferred, bootstrap, load) {
try { return load(preferred); }
catch (error) {
if (!bootstrap || bootstrap === preferred
|| error?.code !== "MCP_VERSION_UNAVAILABLE" || error.version !== preferred) throw error;
return load(bootstrap);
}
}

/**
* Install the pinned version into the trusted area and return its absolute entry
* point. Staged in a sibling temp directory and renamed into place, so a second
Expand Down Expand Up @@ -503,6 +523,12 @@ function install(version, root) {
throw new Error(`could not run npm (${npm.path}): ${result.error.message}`);
}
if (result.status !== 0) {
if (isMissingPinnedVersion(result, version)) {
const error = new Error(`The pinned ${MCP_PACKAGE_NAME}@${version} is not published`);
error.code = "MCP_VERSION_UNAVAILABLE";
error.version = version;
throw error;
}
throw new Error(
`npm install ${spec} failed (exit ${result.status}): ` +
`${(result.stderr || result.stdout || "").trim()}`,
Expand Down Expand Up @@ -542,17 +568,27 @@ function install(version, root) {
* The absolute path the plugin should launch. Installs the pinned version into the
* trusted area on first use; later calls are a stat of a known path.
*
* There is deliberately no fallback: if the trusted install cannot be produced, the
* launcher fails instead of reaching for a package name that a project could answer.
* A reviewed bootstrap is permitted only for npm's exact missing-version error.
* Every candidate uses the same trusted install path and verification; the launcher
* never resolves a package from the user's project.
*/
export function ensureTrustedEntry({ version = pinnedVersion(), root = runtimeRoot() } = {}) {
export function ensureTrustedEntry(options = {}) {
const { version = pinnedVersion(), root = runtimeRoot() } = options;
const trustedRoot = prepareRuntimeRoot(assertRuntimeRootLocation(root));
const dir = installDir(version, trustedRoot);
if (verifyTrustedDirectory(dir)) {
const existing = resolveInstalledEntry(dir, version);
if (existing) return existing;
const manifest = options.version === undefined
? JSON.parse(readFileSync(join(PLUGIN_ROOT, "plugin.json"), "utf8")) : {};
const bootstrap = manifest.mcpBootstrapVersion;
if (bootstrap !== undefined && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(bootstrap)) {
throw new Error("Invalid MCP bootstrap version");
}
return install(version, trustedRoot);
return resolvePinnedEntry(version, bootstrap, candidate => {
const dir = installDir(candidate, trustedRoot);
if (verifyTrustedDirectory(dir)) {
const existing = resolveInstalledEntry(dir, candidate);
if (existing) return existing;
}
return install(candidate, trustedRoot);
});
}

/** Exported for the regression test: the path we expect, without installing anything. */
Expand Down
15 changes: 14 additions & 1 deletion packages/mcp/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,7 @@ async function openSseStream(
// 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
// abort listener 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();
Expand All @@ -790,6 +790,18 @@ async function openSseStream(
// unref so the watchdog never holds the event loop open during shutdown.
watchdog.unref?.();

// Some supported fetch implementations leave reader.read() pending after
// abort. Wake our iterator directly; cancellation is cleanup, not the signal
// on which reconnect progress depends.
const onStreamAbort = () => {
clearInterval(watchdog);
streamEnded = true;
wake();
void reader.cancel().catch(() => {});
};
controller.signal.addEventListener("abort", onStreamAbort, { once: true });
if (controller.signal.aborted) onStreamAbort();

// Pump the SSE stream in the background.
const pump = (async () => {
try {
Expand Down Expand Up @@ -842,6 +854,7 @@ async function openSseStream(
}
}
} finally {
controller.signal.removeEventListener("abort", onStreamAbort);
clearInterval(watchdog);
streamEnded = true;
// Wake any waiter so they see EOF.
Expand Down
22 changes: 22 additions & 0 deletions packages/mcp/test/trusted-launcher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -821,3 +821,25 @@ test("a subdirectory of the home directory is a project again", (t) => {
assert.match(result.stderr, /inside the project tree/);
assert.doesNotMatch(result.stdout, new RegExp(LOCAL_MARKER));
});

test("stable pin bootstrap is used only for the exact unpublished version", async () => {
const { resolvePinnedEntry, isMissingPinnedVersion } = await import("../plugin/scripts/lib/mcp-launch.mjs");
const preferred = "0.0.18", bootstrap = "0.0.17-dev.3";
const missing = Object.assign(new Error("missing"), { code: "MCP_VERSION_UNAVAILABLE", version: preferred });
const calls = [];
assert.equal(resolvePinnedEntry(preferred, bootstrap, version => {
calls.push(version);
if (version === preferred) throw missing;
return "trusted-bootstrap";
}), "trusted-bootstrap");
assert.deepEqual(calls, [preferred, bootstrap]);
assert.equal(resolvePinnedEntry(preferred, bootstrap, () => "cached-stable"), "cached-stable");
for (const error of [new Error("network"), Object.assign(new Error("dependency"), { code: "MCP_VERSION_UNAVAILABLE", version: "other" })]) {
assert.throws(() => resolvePinnedEntry(preferred, bootstrap, () => { throw error; }), value => value === error);
}
const result = error => ({ stdout: JSON.stringify({ error }) });
assert.equal(isMissingPinnedVersion(result({ code: "ETARGET", summary: `No matching version found for ${MCP_PACKAGE_NAME}@${preferred}.` }), preferred), true);
assert.equal(isMissingPinnedVersion(result({ code: "ETARGET", summary: "No matching version found for dependency@99.0.0." }), preferred), false);
assert.equal(isMissingPinnedVersion(result({ code: "ECONNRESET", summary: "network" }), preferred), false);
assert.equal(isMissingPinnedVersion({ stdout: "not json" }, preferred), false);
});
16 changes: 14 additions & 2 deletions scripts/check-mcp-pin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
* A unit test cannot catch this — whether a version exists is a fact about npm,
* not about the repository — so it lives here.
*
* An explicit published bootstrap can serve dev installs while their exact
* preferred stable version is pending. Runtime fallback is allowed only for
* npm ETARGET on that exact preferred version, never for network/install errors.
*
* Two findings, deliberately unequal:
*
* ERROR the pin is not published. The launcher cannot install it. Blocking.
* One exception: on a ref headed for `main`, a pin equal to
* An explicit published bootstrap permits a pending preferred pin.
* Otherwise, on a ref headed for `main`, a pin equal to
* packages/mcp/package.json's version is the release being cut, and
* release-mcp.yml publishes exactly that from this tree moments later.
* Erroring there would redden every release. The exception is scoped to
* `main` on purpose: `dev` pinning an unpublished release version is
* precisely the 2026-09-17 bug, and must stay an error.
* precisely the 2026-09-17 bug without an explicit published bootstrap.
*
* WARNING the pin is published but is not what the dist-tag for this branch
* points at. Not blocking, because it cannot be: pushing to `dev`
Expand Down Expand Up @@ -120,6 +125,13 @@ async function main() {
}

if (!versions.includes(pin)) {
const bootstrap = manifest.mcpBootstrapVersion;
if (pin === sourceVersion && typeof bootstrap === "string" && versions.includes(bootstrap)) {
annotate("warning", `Preferred ${pin} is not published yet; the launcher can bootstrap from published ${bootstrap}. Stable installs switch to ${pin} when npm serves it.`);
summary.push(`Preferred release pending; explicit published bootstrap: \`${bootstrap}\`.`);
summarise(summary);
return;
}
// The release being cut is allowed to pin ahead of npm: release-mcp.yml
// publishes packages/mcp/package.json's version from this very tree a
// moment after the push. Only on a ref headed for `main`, though —
Expand Down
Loading
Loading