Skip to content

fix: the filed bugs, plus MCP 2026-07-28 dual-era support - #3

Merged
calebevans merged 8 commits into
mainfrom
bug-fixes
Aug 12, 2026
Merged

fix: the filed bugs, plus MCP 2026-07-28 dual-era support#3
calebevans merged 8 commits into
mainfrom
bug-fixes

Conversation

@calebevans

Copy link
Copy Markdown
Owner

Addresses the bug report filed on 2026-08-10/11, and closes #2.

The report's diagnosis was wrong in ways that mattered

Bug 1's corruption is client-side, not ours. Running the report's own reproduction on a fresh connection — including the ~2800-char write it named as the trigger, and the small writes after it — stored perfectly clean every time. The server does structured serde_json parsing over newline-delimited stdio with no text splicing, no shared buffers and no cross-request state. The </invoke> literal is the client's own tool-call syntax.

That mattered because the report treated Bugs 2 and 3 as downstream effects of Bug 1, which hid two independent defects:

  • Bug 2 is its own bug. Every length check used str::len() (UTF-8 bytes) while the message and the advertised JSON schema said characters. A 1900-character summary of em dashes is 5696 bytes and was rejected as "exceeds maximum length of 2000 characters". The reporter's 1794-character summary was em-dash-heavy prose — that alone explains the rejection.
  • Bug 3 was wired correctly but swallowed every failure mode, and parentId had never worked at all: it was parsed into StoreInput and then read by nothing, on any path, in any released version.

The "sticky state" intuition was right, but not about the described mechanism. fullText's 1 MiB limit exactly equalled the daemon's frame limit, so a write that passed validation could exceed the frame once enveloped, killing the socket — and DaemonClient never reconnected. Reproduced live, and the fix verified end-to-end against a real server.

What else this fixes

  • Recall removed a superseded memory before validating its replacement, so if the replacement had decayed the original became permanently unrecallable with nothing in its place.
  • Injected replacements bypassed every filter, including namespace — a cross-namespace isolation leak.
  • Oversized daemon responses were worse than requests: the client rejected them after consuming the length prefix but before draining the payload, leaving unread JSON in the socket. No broken pipe, just permanent silent desync.
  • list_memories was never dispatched by the daemon, so the tool was broken in daemon mode.
  • Content limits are now enforced per element, and tags that fail validation are logged instead of silently discarded.

Issue #2 assumed bumping the protocol version was backward-compatible. It isn't — the constant is echoed verbatim into the initialize response. It ships here as a dual-era server, which the spec explicitly sanctions: modern requests are served statelessly with no lock, legacy initialize clients keep working unchanged.

Verification

68 → 478 tests across the branch. Each commit was verified to build and pass independently. The daemon fix and the validation fix were additionally replayed end-to-end against a real server with real embeddings.

CI has been failing on every job since fdf69f7 bumped rust-version to 1.94 while the workflows still installed 1.87; that's fixed here so this opens green.

Deliberately not done

No backfill of already-corrupted records, and no retrieval-precision tuning — both your call. Note the retrieval complaint (Bug 4) may have a real cause: see the vector-index finding in the follow-up PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH

calebevans-ab and others added 8 commits August 11, 2026 00:38
Every length check used str::len() (UTF-8 bytes) while the error message
and the advertised MCP JSON schema both said "characters". A summary of
1900 characters written with em dashes is 5696 bytes and was rejected as
"Summary exceeds maximum length of 2000 characters", while 1900 ASCII
characters was accepted. Verified against a running server.

Byte semantics are intentional and kept: the on-disk record encodes the
summary length as a u16. What was wrong was the reporting.

- Add src/model/validation.rs as the single source of truth. The HTTP
  create path, the HTTP batch path and both MCP store handlers each
  carried a hand-rolled copy of these checks, and the copies had drifted.
- Errors now name the field, the measured byte count, the character
  count and the limit, and explain the discrepancy only when one exists:
  "summary is 5700 bytes (1900 characters), which exceeds the 2000-byte
  limit. Limits are measured in UTF-8 bytes, not characters: ..."
- Memory::validate() already produced correct messages but had zero call
  sites. It now delegates here instead of being a second source of truth.
- Validate the HTTP batch endpoint, which previously checked only that
  the summary was non-empty. A summary of 65536 bytes or more reached the
  wrapping `as u16` cast in record.rs and corrupted the stored record.
- Fix a latent panic in the CLI: &text[..200] aborts when byte 200 is not
  a char boundary. Replaced with truncate_on_char_boundary().
- Replace magic literals with named constants and advertise the real
  limits in the tool schemas (fullText maxLength, array maxItems).
- Correct docs/mcp.md and docs/guide.md, which stated the wrong unit.

Behavioral changes: entities/topics/emotions counts are now enforced on
the HTTP path; an empty-string summary is now rejected over MCP; namespace
names are ASCII-only on both paths (previously HTTP accepted Unicode).
Removing three public ValidationError variants is semver-breaking, which
is acceptable at 0.1.x. Error message text changed on every path; the
machine-readable error code and field are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
fullText's 1 MiB limit was exactly the daemon's 1 MiB frame limit, so a
request that passed validation could exceed the frame once wrapped in its
JSON envelope. Reproduced against a running server: four sequential
store_memory calls on one connection gave OK, then a 1,048,570-byte write
failed with "Broken pipe", and then every later call failed the same way
forever. Only restarting the MCP server recovered.

The response direction was worse and silent. A single get_memory on a
large memory, or a recall_memories returning several, made the daemon
write an over-limit frame. The client rejected it after consuming the
4-byte length prefix but before draining the payload, leaving unread JSON
in the socket -- no broken pipe, just every subsequent read_u32 parsing
from the middle of a message.

- Raise the frame limit to 8 MiB and keep fullText at 1 MiB. What
  overflows is JSON escaping of the text (up to 6x for control bytes),
  which is a transport artifact, so the headroom belongs in the
  transport. The worst-case arithmetic is documented on the constant.
- Check the size before any byte reaches the socket, so an oversized
  request fails cleanly and leaves the connection usable.
- Drain an oversized but well-formed incoming frame into a sink under a
  timeout. Length-prefixed framing makes the boundary known, so this
  resynchronizes exactly rather than best-effort. A length beyond the
  drain limit is treated as unrecoverable desync and closes instead.
- Stop dropping the whole connection for one bad frame; recoverable
  errors now reply and keep serving.
- Add lazy reconnect to DaemonClient with bounded attempts. Retry only
  read-only methods: store_memory mints a server-side id, so a
  transparent retry could double-write, and neither a failed write nor a
  failed read proves the operation did not execute. Mutating calls return
  an explicit "may or may not have been applied" error instead.
- Correlate response ids. Stale replies are skipped up to a bound, and an
  impossible id resets the connection rather than looping forever.
- Negotiate the frame limit via ping, defaulting to 1 MiB for an
  unannounced peer, so a v0.1.10 daemon or client on either side of the
  socket is never sent a frame it will reject.
- Dispatch "list_memories", which the client called but the daemon had no
  arm for, leaving that tool broken in daemon mode.

The primary fix does not depend on retry: poisoning plus lazy reconnect
alone ends the permanent-failure behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
A bug report described store_memory silently corrupting records: trailing
parameters absorbed into the preceding field as literal text, tags empty,
namespace wrong, supersedes ignored -- all returned as success. The
corruption itself originates client-side, in tool-call serialization; the
server does structured serde_json parsing over newline-delimited stdio
with no text splicing, no shared buffers and no cross-request state, and
the report's own reproduction stores cleanly here. That part is not ours
to fix.

What is ours is that the server accepted it cheerfully. Arguments were
read with .get().and_then(..).ok().unwrap_or_default(), so a tags array
sent as a string became an empty vec, a wrong-typed namespace fell back
to the default partition, and an unparseable supersedes became None --
each silently, each reported as success. That is why roughly ten bad
writes accumulated before anyone noticed.

- Add src/mcp/args.rs: typed extractors that fail loudly and name the
  field, shared by store_memory and store_memories so the two cannot
  drift again. Length and count limits delegate to model::validation, so
  there is still one source of truth for them.
- Detect literal tool-call markup in summary and fullText and reject the
  write. The report suggested keying on any parameter-named tag; that
  would be unusable, since <summary> is standard HTML5, the backbone of
  C# doc comments, and an Atom element. The detector instead anchors on
  tokens that are not English words in tag position -- the client's
  reserved vendor prefix, function-call literals, the exact camelCase
  parameter tags -- plus closing-tag adjacency, which needs two
  independent signals before a generic name counts. Errors carry the
  matched token and byte offset so a false positive is diagnosable in
  seconds.
- Reject rather than sanitise. Stripping the markup would leave a record
  whose tags, namespace and supersedes are still silently wrong, and
  whose embedding and graph edges are computed from garbage-adjacent
  text. A rejected write costs one round trip and lands in the agent's
  transcript, where it self-corrects.
- Log rejections at warn with the field and rule, never the text body.

Explicit JSON null keeps meaning "absent" for every optional field: many
SDKs serialize optionals as null, and treating that as a type error would
break them on every call.

Behavioral change: a wrong-typed tags/namespace, or an unparseable
parentId/supersedes, now fails the whole write where it previously stored
a quietly incomplete memory. Batch items that used to store minus their
tags now report an error instead, so stored counts drop and errors rise
for affected clients. The per-item error object gains an additive "field".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Closes #2.

The issue proposed bumping the protocol version as a backward-compatible
first step. It is not. PROTOCOL_VERSION is echoed verbatim into the
`initialize` response, so bumping it alone tells a legacy client it is
talking to a revision where `initialize` does not exist, `ping` is
removed, and resultType/ttlMs/cacheScope are mandatory -- none of which
this server emitted. Under 2025-06-18 lifecycle rules such a client
should disconnect. The bump is therefore landed together with the
stateless path, never before it.

The spec explicitly sanctions serving both eras on one endpoint, so the
era is decided per message rather than per connection: a `params._meta`
carrying io.modelcontextprotocol/protocolVersion is modern, an
`initialize` is legacy. The `_meta` key wins over the method name, since
a legacy client can send `_meta` for progressToken but never that
reverse-DNS key.

- Split McpServer into a stateless McpDispatcher plus a thin wrapper
  holding only legacy lifecycle state. Modern HTTP requests take no lock
  at all. Merely bypassing the session map would have funnelled every
  concurrent modern request through one mutex, which is worse than
  today's per-session locks and would not have fixed the scaling
  complaint the issue actually raises.
- Add server/discover, which the revision requires and the issue does not
  mention. It is also the era probe, so it and `ping` are answered
  without `_meta` -- demanding a protocol version in order to discover
  which versions are supported is circular, and any error there would
  push a modern client into the legacy fallback.
- Add the required resultType to every result, and SEP-2549 caching
  hints. The catalogs are compile-time constants, so they advertise
  public/1h; resources/read is live instance state, so it advertises
  private/0. Emitted on both eras: the 2025-06-18 Result type is an open
  index signature, so the extra keys are schema-legal there.
- Validate the SEP-2243 headers against the body, including the base64
  sentinel, and answer GET/DELETE with 405 on the modern path.
- Fix resources/templates/list, which emitted resource_templates instead
  of resourceTemplates. That was broken against every MCP revision, so no
  compliant client could ever read the result.
- Validate Origin (a spec MUST this server never honoured) and apply a
  body limit to /mcp, which sits outside the tower stack and had none.
  Loopback is allowed on any scheme or port, since DNS rebinding needs an
  attacker-controlled hostname.
- Fix three session-map defects: entries never expired, a repeat
  initialize orphaned the previous entry, and a failed initialize still
  inserted an unusable one. Unknown sessions now answer 404 with a
  JSON-RPC body saying to re-initialize, so expiry is recoverable.
- Stop discarding the real request id on parse and session errors.

Uncertainties are preserved as code comments rather than silently
resolved: 2025-11-25 is deliberately absent from the legacy list pending
a wire diff, the base64 alphabet needs confirming against SEP-2243, and
notification header requirements are undefined by the revision.

Breaking: mcp_router takes a config argument; /mcp enforces a 10 MiB body
limit where it had none; non-loopback browser origins get 403; legacy
HTTP sessions expire after 30 minutes idle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
A bug report found that supersedes never appeared to do anything. The
parameter was in fact wired up, but every way it could fail was swallowed:
a non-existent target produced GraphError::MemoryNotFound which was logged
as "non-fatal" and discarded, a duplicate did the same, and a persistence
failure did the same again. The store returned success in all three cases,
and nothing in the response ever indicated whether a link had been made.
parentId was worse -- it was parsed into StoreInput and then read by
nothing at all, on both the MCP and HTTP batch paths. It has never created
an edge.

The report asked that the recall side be verified independently rather
than assumed broken or fine. It was, and it had two real defects.

- Validate the target before anything is written: it must exist, live in
  the same namespace, and not be the memory itself. The record used to be
  committed before the edge was attempted, so failing afterwards would
  have meant unwinding six subsystems. Checking first makes failing the
  whole store free, and costs nothing but a lookup.
- Treat a duplicate edge as success, not failure. Supersedes is a desired
  end state, so asking for it twice is idempotent, which also makes client
  retries safe.
- Report the outcome. The store response now carries the target and one of
  applied / alreadyApplied / appliedNotDurable / failed, so a caller can
  tell the difference between a link that was made, one already present,
  one that will not survive restart, and one that was not made. A
  persistence failure is not an error: the memory really was stored and
  the edge really is live this session, and saying otherwise would be a
  lie about a store that succeeded.
- Make parentId actually link, via the same helper, so the two kinds of
  caller-specified edge cannot drift apart again.
- Recall: stop removing a superseded memory before its replacement has
  been validated. Previously the original was dropped first, and if the
  replacement had decayed to ghost or tombstone, or failed to load, the
  result set simply lost it with nothing in its place -- permanently
  unrecallable.
- Recall: run injected replacements through the same filters as every
  other candidate. They bypassed all of them, so a replacement in a
  different namespace leaked into results. That is a data-isolation
  defect, not a ranking nicety.
- Guard the supersedes chain walk with a visited set, so a cycle returns
  None instead of an arbitrary node after ten hops.
- HTTP batch: every silent `continue` now records a failure entry.
  Previously items vanished from the response with no explanation.
- Correct the docs, which claimed the old memory is "deprioritized" in
  search. It is removed and replaced.

Breaking: a non-existent or cross-namespace supersedes/parentId now fails
the store (MCP isError, HTTP 422) where it previously returned success.
Callers passing stale ids will see errors immediately. New response fields
are all optional and skipped when empty, so happy-path payloads are
unchanged, and StoredMemory.supersedes carries serde(default) so a new
client can still decode an old daemon's response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Entities, topics and emotions had a count limit but no per-string length
limit anywhere. Two consequences, both silent.

First, the derived tag. Those labels are merged into tags as
"entity/<name>" after validation, so a 128-byte entity became a 135-byte
tag, which Tag::new rejected, which filter_map(..ok()) discarded without
an error or a log. The caller sent 20 tags, 17 were stored, and nothing
said so. The limits now reserve the prefix -- 121 bytes for entities, 122
for topics, 120 for emotions -- so a request that validates cannot
produce a tag that gets thrown away, and a test asserts the derived tag
survives Tag::new at exactly the limit.

Second, the frame budget. The 8 MiB daemon frame limit was justified by
arithmetic that assumed 128-byte labels; without a per-string limit the
envelope was unbounded and that justification did not hold. There is now
a test that builds the worst-case valid request out of control bytes,
which JSON escapes at 6x, and asserts it serializes within
MAX_MESSAGE_SIZE. It fails the moment anyone raises a content limit or
lowers the frame limit.

- Validate every element of tags, entities, topics and emotions in the
  shared validator, naming the field, the index, the measured bytes and
  the limit. Counts are still reported first when both fail.
- Replace the silent tag drops with parse_tags_lossy, which logs each
  drop with the field, the value and the reason. It stays a drop rather
  than an error because the reachable case is the tag alphabet, not
  length -- an entity named Jose with an accent is legitimate input that
  should not fail a store.
- Close the daemon RPC store_memory path, which deserialized StoreInput
  straight off the socket with no limit checks at all. It was the last
  door through which an over-long label reached storage and was dropped
  while the call reported success.
- Advertise maxLength on the label arrays in both tool schemas, and
  document the limits and the prefix reservation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Two independent reviews of 740cdd1..HEAD. They cleared the areas most at
risk -- resolve_supersedes' index bookkeeping and many-to-one handling,
the shared validator being reachable from all five store doors, no
multi-byte panic in the markup detector, and the daemon's mixed-version
clamp in both directions -- and found the following.

- is_retry_safe was wrong about its own criterion. It listed search and
  find_similar as read-only, but both record accesses, which advance
  last_accessed_at and append FSRS events; replaying one after a mid-call
  socket death would double-count and skew the decay schedule. Removed
  them, and stated the real bar in the doc: no observable state change of
  any kind, not merely "not a write". get_memory stays, because the
  daemon dispatches it to the storage adapter, which does no access
  recording -- the review cited QueryEngine::get_memory, which the socket
  never reaches. Their non-retry error also claimed the operation "may or
  may not have been applied", which is false for a read; those two now
  say plainly that nothing was written.

- parentId still drifted from supersedes on the HTTP door. explicit_links
  exists precisely so the two cannot diverge, but only the MCP path was
  converted; both HTTP sites still called the generic add_edge, which
  persists an edge without bumping edge_count -- so a parent edge counted
  toward ranking or not depending on which door created the memory. Both
  now use the shared helper, and the generic add_edge is removed rather
  than left as the trap that caused this.

- The daemon's drain timeout was bypassable. It guarded the oversized
  path, but a peer declaring exactly the limit and then sending nothing
  hit an untimed read_exact, pinning a connection task and allocating the
  full declared length up front. The payload read is now bounded in time,
  and the buffer grows in chunks, so a declared-but-unsent 8 MiB frame
  costs one chunk instead of 8 MiB.

- Nothing bounded a *sequence* of recoverable frame errors, so a peer
  could stream malformed frames indefinitely, each costing a slot. Eight
  consecutive failures now close the connection; any good frame resets.

- Malformed responses were reported as TooLarge, telling the caller to
  shorten fullText in response to a serialization bug. Now distinguished.

- The markup detector rejected legitimate XML. Bare `<invoke name=` and
  `<parameter name=` were single-signal matches added beyond the design;
  TestNG suite files use exactly that spelling. Real corruption always
  carries the client's vendor prefix, which another rule already catches
  alone, so the bare forms are gone and TestNG and Maven fragments store
  normally.

- sweep_sessions computed its count as a subtraction across a DashMap
  that concurrent initialize handlers mutate, which underflows and panics
  in debug builds. Counted inside retain instead, which is also exact.

- ping's frame-limit negotiation released the lock between the call and
  the write, so a concurrent reconnect to a v1 daemon could have its
  correct 1 MiB clamp overwritten with 8 MiB -- the exact permanent
  stream corruption the design prevents. Not reachable today, since ping
  runs once at startup, but it would become reachable the moment anyone
  adds a periodic health check. The exchange now holds the lock
  throughout.

- is_era_neutral_method promised ping was "answered identically in both
  eras" while the dispatcher correctly returned method-not-found for a
  modern ping. Renamed to is_handshake_exempt_method and documented: only
  server/discover is era-neutral; ping is legacy-only and merely tolerated
  without _meta.

- store_memories emitted "supersedes": null on every batch item, because
  json! ignores skip_serializing_if. The single-memory tool, which
  serializes the struct, was already correct.

Several of the new tests were confirmed to fail against the code they
fix, including the sweep underflow panic, the stale clamp overwrite, and
the connection-pinning timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Cargo.toml has declared `rust-version = "1.94"` since fdf69f7, but every
workflow still installed 1.87 and clippy.toml still claimed 1.87 as the
MSRV. Cargo refuses to build a package whose rust-version exceeds the
active toolchain, so every CI job has been failing on that alone since
the bump landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
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.

Adopt MCP 2026-07-28 spec: stateless HTTP transport, protocol version bump

2 participants