Build a Durable Agent Memory MCP Server
Build a Python MCP server that stores and ranks durable coding-agent memories.
Introduction
30 Second Summary
A useful decision can disappear the moment a coding session ends. The next session then wastes time rediscovering the same reasoning.
In this project, you will build a local memory server for coding agents with TypeScript and the Model Context Protocol. It preserves decisions in a durable file so future sessions can find the reasoning behind earlier work.
What You'll Build
You'll demo a coding agent finding a transport decision after a server restart with its original session context attached.
By the end of this project, you'll have:
- A validated memory flow that saves decisions, rationales, findings, and failed approaches with session metadata in a durable JSON dataset.
- A ranked recall system that finds related records through weighted fields and recency scoring.
- A source-attributed retrieval flow that opens a complete memory by its generated identifier. Your saved history remains available after the server restarts.
- Secret Mission: Add a session_summary tool that turns one session's records into a compact activity digest.
Are there any prerequisites?
Basic JavaScript or TypeScript syntax helps. You need no MCP experience, cloud account, or AI subscription because every resource runs locally.
Before We Start
A durable memory service starts with a clear purpose. This is your moment to commit to what your local MCP server will remember for future agent sessions.
Set Up the TypeScript MCP Project
Your durable memory server cannot exchange MCP messages until its runtime versions match the project. A pinned setup keeps every later tool call reproducible.
In this step, you will install Node.js v24.20.0 with npm 11.19.0. You will prepare Visual Studio Code for editing from Terminal.
You will also create the empty project structure. Its pinned dependencies provide the foundation for the server and MCP Inspector.
In this step, get ready to:
- Install the pinned Node.js runtime.
- Prepare Visual Studio Code for Terminal launches.
- Build the pinned project workspace.
Install and verify your tools
Node.js runs the TypeScript server during development. The pinned release includes the npm version this project expects.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open macOS Terminal.
You will see a Terminal window where you can check the runtime already available on your Mac.
- Check the active Node.js version by running this command:
node --version
This check reports which Node.js runtime Terminal can reach.
- Check the bundled npm version by running this command:
npm --version
This check reports the npm version paired with your active Node.js installation.
✔️ I see v24.20.0 and 11.19.0
Your runtime is ready. Terminal can reach the exact Node.js and npm versions used throughout this project.
ⓧ I see an older version
The project uses Node.js v24.20.0. Installing its macOS package updates the runtime available to Terminal.
- Download the macOS installer for Node.js v24.20.0 from the official release page.
- Open the downloaded macOS installer package.
- Follow the installer prompts using the default options.
- Restart macOS Terminal so it reads the updated runtime.
- Repeat both version checks shown above.
You should now see v24.20.0 for Node.js. You should see 11.19.0 for npm.
Still seeing the older runtime?
- Close every open Terminal window before starting a fresh one.
- Run the two version checks again in the fresh Terminal window.
If the older version remains active, help me diagnose the Node.js version selected by macOS Terminal.
ⓧ Command not found
Terminal cannot reach a Node.js installation yet. The official macOS package installs Node.js with its bundled npm release.
- Download the macOS installer for Node.js v24.20.0 from the official release page.
- Open the downloaded macOS installer package.
- Follow the installer prompts using the default options.
- Restart macOS Terminal so it reads the installed runtime.
- Repeat both version checks shown above.
You should now see v24.20.0 for Node.js. You should see 11.19.0 for npm.
Commands still unavailable?
- Confirm the macOS installer completed before reopening Terminal.
- Restart your Mac if Terminal still cannot locate the new installation.
If the commands remain unavailable, help me troubleshoot a missing Node.js command after installing the macOS package.
Visual Studio Code gives the project a folder-based workspace. Its command-line launcher lets Terminal open that workspace directly.
✔️ I already use Visual Studio Code
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
- Press Cmd+Shift+P to open the Command Palette.
- Type shell command into the Command Palette.
- Select Shell Command: Install 'code' command in PATH.
- Restart macOS Terminal so it reads the new PATH value.
Visual Studio Code is now ready to open folders through the code command.
ⓧ I need to install Visual Studio Code
- Download the macOS disk image from the official Visual Studio Code download page.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
You will see the Visual Studio Code window. The remaining setup adds its Terminal launcher to your PATH.
- Press Cmd+Shift+P to open the Command Palette.
- Type shell command into the Command Palette.
- Select Shell Command: Install 'code' command in PATH.
- Restart macOS Terminal so it reads the new PATH value.
Visual Studio Code will not open?
- Confirm that Visual Studio Code.app is inside the Applications folder.
- Open the app once from the Applications folder before trying Spotlight again.
If the app or PATH setup still fails, help me troubleshoot my Visual Studio Code installation on macOS.
Create the project structure
A folder-based workspace keeps the server code beside its durable memory file. The empty storage file gives later tools valid JSON from their first read.
- Select File from the Visual Studio Code menu bar.
- Select Open Folder....
- Choose Desktop in the macOS folder picker.
- Select New Folder.
- Enter agent-memory-mcp as the folder name.
- Select the new agent-memory-mcp folder.
- Click Open.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
You will see agent-memory-mcp at the top of the Explorer view. Visual Studio Code now treats it as your project workspace.
- Create src inside agent-memory-mcp with the new-folder control in the Explorer view.
- Create data inside agent-memory-mcp with the same control.
The Explorer now shows separate folders for server code and stored memory records.
- Create src/index.ts with the New File... button in Explorer.
- Create package.json with the New File... button in Explorer.
The empty src/index.ts file is ready for the server code you will add in the next step.
- Create data/memories.json with the New File... button in Explorer.
- Select package.json in Explorer.
- Replace its contents with the code below:
{
"name": "agent-memory-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"inspect": "npx @modelcontextprotocol/inspector npx tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"zod": "4.5.4"
},
"devDependencies": {
"@modelcontextprotocol/inspector": "2.5.0",
"tsx": "4.23.13"
}
}
What does this file define?
- The scripts section defines the development server command. It also defines the Inspector command used later.
- The MCP TypeScript SDK provides the server APIs used to expose tools.
- Zod validates the inputs sent to those tools.
- The exact dependency versions keep every installation consistent.
- Save package.json.
- Confirm the editor shows no red JSON error markers.
Seeing a JSON error marker?
- Check that every property name remains inside double quotes.
- Compare each comma with the reference code below.
If the marker remains, help me find the JSON syntax problem in my package file.
- Select data/memories.json in Explorer.
- Replace its contents with the code below:
[]
Why start with an empty array?
The JSON array becomes the durable dataset for every saved memory. Starting with an empty array lets the server load valid storage before any records exist.
- Save data/memories.json.
- Confirm the file contains exactly [].
Storage file looks different?
- Remove any braces or sample records from data/memories.json.
- Leave one empty JSON array in the file.
If the file still shows a JSON warning, help me correct the empty memories dataset.
✔️ Awesome, I've got everything!
Your project structure is ready. Double check that both JSON files are saved before installing the dependencies.
ⓧ I'd like to double check the full code
Your package.json file should look exactly like this:
{
"name": "agent-memory-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"inspect": "npx @modelcontextprotocol/inspector npx tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"zod": "4.5.4"
},
"devDependencies": {
"@modelcontextprotocol/inspector": "2.5.0",
"tsx": "4.23.13"
}
}
This reference contains every pinned script and dependency required by the project.
Your data/memories.json file should look exactly like this:
[]
This reference confirms that the durable dataset starts as an empty JSON array.
Install dependencies and verify the setup
The pinned package file describes the toolchain. Installing it turns that description into a local dependency set with a generated lock file.
- Select View from the Visual Studio Code menu bar.
- Select Terminal.
The integrated Terminal opens inside the agent-memory-mcp folder. Commands run here apply to this project.
- Install the pinned project dependencies by running this command:
npm install
What does this command do?
The command reads package.json and downloads its exact dependencies. It also generates package-lock.json to preserve the resolved installation.
- Confirm the installation finishes without an error.
- Confirm package-lock.json appears in the Explorer view.
That setup is now reproducible. The project has a locked dependency tree ready for the server code.
Dependency installation failed?
- Confirm the integrated Terminal path ends with agent-memory-mcp.
- Confirm package.json has no JSON error markers.
- Run the installation command again after restoring the exact package file from the reference tab.
If npm still cannot install the packages, help me troubleshoot the dependency installation.
- Verify the Visual Studio Code launcher in your PATH by running this command:
code .
Visual Studio Code opens the agent-memory-mcp folder from Terminal. This confirms that the code command is available in PATH.
The launcher did not open the project?
- Return to Visual Studio Code from earlier.
- Run Shell Command: Install 'code' command in PATH from the Command Palette again.
- Restart macOS Terminal before retrying the launcher.
If the launcher remains unavailable, help me fix the Visual Studio Code command in PATH.
Before you run the final checks, which two pinned versions should Terminal report?
- Check the final Node.js version by running this command:
node --version
You will see v24.20.0. This confirms the expected Node.js runtime is active.
- Check the final npm version by running this command:
npm --version
You will see 11.19.0. This confirms the package manager matches the pinned runtime.
Everything is in place. Your editor can open the project from Terminal while npm can install its exact dependency set.
Your TypeScript MCP workspace is ready. Next up, you will give the server a durable tool for saving agent-session memories.
Build the Durable Remember Tool
Your TypeScript project is ready in Visual Studio Code. The server now needs a reliable way to keep an agent's session history.
The MCP tool called remember validates each submission. It stores the finished record in a JSON file so the memory survives server restarts.
In this step, get ready to:
- Build the typed storage layer for memory records.
- Register the validated remember tool.
- Persist a transport decision through MCP Inspector.
Define the memory record and storage helpers
Every stored memory needs the same fields so future tools can trust its shape. The storage helpers provide one path for reading the dataset and one path for replacing it.
- Add the imports and record definitions to the empty src/index.ts file by pasting this code:
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
type MemoryCategory =
| "decision"
| "rationale"
| "finding"
| "failed-approach";
interface MemoryRecord {
id: string;
sessionId: string;
agent: string;
project: string;
category: MemoryCategory;
summary: string;
context: string;
tags: string[];
createdAt: string;
}
const MEMORY_FILE = new URL("../data/memories.json", import.meta.url);
What does this foundation do?
- The imports provide identifier generation, file access, server creation, transport support, and input validation.
- MemoryCategory limits every record to one supported category.
- MemoryRecord preserves the memory plus its source metadata.
- MEMORY_FILE resolves the storage file from the location of src/index.ts.
- Save src/index.ts.
- Verify the record shape by checking that MemoryRecord includes id, sessionId, agent, project, category, summary, context, tags, and createdAt.
Seeing import or type errors?
Check each import against the snippet above. A missing quote or brace can make the rest of the file appear broken.
Confirm that MemoryCategory appears before MemoryRecord.
Help me compare my imports and memory types.
- Add the durable read and write helpers directly below MEMORY_FILE by pasting this code:
async function loadMemories(): Promise<MemoryRecord[]> {
const contents = await readFile(MEMORY_FILE, { encoding: "utf8" });
return JSON.parse(contents) as MemoryRecord[];
}
async function saveMemories(memories: MemoryRecord[]): Promise<void> {
await writeFile(
MEMORY_FILE,
`${JSON.stringify(memories, null, 2)}\n`,
{ encoding: "utf8" },
);
}
How does storage work?
- loadMemories() reads the current contents of data/memories.json.
- JSON.parse() converts the stored text into an array of memory records.
- saveMemories() formats the complete array before writing it back to the same file.
- The final newline keeps the saved file tidy.
- Save src/index.ts.
Before you run this check, do you expect the source file to load cleanly or stop on a syntax problem?
- Check the source file by running this command in the Terminal from earlier:
npm run dev
What does this command check?
npm runs the existing dev script from package.json. The script loads src/index.ts through tsx.
You'll see the script start before the Terminal returns to its prompt without a syntax error.
Does the syntax check stop with an error?
Check that both helper functions have matching braces. Confirm that the writeFile() call ends with its closing parenthesis.
Help me fix my storage helper syntax.
Register the validated remember tool
A tool schema acts as the contract for every memory submission. Zod rejects missing values before the storage code accepts the record.
- Paste this first section beneath saveMemories(). It stays open for the handler in the next chunk:
function createServer(): McpServer {
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
server.registerTool(
"remember",
{
description:
"Store a durable decision, rationale, finding, or failed approach from an agent session.",
inputSchema: z.object({
sessionId: z.string().min(1),
agent: z.string().min(1),
project: z.string().min(1),
category: z.enum([
"decision",
"rationale",
"finding",
"failed-approach",
]),
summary: z.string().min(1),
context: z.string().min(1),
tags: z.array(z.string().min(1)).max(10),
}),
},
What does the schema protect?
- McpServer gives the local service its identity.
- registerTool() exposes remember as a callable tool.
- The string rules reject empty source fields.
- The category enum accepts only the four supported memory categories.
- The tag rule accepts up to ten non-empty tags.
- This section ends at the schema boundary. The next block supplies the callback that completes the registration.
- Complete the open registerTool() call by pasting this continuation directly below it:
async ({ sessionId, agent, project, category, summary, context, tags }) => {
const memories = await loadMemories();
const memory: MemoryRecord = {
id: randomUUID(),
sessionId,
agent,
project,
category,
summary,
context,
tags,
createdAt: new Date().toISOString(),
};
memories.push(memory);
await saveMemories(memories);
return {
content: [
{
type: "text",
text: `Saved memory ${memory.id} for session ${memory.sessionId}.`,
},
],
};
},
);
return server;
}
What happens after validation?
- loadMemories() retrieves the records already stored on disk.
- Each accepted submission receives a UUID from randomUUID().
- createdAt records the current time as an ISO timestamp.
- memories.push() adds the new record to the existing array.
- saveMemories() persists the updated array.
- The returned text gives the caller the generated memory ID.
- Save src/index.ts.
Before you run the syntax check again, do you expect the completed server factory to load successfully?
- Check the completed server factory by running:
npm run dev
What does this check prove?
The command loads the completed createServer() definition. Returning to the Terminal prompt confirms that the two code chunks form valid source syntax.
The Terminal returns to its prompt without reporting a syntax error.
Is the server factory failing to load?
Check that the first chunk ends after the tool configuration. Confirm that the second chunk begins with the async callback.
Compare the closing braces near return server because a missing closure can make the final lines fail.
Help me repair my remember tool registration.
Serve the tool and persist a decision
The server factory now contains the tool. A stdio transport connects that factory to the local Inspector process.
- Add the startup lines below createServer() at the bottom of src/index.ts by pasting this code:
void serveStdio(createServer);
console.error("agent-memory MCP server running on stdio");
How does the server start?
- serveStdio() receives the server factory so local clients can exchange MCP messages through standard input and output.
- console.error() writes the startup status away from the protocol response stream.
- Save src/index.ts.
Keep the Inspector process running
The Inspector stays attached to the Terminal process during testing. Press Ctrl+C when you finish to stop the server.
- Start the server and Inspector from the Terminal by running this command:
npm run inspect
What does this command launch?
npm runs the existing inspect script from package.json. The script starts src/index.ts through tsx.
The command also starts the browser interface used to connect to the local server.
- Use the preconfigured connection controls in the Inspector page to connect to the local server.
- Open the available tool list after the connection succeeds.
- Select remember from the tool list.
You'll see an input form containing the seven fields defined by the remember schema.
Cannot connect to the server?
Keep the Terminal process running while the Inspector connects. Check the Terminal for a source error if the connection closes immediately.
Help me connect MCP Inspector to my local server.
- Enter mcp-build-001 in the sessionId field.
- Enter coding-agent in the agent field.
- Enter agent-memory-mcp in the project field.
- Set category to decision.
- Enter Use stdio for the local MCP server in the summary field.
- Enter Stdio keeps the server local and avoids adding an HTTP port. in the context field.
- Add one item to the tags array.
- Enter transport in the new tag item.
Before you submit the call, do you expect data/memories.json to remain empty or gain one record?
- Submit the remember tool call.
You'll see a saved-memory confirmation containing a generated ID and the session mcp-build-001. That confirmation proves the tool accepted the validated input.
- Return to the Visual Studio Code window from earlier.
- Select data/memories.json in the project file sidebar.
- Review the object inside the JSON array.
You'll see the submitted source fields plus a generated id and createdAt value. The record now lives outside the running server process.
Is the memory file still empty?
Confirm that the tool result reports a saved memory before checking the file. Reopen data/memories.json if the editor is showing an older view.
Check that the path in MEMORY_FILE points from src/index.ts to the existing data/memories.json file.
Help me find why my memory was not persisted.
✔️ Awesome, I've got everything!
Great. Double check that src/index.ts is saved and that data/memories.json contains your transport decision.
ⓧ I'd like to double check the full code
Here is the complete src/index.ts file for this step:
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
type MemoryCategory =
| "decision"
| "rationale"
| "finding"
| "failed-approach";
interface MemoryRecord {
id: string;
sessionId: string;
agent: string;
project: string;
category: MemoryCategory;
summary: string;
context: string;
tags: string[];
createdAt: string;
}
const MEMORY_FILE = new URL("../data/memories.json", import.meta.url);
async function loadMemories(): Promise<MemoryRecord[]> {
const contents = await readFile(MEMORY_FILE, { encoding: "utf8" });
return JSON.parse(contents) as MemoryRecord[];
}
async function saveMemories(memories: MemoryRecord[]): Promise<void> {
await writeFile(
MEMORY_FILE,
`${JSON.stringify(memories, null, 2)}\n`,
{ encoding: "utf8" },
);
}
function createServer(): McpServer {
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
server.registerTool(
"remember",
{
description:
"Store a durable decision, rationale, finding, or failed approach from an agent session.",
inputSchema: z.object({
sessionId: z.string().min(1),
agent: z.string().min(1),
project: z.string().min(1),
category: z.enum([
"decision",
"rationale",
"finding",
"failed-approach",
]),
summary: z.string().min(1),
context: z.string().min(1),
tags: z.array(z.string().min(1)).max(10),
}),
},
async ({ sessionId, agent, project, category, summary, context, tags }) => {
const memories = await loadMemories();
const memory: MemoryRecord = {
id: randomUUID(),
sessionId,
agent,
project,
category,
summary,
context,
tags,
createdAt: new Date().toISOString(),
};
memories.push(memory);
await saveMemories(memories);
return {
content: [
{
type: "text",
text: `Saved memory ${memory.id} for session ${memory.sessionId}.`,
},
],
};
},
);
return server;
}
void serveStdio(createServer);
console.error("agent-memory MCP server running on stdio");
What should match?
Your file should contain the storage helpers, one remember registration, and the stdio startup lines in this order.
Your server can now preserve validated agent history across restarts. Next, you'll add the first recall path for these saved records.
Test Exact-Match Recall
Your MCP server already preserved one transport decision in data/memories.json. That record survived outside the server process.
A useful memory server also needs a way to find stored records. This step creates a narrow recall baseline that searches one field for one complete phrase.
In this step, get ready to:
- Register a Zod-validated recall tool.
- Search only the memory summary with a case-insensitive substring check.
- Test the persisted decision with transport decision.
Register the recall tool
The first retrieval path accepts a search query through a validated tool input. Its handler loads the persisted records before checking their summaries.
- Select src/index.ts in the Visual Studio Code Explorer sidebar.
- Locate the return server line inside createServer().
- Insert this tool registration immediately above that line:
server.registerTool(
"recall",
{
description: "Find agent memories whose summaries contain the query.",
inputSchema: z.object({
query: z.string().min(1),
}),
},
async ({ query }) => {
const memories = await loadMemories();
const matches = memories.filter((memory) =>
memory.summary.toLowerCase().includes(query.toLowerCase()),
);
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No related memories found." }],
};
}
return {
content: [
{
type: "text",
text: matches.map((memory) => `[${memory.id}] ${memory.summary}`).join("\n"),
},
],
};
},
);
What does this code do?
- The inputSchema requires a non-empty query string.
- The handler uses loadMemories() to read the persisted records.
- The filter lowercases each summary plus the query before checking the complete phrase.
- The fallback returns No related memories found. when no summary contains that phrase.
- Save src/index.ts.
- Return to the macOS Terminal window running MCP Inspector.
- Stop the existing process with Ctrl+C.
- Restart the server through MCP Inspector by running:
npm run inspect
- Return to MCP Inspector in your browser.
- Reconnect MCP Inspector to the restarted stdio server.
Good progress. The restarted server now advertises both remember and recall.
Recall tool missing after restart?
- Confirm the recall registration remains inside createServer().
- Save src/index.ts again.
- Stop the Inspector process before running the restart command again.
Help me debug why my recall tool is missing.
✔️ Awesome, I've got everything!
Your saved src/index.ts now registers the durable remember tool plus the summary-only recall tool.
ⓧ I'd like to double check the full code
Your src/index.ts should match this file:
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
type MemoryCategory =
| "decision"
| "rationale"
| "finding"
| "failed-approach";
interface MemoryRecord {
id: string;
sessionId: string;
agent: string;
project: string;
category: MemoryCategory;
summary: string;
context: string;
tags: string[];
createdAt: string;
}
const MEMORY_FILE = new URL("../data/memories.json", import.meta.url);
async function loadMemories(): Promise<MemoryRecord[]> {
const contents = await readFile(MEMORY_FILE, { encoding: "utf8" });
return JSON.parse(contents) as MemoryRecord[];
}
async function saveMemories(memories: MemoryRecord[]): Promise<void> {
await writeFile(
MEMORY_FILE,
`${JSON.stringify(memories, null, 2)}\n`,
{ encoding: "utf8" },
);
}
function createServer(): McpServer {
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
server.registerTool(
"remember",
{
description:
"Store a durable decision, rationale, finding, or failed approach from an agent session.",
inputSchema: z.object({
sessionId: z.string().min(1),
agent: z.string().min(1),
project: z.string().min(1),
category: z.enum([
"decision",
"rationale",
"finding",
"failed-approach",
]),
summary: z.string().min(1),
context: z.string().min(1),
tags: z.array(z.string().min(1)).max(10),
}),
},
async ({ sessionId, agent, project, category, summary, context, tags }) => {
const memories = await loadMemories();
const memory: MemoryRecord = {
id: randomUUID(),
sessionId,
agent,
project,
category,
summary,
context,
tags,
createdAt: new Date().toISOString(),
};
memories.push(memory);
await saveMemories(memories);
return {
content: [
{
type: "text",
text: `Saved memory ${memory.id} for session ${memory.sessionId}.`,
},
],
};
},
);
server.registerTool(
"recall",
{
description: "Find agent memories whose summaries contain the query.",
inputSchema: z.object({
query: z.string().min(1),
}),
},
async ({ query }) => {
const memories = await loadMemories();
const matches = memories.filter((memory) =>
memory.summary.toLowerCase().includes(query.toLowerCase()),
);
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No related memories found." }],
};
}
return {
content: [
{
type: "text",
text: matches.map((memory) => `[${memory.id}] ${memory.summary}`).join("\n"),
},
],
};
},
);
return server;
}
void serveStdio(createServer);
console.error("agent-memory MCP server running on stdio");
Run the mismatch test
Before you call the tool, do you think one complete search phrase can find a record when its words live in separate metadata fields?
- Return to MCP Inspector in your browser.
- Select recall from the available tools.
- Enter transport decision for query.
- Call the selected tool.
You'll see No related memories found. That miss is the intended result.
- Return to data/memories.json in the Visual Studio Code Explorer sidebar.
- Locate the record with mcp-build-001 as its sessionId.
- Compare its summary with its category plus tags.
The word decision sits in category. The word transport sits in tags.
The current search checks only summary for the complete lowercased phrase. It ignores every metadata field that could connect this query to the stored record.
You found the retrieval gap through a real tool call. Next, you'll rank memories using their summaries plus the metadata that gives each record meaning.
Rank Relevant Memories
Your first recall test exposed a real retrieval gap. The saved transport decision exists, but an exact summary search cannot find the useful metadata around it.
This step replaces that narrow check with weighted retrieval. Your server will score several memory fields before returning the strongest source-attributed results in MCP Inspector.
In this step, get ready to:
- Normalize recall queries into searchable tokens.
- Score memories across content, metadata, and recency.
- Rank the five strongest matches with source attribution.
Normalize the search terms
Tokenization turns a phrase into consistent search terms. Lowercasing removes case differences while the length filter drops tokens that contain little meaning.
- In src/index.ts, find the closing brace of saveMemories().
- Add the tokenization helper directly below it by pasting this code:
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2);
}
What does this code do?
- The lowercase conversion makes matching independent of capitalization.
- The split pattern treats every non-alphanumeric character as a word boundary.
- The final filter removes tokens containing two characters or fewer.
- Save src/index.ts.
- Confirm that the editor reports no TypeScript errors inside tokenize().
Seeing an error in tokenize?
Check that the regular expression contains the exact character groups shown above. Confirm that tokenize() sits outside saveMemories().
Use this prompt if the editor still highlights the helper: Help me fix the TypeScript error in my tokenize function.
Score content and source metadata
A weighted score lets strong fields contribute more to retrieval. Summary matches receive the highest weight while source metadata still helps related memories surface.
- Add the opening portion of scoreMemory() directly below tokenize() by pasting this first fragment:
function scoreMemory(memory: MemoryRecord, query: string): number {
const queryTokens = tokenize(query);
const fields = [
{ text: memory.summary, weight: 4 },
{ text: memory.tags.join(" "), weight: 3 },
{ text: memory.category, weight: 2 },
{ text: memory.project, weight: 2 },
{ text: memory.context, weight: 1 },
{ text: memory.agent, weight: 1 },
{ text: memory.sessionId, weight: 1 },
];
let score = 0;
How are the fields weighted?
- The summary receives weight 4 because it states the memory's main point.
- Tags receive weight 3 because they capture deliberate retrieval labels.
- Category and project receive weight 2 because they provide strong grouping signals.
- Context, agent, and session ID receive weight 1 because they add useful source evidence.
- Complete scoreMemory() by pasting this second fragment immediately after let score = 0;:
for (const queryToken of queryTokens) {
for (const field of fields) {
const fieldTokens = tokenize(field.text);
const matches = fieldTokens.some(
(fieldToken) =>
fieldToken.includes(queryToken) || queryToken.includes(fieldToken),
);
if (matches) {
score += field.weight;
}
}
}
if (score === 0) {
return 0;
}
const ageMilliseconds = Date.now() - Date.parse(memory.createdAt);
const ageDays = Math.max(0, ageMilliseconds / 86_400_000);
const recencyBonus = 1 / (1 + ageDays / 30);
return score + recencyBonus;
}
How does the score work?
- The nested loops compare every query token with the tokens from each memory field.
- A partial token match adds the weight assigned to that field.
- A zero field score removes unrelated records before ranking.
- The recency calculation gives newer matching memories a small additional score.
- Save src/index.ts.
- Confirm that the editor reports no unclosed braces inside scoreMemory().
Seeing an error in scoreMemory?
Check that the second fragment follows let score = 0; inside the same function. A misplaced closing brace can move the loops outside the function.
Use this prompt to inspect the function structure: Help me find the misplaced brace in scoreMemory.
Ranked results also need a stable display format. One formatter keeps the identifier, source fields, score, and complete context attached to every result.
- Add formatMemory() directly below scoreMemory() by pasting this code:
function formatMemory(memory: MemoryRecord, score?: number): string {
const scoreLine = score === undefined ? "" : `\nScore: ${score.toFixed(2)}`;
return `[${memory.id}] ${memory.summary}
Project: ${memory.project}
Session: ${memory.sessionId}
Agent: ${memory.agent}
Category: ${memory.category}
Tags: ${memory.tags.join(", ")}
Created: ${memory.createdAt}${scoreLine}
Context: ${memory.context}`;
}
Why format every source field?
The optional score line appears for ranked recall results. The remaining lines preserve the record's identity and source metadata.
That attribution lets a future tool trace a result back to the session that produced it.
- Save src/index.ts.
- Confirm that the editor reports no TypeScript errors inside formatMemory().
Seeing a template string error?
Check that the returned record starts with a backtick before the opening square bracket. Confirm that the final backtick appears after memory.context.
Use this prompt if the template still fails: Help me fix the formatMemory template string.
Replace the exact-match recall
The current callback filters one complete phrase against the summary. The new callback scores every record before sorting the strongest matches.
- In the existing recall registration, find the callback that begins with async ({ query }) => {.
- Replace that callback with this ranked retrieval callback:
async ({ query }) => {
const memories = await loadMemories();
const ranked = memories
.map((memory) => ({ memory, score: scoreMemory(memory, query) }))
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 5);
if (ranked.length === 0) {
return {
content: [{ type: "text", text: "No related memories found." }],
};
}
return {
content: [
{
type: "text",
text: ranked
.map(
({ memory, score }, index) =>
`${index + 1}. ${formatMemory(memory, score)}`,
)
.join("\n\n"),
},
],
};
}
What changed in recall?
- The mapping stage pairs every memory with its calculated score.
- The filter removes records that matched none of the query tokens.
- The sort places the highest score first.
- The slice limits each response to five records.
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Double check that src/index.ts is saved before restarting the Inspector process.
ⓧ I'd like to double check the full code
Compare your cumulative src/index.ts with this version:
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
type MemoryCategory =
| "decision"
| "rationale"
| "finding"
| "failed-approach";
interface MemoryRecord {
id: string;
sessionId: string;
agent: string;
project: string;
category: MemoryCategory;
summary: string;
context: string;
tags: string[];
createdAt: string;
}
const MEMORY_FILE = new URL("../data/memories.json", import.meta.url);
async function loadMemories(): Promise<MemoryRecord[]> {
const contents = await readFile(MEMORY_FILE, { encoding: "utf8" });
return JSON.parse(contents) as MemoryRecord[];
}
async function saveMemories(memories: MemoryRecord[]): Promise<void> {
await writeFile(
MEMORY_FILE,
`${JSON.stringify(memories, null, 2)}\n`,
{ encoding: "utf8" },
);
}
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2);
}
function scoreMemory(memory: MemoryRecord, query: string): number {
const queryTokens = tokenize(query);
const fields = [
{ text: memory.summary, weight: 4 },
{ text: memory.tags.join(" "), weight: 3 },
{ text: memory.category, weight: 2 },
{ text: memory.project, weight: 2 },
{ text: memory.context, weight: 1 },
{ text: memory.agent, weight: 1 },
{ text: memory.sessionId, weight: 1 },
];
let score = 0;
for (const queryToken of queryTokens) {
for (const field of fields) {
const fieldTokens = tokenize(field.text);
const matches = fieldTokens.some(
(fieldToken) =>
fieldToken.includes(queryToken) || queryToken.includes(fieldToken),
);
if (matches) {
score += field.weight;
}
}
}
if (score === 0) {
return 0;
}
const ageMilliseconds = Date.now() - Date.parse(memory.createdAt);
const ageDays = Math.max(0, ageMilliseconds / 86_400_000);
const recencyBonus = 1 / (1 + ageDays / 30);
return score + recencyBonus;
}
function formatMemory(memory: MemoryRecord, score?: number): string {
const scoreLine = score === undefined ? "" : `\nScore: ${score.toFixed(2)}`;
return `[${memory.id}] ${memory.summary}
Project: ${memory.project}
Session: ${memory.sessionId}
Agent: ${memory.agent}
Category: ${memory.category}
Tags: ${memory.tags.join(", ")}
Created: ${memory.createdAt}${scoreLine}
Context: ${memory.context}`;
}
function createServer(): McpServer {
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
server.registerTool(
"remember",
{
description:
"Store a durable decision, rationale, finding, or failed approach from an agent session.",
inputSchema: z.object({
sessionId: z.string().min(1),
agent: z.string().min(1),
project: z.string().min(1),
category: z.enum([
"decision",
"rationale",
"finding",
"failed-approach",
]),
summary: z.string().min(1),
context: z.string().min(1),
tags: z.array(z.string().min(1)).max(10),
}),
},
async ({ sessionId, agent, project, category, summary, context, tags }) => {
const memories = await loadMemories();
const memory: MemoryRecord = {
id: randomUUID(),
sessionId,
agent,
project,
category,
summary,
context,
tags,
createdAt: new Date().toISOString(),
};
memories.push(memory);
await saveMemories(memories);
return {
content: [
{
type: "text",
text: `Saved memory ${memory.id} for session ${memory.sessionId}.`,
},
],
};
},
);
server.registerTool(
"recall",
{
description:
"Find relevant agent memories using weighted fields and a recency bonus.",
inputSchema: z.object({
query: z.string().min(1),
}),
},
async ({ query }) => {
const memories = await loadMemories();
const ranked = memories
.map((memory) => ({ memory, score: scoreMemory(memory, query) }))
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 5);
if (ranked.length === 0) {
return {
content: [{ type: "text", text: "No related memories found." }],
};
}
return {
content: [
{
type: "text",
text: ranked
.map(
({ memory, score }, index) =>
`${index + 1}. ${formatMemory(memory, score)}`,
)
.join("\n\n"),
},
],
};
},
);
return server;
}
void serveStdio(createServer);
console.error("agent-memory MCP server running on stdio");
How to compare the file
Check the helper order first. Then compare the complete recall registration before continuing.
- Return to the terminal running MCP Inspector.
- Stop the current Inspector process.
- Restart the Inspector from the agent-memory-mcp folder by running this command:
npm run inspect
Why restart the Inspector?
The running process still has the earlier server code in memory. Restarting loads the new tokenization and ranking functions.
- Reconnect MCP Inspector to the local stdio server.
Before you call recall, which stored fields do you expect to contribute to the score?
- Return to the Inspector tool list.
- Select the recall tool.
- Enter transport decision in the query field.
- Submit the tool call.
You should now see the persisted transport record instead of No related memories found. The result includes its ID, project, session, agent, category, tags, timestamp, numeric score, and context.
Still getting no related memories?
Confirm that the saved record in data/memories.json has category decision and tag transport.
Check that the updated callback calls scoreMemory() before filtering results. Restart the Inspector again after saving any correction.
Use this prompt if the record still does not rank: Help me debug my weighted recall result.
Your recall tool can now recover a relevant memory from its content and source metadata. Next, you will use its stable ID to retrieve the complete evidence behind that result.
Retrieve Memory Evidence
Your weighted recall now finds the transport decision. The result includes the memory ID plus its source metadata.
Discovery is only half the job. In this step, your MCP server will use that stable ID to recover the complete source record after a restart.
In this step, get ready to:
- Register the validated get tool.
- Prove the stored record survives an Inspector restart.
- Trace a ranked result back to its complete source record.
Register the get tool
Recall gives each result a stable identifier. The get tool turns that identifier into a direct path to the evidence behind the result.
- In src/index.ts, find the return server line near the bottom of createServer().
- Insert the following tool registration immediately above that line by pasting this code:
server.registerTool(
"get",
{
description: "Retrieve the complete source record for one memory ID.",
inputSchema: z.object({
id: z.string().min(1),
}),
},
async ({ id }) => {
const memories = await loadMemories();
const memory = memories.find((candidate) => candidate.id === id);
if (!memory) {
return {
content: [{ type: "text", text: `No memory found for ID ${id}.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: formatMemory(memory) }],
};
},
);
What does this code do?
- The input schema requires a non-empty id value before the handler runs.
- The handler loads every persisted record from data/memories.json.
- The find() call selects the record whose generated ID matches the request.
- The failure path sets isError to true when the ID has no matching record.
- The success path passes the complete record through formatMemory() so its source metadata stays attached.
- Save src/index.ts.
- Stop the current Inspector process in the terminal.
Before you restart it, predict whether the saved transport decision still exists outside the server process.
- Restart MCP Inspector by running this command:
npm run inspect
You should see MCP Inspector start from the new server process.
- Reconnect MCP Inspector to the restarted server.
- Confirm get appears among the available tools.
The new process has discovered your third tool. Your server can now follow a memory ID back to its source record.
Cannot see the get tool?
Confirm the get registration sits inside createServer() before the return server line. Save src/index.ts before restarting the Inspector process.
Check that every opening bracket in the new registration has a matching closing bracket.
Help me debug my get tool registration.
✔️ Awesome, I've got everything!
Great. Double check that you saved src/index.ts before testing the new tool.
ⓧ I'd like to double check the full code
Your complete src/index.ts file should now look like this.
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
type MemoryCategory =
| "decision"
| "rationale"
| "finding"
| "failed-approach";
interface MemoryRecord {
id: string;
sessionId: string;
agent: string;
project: string;
category: MemoryCategory;
summary: string;
context: string;
tags: string[];
createdAt: string;
}
const MEMORY_FILE = new URL("../data/memories.json", import.meta.url);
async function loadMemories(): Promise<MemoryRecord[]> {
const contents = await readFile(MEMORY_FILE, { encoding: "utf8" });
return JSON.parse(contents) as MemoryRecord[];
}
async function saveMemories(memories: MemoryRecord[]): Promise<void> {
await writeFile(
MEMORY_FILE,
`${JSON.stringify(memories, null, 2)}\n`,
{ encoding: "utf8" },
);
}
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2);
}
function scoreMemory(memory: MemoryRecord, query: string): number {
const queryTokens = tokenize(query);
const fields = [
{ text: memory.summary, weight: 4 },
{ text: memory.tags.join(" "), weight: 3 },
{ text: memory.category, weight: 2 },
{ text: memory.project, weight: 2 },
{ text: memory.context, weight: 1 },
{ text: memory.agent, weight: 1 },
{ text: memory.sessionId, weight: 1 },
];
let score = 0;
for (const queryToken of queryTokens) {
for (const field of fields) {
const fieldTokens = tokenize(field.text);
const matches = fieldTokens.some(
(fieldToken) =>
fieldToken.includes(queryToken) || queryToken.includes(fieldToken),
);
if (matches) {
score += field.weight;
}
}
}
if (score === 0) {
return 0;
}
const ageMilliseconds = Date.now() - Date.parse(memory.createdAt);
const ageDays = Math.max(0, ageMilliseconds / 86_400_000);
const recencyBonus = 1 / (1 + ageDays / 30);
return score + recencyBonus;
}
function formatMemory(memory: MemoryRecord, score?: number): string {
const scoreLine = score === undefined ? "" : `\nScore: ${score.toFixed(2)}`;
return `[${memory.id}] ${memory.summary}
Project: ${memory.project}
Session: ${memory.sessionId}
Agent: ${memory.agent}
Category: ${memory.category}
Tags: ${memory.tags.join(", ")}
Created: ${memory.createdAt}${scoreLine}
Context: ${memory.context}`;
}
function createServer(): McpServer {
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
server.registerTool(
"remember",
{
description:
"Store a durable decision, rationale, finding, or failed approach from an agent session.",
inputSchema: z.object({
sessionId: z.string().min(1),
agent: z.string().min(1),
project: z.string().min(1),
category: z.enum([
"decision",
"rationale",
"finding",
"failed-approach",
]),
summary: z.string().min(1),
context: z.string().min(1),
tags: z.array(z.string().min(1)).max(10),
}),
},
async ({ sessionId, agent, project, category, summary, context, tags }) => {
const memories = await loadMemories();
const memory: MemoryRecord = {
id: randomUUID(),
sessionId,
agent,
project,
category,
summary,
context,
tags,
createdAt: new Date().toISOString(),
};
memories.push(memory);
await saveMemories(memories);
return {
content: [
{
type: "text",
text: `Saved memory ${memory.id} for session ${memory.sessionId}.`,
},
],
};
},
);
server.registerTool(
"recall",
{
description:
"Find relevant agent memories using weighted fields and a recency bonus.",
inputSchema: z.object({
query: z.string().min(1),
}),
},
async ({ query }) => {
const memories = await loadMemories();
const ranked = memories
.map((memory) => ({ memory, score: scoreMemory(memory, query) }))
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 5);
if (ranked.length === 0) {
return {
content: [{ type: "text", text: "No related memories found." }],
};
}
return {
content: [
{
type: "text",
text: ranked
.map(
({ memory, score }, index) =>
`${index + 1}. ${formatMemory(memory, score)}`,
)
.join("\n\n"),
},
],
};
},
);
server.registerTool(
"get",
{
description: "Retrieve the complete source record for one memory ID.",
inputSchema: z.object({
id: z.string().min(1),
}),
},
async ({ id }) => {
const memories = await loadMemories();
const memory = memories.find((candidate) => candidate.id === id);
if (!memory) {
return {
content: [{ type: "text", text: `No memory found for ID ${id}.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: formatMemory(memory) }],
};
},
);
return server;
}
void serveStdio(createServer);
console.error("agent-memory MCP server running on stdio");
Recall the persisted record
The memory lives in the local JSON dataset instead of the old server process. Recalling it after the restart proves that the record survived.
- Call the recall tool in MCP Inspector with transport decision as the query.
You should see the saved transport decision with its numeric score. Its generated ID and source metadata should still be present.
That restart proves your dataset is durable. The saved decision remains available to a fresh server process.
- Copy the returned memory ID into this field: your returned memory ID.
Complete the recall-to-get round trip
A ranked result helps an agent discover relevant history. The generated ID provides the stable link to the complete evidence behind that result.
Before you call get, predict which source details the stable ID will recover.
- Call the get tool with this memory ID: your returned memory ID.
What should I see?
- The complete summary appears with its context.
- The project identifies where the decision belongs.
- The session ID identifies the agent session that produced it.
- The agent identifies the source that captured the record.
- The category describes the type of memory.
- The tags preserve the searchable metadata.
- The timestamp shows when the record was created.
One failure path remains. An unknown ID should produce an explicit tool error instead of an empty success response.
Before the final call, predict how get will distinguish a missing record from a successful lookup.
- Enter an ID that does not appear in the recall results.
- Call the get tool again.
You should see an error result with isError set to true.
Does get return the wrong result?
Use the exact generated ID from the latest recall result. Confirm that no spaces were added before or after the value.
If every ID returns an error, check that loadMemories() still reads from data/memories.json.
Help me debug my recall-to-get round trip.
You have completed the full traceability loop. A ranked memory now leads back to its durable source record after a server restart.
Secret mission
Summarize One Agent Session
Turn the memories from one agent session into a compact activity digest. Your new tool will report the memory count, agents, projects, categories, and chronological summaries without changing the durable JSON format.
Clean Up Your Resources
Clean Up Your Resources
Everything you built runs locally, so keeping the files costs $0. Choose an option based on whether you want to continue experimenting.
Resources you used:
- A running MCP Inspector process connected to your local server (no cost).
- The local agent-memory-mcp folder containing installed npm packages, package-lock.json, src/index.ts, and durable records in data/memories.json (no cost).
Keep everything running
No cleanup is required. Choose this option if you want to keep testing tools or adding memory records.
- Leave MCP Inspector connected while you continue experimenting with the server.
- Keep the agent-memory-mcp folder in its current location.
- Preserve data/memories.json so future server sessions can load your stored agent history.
Pause - I'll come back to this later
Pausing ends the active Inspector session while preserving your code and memories. The next run can reload the same JSON records.
- Switch back to the Terminal window running MCP Inspector.
- Press Ctrl+C to stop the Inspector process.
- Confirm you can type at the Terminal prompt again.
- Keep the agent-memory-mcp folder in its current location.
Delete - I don't want to use this again
Deleting the project folder removes the server, dependencies, and stored agent history. The developer tools installed on your Mac stay available for future projects.
- Switch back to the Terminal window running MCP Inspector.
- Press Ctrl+C to stop the Inspector process.
- Close the editor window containing agent-memory-mcp.
- Find the agent-memory-mcp folder in Finder.
- Move the agent-memory-mcp folder to Trash.
- Confirm agent-memory-mcp no longer appears in its original Finder location.
Nice Work!
Nice Work!
You did it! Your local MCP server now preserves agent history across restarts.
You've learned how to:
- Built a TypeScript MCP server over stdio transport. It exposes validated remember, recall, plus get tools.
- Stored agent-session history in data/memories.json. The durable JSON dataset survives server restarts.
- Experienced the failure of summary-only recall. Fixed it with weighted retrieval across memory fields. Added recency scoring to favor newer records. Preserved source attribution from each ranked result to its complete record.
- Completed the Secret Mission by adding session_summary. The tool produces a chronological digest for one agent session.
Ready to quiz yourself?