Skip to content

fix(mcp): send proactive-usage instructions in the initialize handshake - #681

Merged
harrymove-ctrl merged 6 commits into
devfrom
fix/mcp-proactive-instructions
Aug 18, 2026
Merged

fix(mcp): send proactive-usage instructions in the initialize handshake#681
harrymove-ctrl merged 6 commits into
devfrom
fix/mcp-proactive-instructions

Conversation

@harrymove-ctrl

@harrymove-ctrl harrymove-ctrl commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Closes WALM-324.

Problem

Desktop MCP clients and Codex switched to lazy tool loading. Tool schemas no longer enter the model's context until a tool is explicitly loaded, and our entire "when to save / when to recall" contract lived inside those tool descriptions.

Result: the model sees no memory tool, so it either offers its own built-in memory or flatly denies the tool exists. It only works if the user prefixes the prompt with "using the memwal tools...".

Why it still worked in the demo videos

The plugin install path ships a SessionStart hook (packages/mcp/plugin/scripts/on_session_start.mjs) that injects the proactive guidance into the session. Plain MCP installs (command + args in the client config) never got that hook, so only plugin users kept working. The videos were recorded on the plugin path.

Fix

The MCP instructions field is delivered with initialize, before any tools/list, so lazy tool loading cannot strip it. Clients inject it into the model's system prompt.

There are three initialize responders, and none of them set it:

  1. services/server/scripts/mcp/server.ts (relayer) constructed McpServer with no options object. Serves the direct HTTP/OAuth connector path.
  2. packages/mcp/src/bridge.ts buildLocalInitializeResult answers initialize locally at cold start and suppresses the relayer's reply. This is the path every signed-in stdio client uses.
  3. packages/mcp/src/auth-required.ts hand-rolls its own result for the signed-out case.

All three now send instructions. The relayer and bridge carry the full recall/remember/recover guidance plus a line countering the reported symptom ("if a memwal_* tool is not currently loaded, load it and use it; never tell the user that memory is unavailable"). The signed-out stub instead points at memwal_login, since every memory tool fails without credentials and telling the model to save proactively there would only manufacture errors.

Bridge and auth-required share one definition in the new packages/mcp/src/instructions.ts. The relayer keeps its own copy because services/server/scripts is the standalone memwal-server-scripts npm package with no workspace link to packages/mcp; both sides carry a comment pointing at the other.

How #2 was found

Worth recording, because it invalidated an assumption in the first version of this PR. I originally claimed the bridge forwards initialize upstream untouched, so fixing the relayer alone would be enough. End-to-end probing against a deployed relayer disproved it: /version reported build.commit exactly equal to this branch HEAD, confirming the relayer was running the fix, yet the client handshake still returned version 0.0.1 with no instructions. That value could only come from buildLocalInitializeResult.

So the relayer fix alone was correct but unreachable for every stdio user. Unit tests could not catch this: the relayer test drives the routes directly and the auth-required test runs a different mode. Only the real bridge path exposed it.

Regression tests

instructions is an optional field on both responders, so dropping it fails no type check and breaks no other assertion. That is exactly how this went missing.

  • services/server/scripts/mcp/__tests__/instructions.test.ts drives the real mounted routes over loopback HTTP.
  • packages/mcp/test/coldstart-init.test.mjs now asserts the bridge's local initialize carries instructions and a non-stub version. Every other assertion in that file passed while the field was absent.

Confirmed the relayer test actually bites: removing the constructor argument turns it red, restoring it turns it green.

Drive-by: real serverInfo.version

All three handshakes hardcoded version: "0.0.1", matching no real package. Every user's handshake log reported the same fake version, which is part of why triaging this was guesswork, and the components were indistinguishable on the wire. All now report their real version via packages/mcp/src/version.ts.

Self-demonstrating: packages/mcp was bumped 0.0.5 to 0.0.9 while this branch was open and the handshake picked it up with no code change. It also turned the version into the diagnostic that located bug #2.

MEMWAL_MCP_COMPATIBILITY_VERSION stays pinned at 0.0.1 on purpose. It is the relayer-contract baseline compared against minSupportedSdk.mcp and checked by scripts/check-compatibility-contract.mjs, not a release version.

Verification

  • npm test services/server/scripts: 198 pass, 0 fail
  • pnpm test packages/mcp: 18 pass, 0 fail
  • pnpm typecheck packages/mcp: clean

Live bridge probe against the deployed dev relayer, before and after the bridge fix:

before:  "serverInfo":{"version":"0.0.1"}            instructions: MISSING
after:   "serverInfo":{"version":"0.0.9"}            instructions: PRESENT (1178 chars)

Client consumption is confirmed, not assumed: a Claude client's system prompt was observed gaining a ## memwal section containing this text verbatim, in a session where it had previously been absent.

Deploy safety: replicated the Dockerfile's scripts layer in a temp dir and confirmed ../package.json resolves to /app/scripts/package.json in the container layout.

Verified end to end on Claude Desktop

All three behavioral cases pass against the patched build, with the relayer deployed from this branch.

Case Input Result
Proactive save My default deploy region is ap-southeast-1 memwal_remember fired unprompted and succeeded
Proactive recall new chat: What'''s my default deploy region? memwal_recall fired, returned the fact, answered correctly
Negative control Run the tests for me no memwal_remember, correctly silent

The recall case is the one the ticket title is about. Request/response captured:

Request : {"query": "default deploy region deployment preference"}
Response: [score=0.679] User'''s default deploy region is ap-southeast-1 (Singapore)...

An earlier recall run looked like a pass and was not: the answer came from Claude'''s built-in memory while the memwal write had 401'''d, so Walrus held nothing. A direct memwal_recall confirmed zero matching entries. The rerun above uses a fact that exists only in Walrus, so it cannot be satisfied by any other store.

The 401 was stale environment-scoped credentials, cleared with memwal_login. Not a code issue.

Scope of the claim

  • Proven: mechanism on Claude Code, by direct observation of the system prompt gaining a ## memwal section containing this text verbatim.
  • Proven: behavior on Claude Desktop, all three cases above.
  • Not isolated: Desktop never displayed an instructions block when asked to quote its system prompt, and the account carries a stored preference naming memwal. The outcome is correct; attribution between the two channels is not cleanly separable there.

Also included: collapse duplicate recall results

Folded in from #682 (now closed) so both MCP fixes land together.

A limit=5 recall on a real dev account returned the same fact five times, spending the entire retrieval budget on one preference and crowding out everything else the query should have surfaced:

1. [score=0.224] User prefers dark roast coffee
2. [score=0.224] User prefers dark roast coffee
... x5

Fixed on the read side, not the write side. Storing a fact repeatedly is legitimate: each remember is a distinct event with its own blob and timestamp, and there is deliberately no content-level uniqueness constraint (the only unique index is request idempotency on remember_jobs (owner, idempotency_key), which guards retries). Deduping on write would silently discard a genuine re-statement.

collapseDuplicates folds results whose text matches after trimming, whitespace collapsing, and case folding, keeping the highest scoring copy since rows arrive ranked. Matching is exact-after-normalization rather than fuzzy, because merging facts that merely resemble each other would hide real information. The reply reports how many copies were folded rather than quietly returning fewer rows.

7 tests in mcp/__tests__/recall-dedupe.test.ts, including two safety cases: distinct facts must pass through untouched, and "prefers dark roast coffee" must not collapse with "prefers dark roast coffee in the morning only".

This is relayer-side only (services/server/scripts, "private": true), so it does not affect the npm package version.

Release

Manual bump to 0.0.10 following the flow from #678, not changesets: all 11 manifest/changelog/doc files moved together. Verified with the repo checker:

$ node scripts/verify-manual-sdk-release.mjs
MCP package 0.0.10: manifests and changelogs synchronized

Not covered by this PR

  • ChatGPT / Codex agent surface, untested.
  • Codex setup docs are broken independently, since Codex moved into the ChatGPT app and local MCP servers now only apply to the agent surface. Separate docs task.

Desktop MCP clients and Codex switched to lazy tool loading, so tool schemas
no longer reach the model's context until a tool is explicitly loaded. The
"call this PROACTIVELY" guidance lived only in the tool descriptions
(tools/remember.ts), so the model saw no memory tool and either offered its
own built-in memory or denied the tool existed.

The MCP `instructions` field travels with `initialize`, before any
tools/list, so lazy loading cannot strip it. Neither server path set it: the
relayer built McpServer with no options object, and the auth-required stub
hand-rolled its initialize result.

* relayer: full recall/remember/recover guidance, mirroring the text the
  plugin already injects via its SessionStart hook. Plain MCP installs never
  got that hook, which is why proactive behavior only survived for plugin
  users and why the demo videos still worked.
* auth-required stub: signed-out variant pointing at memwal_login instead,
  since every memory tool fails without credentials.

Also replaces the hardcoded serverInfo.version "0.0.1" in both handshakes
with the real package version. It matched neither packages/mcp (0.0.5) nor
memwal-server-scripts (0.1.0), so every user's handshake log reported the
same fake version and made field triage guesswork. Distinct from
MEMWAL_MCP_COMPATIBILITY_VERSION, which stays pinned at 0.0.1 as the
relayer-contract baseline checked by check-compatibility-contract.mjs.

Refs WALM-324
@harrymove-ctrl
harrymove-ctrl force-pushed the fix/mcp-proactive-instructions branch from fdda948 to 8fd56b1 Compare August 18, 2026 06:54
The `instructions` field is an optional McpServer constructor argument, so
dropping it fails no type check and breaks no other test. That is exactly how
the proactive save/recall contract went missing in the first place: the
guidance lived only in tool descriptions, which lazy-loading clients never put
in context.

Drives the real mounted routes over loopback HTTP and asserts the handshake
names memwal_recall/memwal_remember, keeps the "not currently loaded" rebuttal
that counters the reported symptom, and reports the real package version
rather than a hardcoded stub. Verified to fail when the constructor argument
is removed.

Refs WALM-324
@railway-app
railway-app Bot temporarily deployed to Walrus Memory / dev August 18, 2026 07:29 Inactive
The bridge answers `initialize` itself at cold start and SUPPRESSES the
relayer's reply, so the relayer's instructions never reached a stdio client.
Every signed-in user went through that path, which meant the previous commit
fixed the field on a response nobody sees.

Caught by end-to-end probing against a deployed relayer: build metadata
confirmed the relayer was running this branch, yet the handshake still
reported version 0.0.1 with no instructions. That value could only come from
buildLocalInitializeResult in bridge.ts.

The relayer copy still matters: it serves the direct HTTP/OAuth connector
path, which does not go through the bridge. Both are needed.

Extracts both payloads into instructions.ts so bridge and auth-required share
one definition, and asserts the local initialize carries them in
coldstart-init, where the gap was previously invisible to every assertion.

Refs WALM-324
@ducnmm

ducnmm commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

LGTM! The fix via initialize instructions and the test coverage look solid. 🚀

Quick reminder: please add a changeset (.changeset/) or update the changelog for @mysten-incubation/memwal-mcp so CI can pick up the version bump & publish on release.

Patch bump for @mysten-incubation/memwal-mcp. The published package changes
here are the bridge and auth-required initialize responders; the relayer-side
change ships with the service, not npm.

Requested in review on #681.
@harrymove-ctrl

harrymove-ctrl commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @ducnmm! Changeset added in ff0d615d: .changeset/gh-681-proactive-instructions.md, patch bump for @mysten-incubation/memwal-mcp.

So release-mcp.yml will bump 0.0.9 → 0.0.10 and publish on the next main release.

Scoped it to the MCP package only, since the published surface here is the bridge and auth-required initialize responders. The relayer-side change in services/server/scripts ships with the service rather than npm, so it is not part of the bump.

For the same reason, the follow-up #682 (collapse duplicate memwal_recall results) deliberately has no changeset: it only touches services/server/scripts, which is "private": true and never published. Shout if you would rather it had one anyway.

CI is green here, 15/15.

One heads-up: your LGTM came through as a plain comment rather than a submitted review, so GitHub still reports REVIEW_REQUIRED and the PR is BLOCKED from merging. If you are happy with it, a formal Approve on the Files changed tab would unblock it.

Follows the manual release flow used by #678, not changesets: bump every
MCP/plugin manifest together, add the version section to both changelogs, and
move the verifier's expected version forward. `verify-manual-sdk-release.mjs`
reports "MCP package 0.0.10: manifests and changelogs synchronized".

Drops the changeset added earlier in this PR. Leaving it alongside a manual
bump would have double-bumped on main, since release-mcp.yml runs
`changeset version` there and would have taken 0.0.10 to 0.0.11 while
rewriting the changelog section added here.

Replaces the review request on #681 for a changeset or changelog update.
@harrymove-ctrl

Copy link
Copy Markdown
Collaborator Author

Redone properly following the manual release flow, per @Henry's note that this repo bumps the version + changelog rather than using changesets. Reference: #678 (42bdd5de, "bump MCP package to 0.0.9 & update changelog").

2de2df60 bumps 0.0.9 → 0.0.10 across all 11 files that #678 touched:

  • packages/mcp/package.json
  • .claude-plugin/marketplace.json, .cursor-plugin/marketplace.json
  • packages/mcp/plugin/plugin.json and the .claude-plugin / .codex-plugin / .cursor-plugin variants
  • packages/mcp/CHANGELOG.md and docs/mcp/changelog.mdx (new ## 0.0.10 section, plus the "latest MCP package release is…" answer line)
  • packages/mcp/TESTING.md (prerelease dist-tag references)
  • scripts/verify-manual-sdk-release.mjs (expected version)

Verified with the repo's own checker rather than by eye:

$ node scripts/verify-manual-sdk-release.mjs
MCP package 0.0.10: manifests and changelogs synchronized

I also removed the changeset I added in ff0d615d. Keeping both would have double-bumped: release-mcp.yml runs pnpm changeset version on main, so it would have taken the manual 0.0.10 to 0.0.11 and rewritten the changelog section added here. One mechanism or the other, not both.

@ducnmm this supersedes the changeset from my earlier reply, so that request is now satisfied via the changelog route you offered as the alternative.

Still worth noting: the LGTM is a plain comment, so GitHub reports REVIEW_REQUIRED and the PR stays BLOCKED. A formal Approve would unblock it.

A limit=5 recall on a real account returned the same fact five times, so one
preference consumed the entire retrieval budget and crowded out every other
memory the query should have surfaced.

Storing a fact repeatedly is legitimate: each remember is a distinct event
with its own blob and timestamp, and there is no content-level uniqueness
constraint by design (the sole unique index is request idempotency on
remember_jobs (owner, idempotency_key), which guards retries, not content).
Deduping on write would silently discard a genuine re-statement, so the fix
belongs on the read side.

Collapses results whose text matches after trimming, whitespace collapsing,
and case folding, keeping the highest scoring copy since rows arrive ranked.
Matching is exact-after-normalization rather than fuzzy: merging facts that
merely resemble each other would hide real information. The reply reports how
many copies were folded so nothing disappears silently.
@railway-app
railway-app Bot temporarily deployed to Walrus Memory / dev August 18, 2026 09:34 Inactive
@harrymove-ctrl
harrymove-ctrl merged commit 46a9fc4 into dev Aug 18, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants