Designing an MCP Server for AI Agent Memory: Lessons from Knownbase
AI coding agents forget everything between sessions. Each new chat or terminal starts from zero: the agent re-reads the code, but it no longer knows why things are the way they are, what was already tried, or what another agent changed yesterday. We built Knownbase, a hosted MCP server that gives agents durable project memory, to fix that. Along the way we learned that an MCP server for AI agent memory is less a storage problem than an interface design problem. The client is a language model with a finite context window. It can't see your database and will do exactly what your tool descriptions suggest.
Design the tool set around the agent's workflow, not the data model
Knownbase speaks the Model Context Protocol over streamable HTTP at a single /mcp endpoint. The tool set falls into a few groups:
- Orientation and resume:
get_contextto start a task,get_changes_sinceto catch up. - Writing knowledge:
rememberfor a single durable fact, andcheckpointto close a work session with its decisions, open items and next actions. - Direct note access: search, single and batch reads, create/update, partial patch, and soft delete.
- History: listing and reading a note's prior revisions.
- Workspace housekeeping: project and tag renames, project export, and usage information.
The tool descriptions are written as instructions. get_context starts with "START HERE for project work", and get_changes_since with "RESUME AFTER ANOTHER AGENT OR HUMAN MAY HAVE CHANGED THE PROJECT". Agents pick tools from their descriptions, so the descriptions are the real user interface. Plain CRUD alone would push every agent into several cold searches at the start of every task.
Pack context to a token budget
get_context returns a project's current state in one call: the project summary and agent instructions, active constraints, canonical decisions, failed approaches, current status, relevant notes, open items, recent changes, and any unresolved conflicts. It all has to fit a budget. There are three detail levels (lean, balanced, deep), each with its own token budget and excerpt length. An explicit maxTokens overrides the project's configured default, which in turn overrides the detail level.
Candidates are scored on query relevance, recency, importance, memory type, canonical status and whether other notes link to them. Recency uses hyperbolic rather than exponential decay. A two-year-old architecture decision should count for less than last week's, but not for nothing, because old decisions are often the ones an agent most needs and is least likely to rediscover.
Packing follows two rules:
// header and conflicts are charged first — a verbose constraint
// must never push out the warning it conflicts with
let spent = estimateTokens(header);
for (const name of contextSectionOrder) {
for (const { row, score } of buckets[name]) {
const full = contextEntry(row, score, excerptChars);
if (spent + estimateTokens(full) <= budget) {
/* take it */ continue;
}
// degrade before dropping: a one-line pointer beats absence
const degraded = contextEntry(row, score, contextDegradedExcerptChars);
if (spent + estimateTokens(degraded) <= budget) {
/* take it */ continue;
}
omitted.push({ id: row._id, section: name });
}
}
Sections are packed in a fixed order, starting with constraints. An entry that doesn't fit is first shortened to a short excerpt that keeps its ID, and is dropped only if even that won't fit. The response's meta block reports the budget, the estimated tokens spent, and how many candidates were selected or omitted, with omitted IDs. An agent can see what it missed and fetch it deliberately.
Lean responses by default
The same principle applies to every read. search_notes and list_note_revisions return lean results by default: ID, title, snippet, body size, no full body. Callers opt in with includeBody: true, or follow up with get_notes, which batch-fetches up to 50 IDs in one round trip. We also added patch_note for append, prepend or exact literal replace, because rewriting a whole note to fix one sentence costs the entire note twice, once in each direction. A patch whose search text is absent fails loudly.
Cursor-based resume
Agents rarely work alone, and reloading a whole project to find out what moved is wasteful. So get_changes_since returns a bounded, oldest-first stream of changes after a timestamp or after an opaque cursor from the previous response.
The cursor orders by updatedAt plus note ID, so notes that share a millisecond are neither skipped nor repeated across pages. It also records the project and the type and lifecycle filters, so a caller continuing a cursor can't accidentally change the question halfway through. Only the page size and token budget can change between pages. Each change carries an excerpt and body size, not the full body. The contract: follow every page while hasMore is true, then save the cursor for next time.
We deliberately don't infer friendly buckets such as "decisions changed". The server reports created, updated or deleted plus current state, which is what the stored document actually proves.
Typed memory with a lifecycle
Every note can carry memory metadata: a type (decision, constraint, architecture, fact, incident, failed approach, procedure, status, task, handoff, preference, reference, or plain note) and a status (current, superseded, historical, disputed, pending).
The status is what makes retrieval trustworthy. get_context excludes superseded and historical claims by default, so it returns what is true now, not everything that was ever true. When a new memory supersedes an old one, the old note is marked superseded and gets a server-derived supersededBy pointer back to its replacement, which keeps the history navigable in both directions. get_context also reports two kinds of conflict worth interrupting an agent for: a claim explicitly marked disputed, and a supersession that never finished (A says it replaces B, but B is still current).
Duplicate and contradiction detection on remember
remember takes content and, ideally, a type. It derives the title, checks the project for near-identical or contradicting memories, and returns one of four statuses: created, updated, duplicate or conflict. A conflict means nothing was stored. The response names the existing note and the exact call that resolves it: re-send with supersedes: [id] if the new claim replaces the old one, or force: true if both are true.
The hard part was telling a restatement from a reversal. Word-level similarity can't do it. "Opaque tokens instead of JWTs" and "JWTs instead of opaque tokens" use the same words. So the server also compares word order through bigrams, measured two ways. Symmetric similarity answers "is this the same text?" Coverage of the old note's bigrams by the new text answers "is the old claim contained in the new one?" A write only counts as a restatement, and updates the existing note in place, if it is similar, covers most of the existing note, and is longer. The restatement check runs before the conflict check. When we ran them the other way round, every elaborated decision came back as a conflict, which would have taught agents to send force: true by reflex.
We also accepted a limit of lexical scoring. Contradicting claims often score like unrelated ones. When a decision, constraint or architecture note is created, the response therefore lists same-type neighbours and sets reviewRequired. The caller is a language model that can read two sentences and tell whether they disagree, a judgement a similarity coefficient can't make. Retries are safe too: an optional idempotencyKey makes a repeated remember call return the original result instead of writing a second note.
Revisions and optimistic concurrency
Every edit to an existing note saves the previous body as a revision, capped per plan, with the oldest pruned first. Each note carries a version that increments on every edit. An editor can pass the version it loaded as expectedVersion. If the note has moved on since, the write is refused with an HTTP 409 and a message saying which version the caller had and which is current:
if (
existing &&
input.expectedVersion != null &&
Number(input.expectedVersion) !== (existing.version || 1)
) {
throw publicError(
`This note changed since you loaded it (you have v${input.expectedVersion}, current is v${existing.version || 1}). Reload and reapply your edit.`,
409
);
}
The check is optional: clients that never send expectedVersion keep last-write-wins, and clients that care about concurrent agents get a clear failure instead of a silent overwrite.
OAuth 2.1 with dynamic client registration and PKCE
The server supports static API keys (including project-scoped and read-only keys) and a full OAuth 2.1 flow that clients discover on their own:
- A
401from/mcpcarries aWWW-Authenticateheader pointing to the protected-resource metadata, which points to the authorization server metadata. - Dynamic Client Registration (RFC 7591) lets a client register itself. It always registers a public client with no secret, because every authorization request needs PKCE anyway and a client secret would add a credential to protect without adding security.
- PKCE with
S256is required. Requests without it are rejected. - Loopback redirect URIs for native and CLI clients match on everything except the port, because those clients bind a new port on every run. Every other redirect URI must match byte for byte.
- Refresh tokens rotate on every use. A rotated token is marked, not deleted. If someone reuses one, the server treats it as theft: it revokes the whole token family and deletes that family's access tokens too.
Search that degrades gracefully
search_notes takes semantic: true, but semantic search depends on the plan, the deployment and an embeddings provider being available. Any of those can be missing, and an embeddings API can fail temporarily. So the semantic path tries a vector index, then an in-process ranked scan, and on any failure falls through to keyword search: a full-text index where one exists, and a substring match where it doesn't. Every response reports searchMode, so the agent knows which one it got, and passing semantic: true is always safe: the worst case is keyword results, never an error.
Try it, or have us build yours
Knownbase is live at knownbase.dev and works with any MCP client that supports remote servers. If you're designing an MCP server or an agent-facing API for your own product, our API development team can help you get the contract right the first time.