DEV Community

Sanyam Sood
Sanyam Sood

Posted on

I Built an MCP Memory Server Where Code, Not the Model, Decides What Gets Stored

There's a hole in almost every AI memory library, and once you see it you can't unsee it: the model that hallucinates is also the thing you've put in charge of the record.

You hand an LLM a conversation and ask it to remember what matters. It reads, it decides, it writes its interpretation into long-term memory as if it were a fact. Three sessions later your app confidently "knows" something the user never said — and you can't even find where it came from. The fox is guarding the henhouse, and it files the incident report afterward.

I spent a long, humbling year on this problem. What I ended up building — and just published as an MCP server — inverts the arrangement completely. The model doesn't get to decide what's remembered. It gets to propose. Deterministic code, with no model and no prompt anywhere in it, decides what's actually admitted.

This post is the honest version: what it does, where it's genuinely strong, where it's weak, and the night — right before I published — that it lied to me and I nearly shipped it anyway.

The thesis: propose, then adjudicate

The rule is one sentence: the model proposes, deterministic code decides, and nothing ungrounded is committed.

Concretely, your agent doesn't just assert a fact. It asserts a claim and quotes the exact text it's grounding on. Then a gate checks whether the quote actually supports the claim. Watch it refuse me when I try to smuggle an interpretation past the evidence:

remember(claim="Priya joined Acme in 2019 under duress.",
         evidence="Priya Raman joined Acme in 2019 as a logistics analyst.")

REFUSED (asserts_more_than_evidence) — the claim adds something the evidence does not say.
  claim   : Priya joined Acme in 2019 under duress.
  evidence: Priya Raman joined Acme in 2019 as a logistics analyst.
Enter fullscreen mode Exit fullscreen mode

"Under duress" is nowhere in the evidence, so the claim never enters the store. And I can't argue my way in, because the gate isn't a conversation I can be persuasive with — it's a function. That's the whole point. Prompt-injection, jailbreaks, a model just being confidently wrong — none of them help you here, because there's no model on the deciding side to fool.

This is the anti-hallucination check moved to write time, where it's cheap and permanent, instead of hoping a re-ranker or an LLM-judge catches the problem later at read time.

Provenance you can actually audit

When a claim is grounded, it's admitted — and it's stored with the byte range it came from:

ADMITTED — Dana Kim has a cat named Pepper.
  grounding : grounded_verbatim
  receipt   : bytes [0:71] of sha256:b410428a2b58…
Enter fullscreen mode Exit fullscreen mode

That receipt is the part I'm proudest of. It isn't a citation to a chunk that points near the answer. It's (document_hash, byte_start, byte_end) — the precise span. And it's checkable:

verify_receipts
  → receipts re-verified: 1/1 checkable — verified
Enter fullscreen mode Exit fullscreen mode

verify_receipts re-hashes the source document and re-slices the exact byte range. If someone edited the document out from under a stored fact, the receipt doesn't quietly stay green — it fails:

  → receipts re-verified: 0/1 checkable
     FAILED — Dana Kim has a cat named Pepper.
     A failure means the source document changed after the claim was bound to it.
Enter fullscreen mode Exit fullscreen mode

A memory that can be caught lying to you is worth more than one that's confidently smooth. Most "citations" in RAG systems are the smooth kind: they point roughly at a chunk and keep looking fine even after the underlying text has drifted. A byte-range receipt is provenance you can falsify, which is the only kind worth having.

It abstains instead of confabulating

Ask it something outside what it holds, and it doesn't improvise a plausible answer. It tells you exactly where the edge of its knowledge is, and what it does know:

> What is Dana Kim's salary?

ABSTAINED (unknown_predicate) — no claims ground "salary"; 2 claims about Dana Kim exist,
grounding: named, pepper, cat, plays, weekends, basketball
A refusal, not an empty result: the substrate is telling you it has nothing rather than guessing.
Next: ask about one of: named, pepper, cat, plays, weekends — or commit a claim grounding "salary".
Enter fullscreen mode Exit fullscreen mode

Notice the refusal redirects. Most callers here are agents, and an agent can't browse the store to work out what to ask instead — so a bare "no" is where the interaction dies. Naming the grounded terms and the next action turns a dead end into a usable signal. (That redirect, incidentally, was one of the things I fixed the day before launch — more on that below.)

Provable deletion, not hopeful deletion

If you're building anything that touches real users' data, you eventually meet "delete me — and prove you did." forget(subject) is built for exactly that:

forget(subject="Priya Raman")
  → ERASED Priya Raman — certificate issued
     nodes removed        : 3
     bystanders surviving : 1
Enter fullscreen mode Exit fullscreen mode

Exact closure means the subject is fully removed. Bystander survival means everyone else's facts stay intact — deleting Priya doesn't quietly damage the record about the people she was mentioned alongside. And you get a signed certificate as the artifact, not a soft delete you hope worked. That's the difference between telling a compliance officer "trust me" and handing them a receipt.

Two things are true at once

Here's where I stop selling and start being straight with you, because I think it's the more useful mode.

The idea is solid. Model proposes, code decides, byte-range receipts, provable erasure. I've hammered on this and it holds. That's the guarantee, and it's the part I'd stake the project on.

The code is young. It's two days old on PyPI as I write this, and it's young in exactly the way two-day-old code is young. I know this precisely, because of what happened the night before I published.

The night it lied to me

I was about to post. Before I did, I finally did the thing I should always have done and rarely had: I installed my own package the way a total stranger would — a clean environment, over the actual protocol a real client speaks — and I started trying to break it.

It lied to me within minutes.

I told it to remember "Ada Lovelace wrote the first algorithm." It cheerfully returned ADMITTED. It had stored nothing. The write path was reporting success while silently dropping the fact on the floor — which is the single worst bug a memory system can have, and it was sitting right in the front door.

The cause was almost funny. The firewall that classifies incoming claims recognized verbs by their spelling — anything ending in -s, -ed, -ing. So it had simply never heard of "wrote." Or "went." Or "built." Every irregular past tense in English was invisible to it, and any sentence built on one got thrown out as a meaningless fragment. Nine of sixteen perfectly ordinary sentences were being rejected. And the ones that passed mostly passed by luck — "Marcus Webb sold his bookshop" only survived because his ends in an s.

I found three launch-blocking bugs that night. The verb one. A path-traversal input that threw a raw traceback instead of a clean error. A corrupt-store file that crashed the server on every call instead of quarantining itself.

I fixed all three, wrote regression tests so they can't creep back, and only then cut the release you'd install today.

The deeper lesson was the uncomfortable one. That "report ADMITTED while storing nothing" bug was the third time in this codebase I'd made the same category of mistake: compute a decision, then ignore it. A validation result computed and discarded. An abstain flag computed and discarded. And now an ingest result computed and discarded. Computing a check and then not honoring it is worse than not checking at all, because it looks like a check. That pattern is now something I actively hunt for.

The tool that caught all this — a harness that drives the installed binary over stdio, on Python 3.9 through 3.13, throwing malformed JSON-RPC, 46KB payloads, path traversal, null bytes, corrupt store files, and three servers hammering one store at once — is what should have existed at version 0.1.0. It exists now, and it runs on every release.

So when you find a bug — and you will — that's not the project falling apart. That's the loop working. It caught three the night before launch. It'll catch yours.

The honestly weak part

Recall is the soft half, and I'd rather you hear it from me than discover it in your first ten minutes and feel lied to.

On a 410-question set where the answer genuinely is in the store, it still refuses about 37% of the time on a default install (about 25% if you add the optional semantic encoder). That's high, and I publish the number rather than hide it, because the alternative — quietly answering when it shouldn't — is the exact failure this whole project exists to avoid. The trade is deliberate: it optimizes never storing an ungrounded fact and every stored fact being provable, not raw recall. If you want a maximal-recall retriever, this is the wrong tool and I'll tell you so in the comments.

And on the subject of being wrong in public: a couple of months ago I retracted my own benchmark for this project. It had reported a perfect score — zero false assertions — and it turned out to be measuring an empty database. 721 of 722 memory stores were empty, every single answer was the same refusal string, and a corpus where "I don't know" is always the right answer makes an empty store look flawless. That retraction, with the raw data, is public in the repo. If you evaluate abstention or grounding in your own work, that failure mode is worth five minutes regardless of whether you ever touch my code.

Try it — local-first, all the way down

uvx fireweed-mcp
Enter fullscreen mode Exit fullscreen mode

Or wire it into an MCP client:

claude mcp add fireweed -- uvx fireweed-mcp
Enter fullscreen mode Exit fullscreen mode
{
  "mcpServers": {
    "fireweed": { "command": "uvx", "args": ["fireweed-mcp"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. No API keys, no cloud, no model download, no GPU. Nothing in the server runs inference, so it doesn't care what's behind your agent — llama.cpp, Ollama, a frontier API, whatever. Storage is an open format with a stdlib-only reference reader, so your data outlives this project and outlives me.

Licence, up front so nobody feels tricked: FSL-1.1-ALv2. Source-available, not OSI open source — free for anything except building a competing product, and it converts to Apache-2.0 in 2028. I'd rather say that in a paragraph you actually read than let you find it in the LICENSE file and reasonably decide I was being cagey.

Repo, retraction, and raw data: https://github.com/Starksood/fireweed-mcp

I'll be reading comments. The most useful thing you can do is wire it into something, try to break it, and tell me how. Finding the bugs faster than users hit them is the entire game — and you're better at breaking my code than I am.

Top comments (0)