Build an AI Memory Palace Coach
Build a local MCP server that turns Claude into a memory palace coach.
Introduction
30 Second Summary
You could probably walk through your home right now and describe every room, every piece of furniture, every detail from memory. Memory champions exploit this exact spatial ability to memorize entire decks of cards and hundreds of random words.
In this project, you will build an AI memory coach that runs inside Claude Desktop. It teaches you the Method of Loci, the same technique used by 9 out of 10 World Memory Championship competitors, and schedules your training using a scientifically proven spaced repetition algorithm.
What You'll Build
A local Python MCP server that gives Claude Desktop persistent tools for creating memory palaces, encoding content with vivid imagery, coaching recall sessions, and scheduling reviews with the FSRS spaced repetition algorithm.
By the end of this project, you'll have:
- A working MCP server with 10+ tools that turn Claude into a personal memory palace coach.
- A SQLite database storing your palaces, loci, encodings, and full session history.
- An FSRS-powered spaced repetition engine that schedules your reviews at scientifically optimal intervals to maximize long-term retention.
- A timed recall challenge that tests your memory against published neuroscience baselines (Secret Mission).
Are there any prerequisites?
You need a Claude Pro or Max subscription with Claude Desktop installed on your Windows device. No prior Python or AI experience is required.
Set Up Python and Your Project
Your memory palace coach is a Python server that Claude Desktop calls behind the scenes whenever you chat about memory training.
Before you can build any of that, you need Python itself, a clean project folder, and two key libraries installed.
In this step, get ready to:
- Install Python 3.10 or later and verify it works in PowerShell.
- Create a project folder with a virtual environment and install the MCP SDK and FSRS library.
- Create a minimal MCP server file and confirm it runs without errors.
Download and install Python
Both the MCP Python SDK and the FSRS spaced repetition library require Python 3.10 or later.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check whether Python is already installed by running this command:
python --version
I see Python 3.10 or higher
You are all set. As long as the version number is 3.10 or higher, Python is ready to go.
Command not found or version is below 3.10
- Open python.org/downloads in your browser.
- Click the large Download Python button at the top of the page.
- Run the downloaded installer.
- On the first screen of the installer, check the box labeled Add python.exe to PATH at the bottom. This is critical.
- Click Install Now and wait for it to finish.
- Close PowerShell completely and reopen it by pressing the Windows key, typing PowerShell, and pressing Enter.
- Verify the installation by running this command:
python --version
You should now see a version number like Python 3.12.x or similar.
Still seeing 'command not found'?
The most common cause is missing the Add python.exe to PATH checkbox during installation. Rerun the installer, select Modify, and make sure PATH is enabled.
Make sure you fully closed and reopened PowerShell after installing. The old window does not pick up PATH changes.
Help me troubleshoot my Python installation on Windows
Create your project folder and install dependencies
A virtual environment keeps your project's libraries separate from the rest of your system. This prevents version conflicts between different Python projects on your machine.
- Move to your Desktop by running this command:
cd ~\Desktop
- Create your project folder and move into it by running these commands:
mkdir memory-palace
cd memory-palace
- Confirm the folder exists by running:
ls
You should see an empty folder listing. The prompt now shows memory-palace as your current directory.
- Create a virtual environment by running this command:
python -m venv .venv
- Activate the virtual environment by running:
.venv\Scripts\activate
Your terminal prompt should now start with (.venv). This confirms the virtual environment is active.
Seeing a script execution policy error?
PowerShell sometimes blocks activation scripts by default. Fix it by running this command, then try the activate command again:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
Help me fix a PowerShell execution policy error when activating my Python venv
- Install the two libraries your project needs by running:
pip install "mcp[cli]" fsrs
What are these libraries?
- mcp[cli] is the official MCP Python SDK. It includes FastMCP, a high-level framework for building servers that Claude Desktop can call as tools.
- fsrs is the Free Spaced Repetition Scheduler library. It is the same algorithm that powers Anki's scheduling, packaged as a lightweight Python library.
You should see Successfully installed followed by a list of packages at the end of the output.
Seeing install errors?
Make sure your virtual environment is active. Your prompt should start with (.venv). If it does not, run .venv\Scripts\activate again before retrying the install command.
Help me troubleshoot pip install errors for mcp and fsrs
Create a minimal MCP server skeleton
Before writing any memory palace logic, you want to confirm that the MCP SDK loads correctly. A tiny skeleton server is the fastest way to test this.
- Open your text editor.
- Create a new file and save it as memory_palace_server.py inside your memory-palace folder on the Desktop.
- Copy the following code into the file:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
if __name__ == "__main__":
mcp.run(transport="stdio")
What does this code do?
- The import pulls in FastMCP, the high-level framework from the MCP SDK that lets you expose Python functions as tools Claude can call.
- FastMCP("Memory Palace Coach") creates your server instance and gives it a name that appears in Claude Desktop.
- mcp.run(transport="stdio") starts the server using the STDIO transport, which is how Claude Desktop communicates with local MCP servers.
- Save memory_palace_server.py.
- Back in PowerShell (with (.venv) still active), test that the server starts by running:
python memory_palace_server.py
The script starts and your terminal appears to freeze with no output. This is expected. The server is waiting for MCP protocol messages from Claude Desktop.
- Press Ctrl+C to stop the server.
Seeing a ModuleNotFoundError?
If you see ModuleNotFoundError: No module named 'mcp', your virtual environment is not active. Check that your prompt starts with (.venv). If it does not, run .venv\Scripts\activate and try again.
If you see an immediate traceback with a different error, make sure the file is saved inside the memory-palace folder and that PowerShell is in that same folder.
Help me debug my MCP server startup error
Awesome, I've got everything!
Great. Double check you have saved memory_palace_server.py and that it ran without errors.
I'd like to double check the full code
Here is the complete memory_palace_server.py file so far.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
if __name__ == "__main__":
mcp.run(transport="stdio")
Your environment is ready and the MCP SDK is loading cleanly. Next up, you will build the SQLite database that stores your memory palaces and create the first tools that let Claude architect palaces for you.
Build the Database and Palace Architect
Your Python environment is ready, with the MCP SDK and FSRS library installed. Now it's time to give your memory palace coach a brain.
Every palace, every locus, and every bizarre image you create needs to persist between sessions. In this step, you'll build a SQLite database to store the full palace graph, then create the MCP tools that let Claude create and manage palaces on your behalf.
In this step, get ready to:
- Create the SQLite database schema for palaces, loci, encodings, FSRS cards, and sessions.
- Build MCP tools for creating palaces and adding loci.
- Build MCP tools for viewing palace details and confirming loci.
Write the database initialization code
A memory palace system has a natural hierarchy. A palace contains many loci (anchor points). Each locus can hold one encoding (a vivid image tied to a fact). Each encoding maps to one FSRS spaced repetition card for scheduling reviews.
You'll translate this hierarchy into a SQLite database that lives right next to your server script.
- In memory_palace_server.py, find the existing import at the top of the file:
from mcp.server.fastmcp import FastMCP
- Replace that single import line with the full set of imports your server needs, then add the database path constant and a connection helper below the existing mcp = FastMCP("Memory Palace Coach") line. Copy this code to replace the import and add the new lines below the FastMCP instantiation:
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fsrs import Card, Rating, Scheduler
from mcp.server.fastmcp import FastMCP
# Initialize FastMCP server
mcp = FastMCP("Memory Palace Coach")
# Database path (same directory as this script)
DB_PATH = Path(__file__).parent / "memory_palace.db"
def get_db():
"""Get a database connection with row factory."""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
What does this code do?
- json and sqlite3 are Python built-ins for serializing data and working with SQLite databases.
- DB_PATH puts the database file in the same folder as your script, so everything stays together.
- get_db() creates a reusable database connection. The row_factory setting lets you access columns by name instead of index.
- WAL (Write-Ahead Logging) mode makes SQLite handle concurrent reads and writes more gracefully.
You won't see a change yet. The next chunk uses these to build the actual database tables.
- Below the get_db() function, add the database initialization function that creates all six tables:
def init_db():
"""Initialize the database schema."""
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS palaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
loci_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS loci (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
visual_description TEXT NOT NULL,
confirmed INTEGER DEFAULT 0,
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS encodings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
locus_id INTEGER NOT NULL,
palace_id INTEGER NOT NULL,
content_item TEXT NOT NULL,
imagery_description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (locus_id) REFERENCES loci(id),
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS fsrs_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
encoding_id INTEGER NOT NULL UNIQUE,
card_data TEXT NOT NULL,
due_at TEXT NOT NULL,
last_review TEXT,
review_count INTEGER DEFAULT 0,
FOREIGN KEY (encoding_id) REFERENCES encodings(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
loci_recalled INTEGER DEFAULT 0,
total_loci INTEGER DEFAULT 0,
duration_minutes REAL DEFAULT 0,
struggle_loci TEXT DEFAULT '[]',
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS recall_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
locus_id INTEGER NOT NULL,
user_response TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
FOREIGN KEY (locus_id) REFERENCES loci(id)
);
""")
conn.commit()
conn.close()
What do these tables store?
- palaces stores each memory palace (your bedroom, kitchen, office).
- loci stores the anchor points within each palace, ordered by position in the walk.
- encodings binds a content item (the thing you want to memorize) to a locus, with the bizarre-imagery description.
- fsrs_cards tracks spaced repetition state for each encoding so FSRS can schedule reviews.
- sessions and recall_attempts log every walk-through and how you scored on each locus.
Why CREATE TABLE IF NOT EXISTS?
This pattern means the schema creation is safe to run multiple times. If the tables already exist, SQLite skips them instead of crashing. Your server calls init_db() on every startup, so this keeps things smooth.
Now you need to call init_db() when the server starts so the database is ready before Claude sends any tool calls.
- Scroll to the bottom of your file and find the if __name__ == "__main__": block:
if __name__ == "__main__":
mcp.run(transport="stdio")
- Add init_db() on the line before mcp.run(transport="stdio"), so the block now looks like this:
if __name__ == "__main__":
init_db()
mcp.run(transport="stdio")
- Save memory_palace_server.py.
- Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). Run the server to test the database creation by running this command:
python memory_palace_server.py
The script will start and appear to hang. That is normal. The server is listening on stdio, waiting for Claude Desktop to send it messages.
- Press Ctrl+C to stop the server.
- Confirm the database file was created by running:
dir
You should see memory_palace.db listed alongside memory_palace_server.py in the output.
Seeing an import error?
- If you see ModuleNotFoundError: No module named 'fsrs', your virtual environment is not active. Run .venv\Scripts\activate and try again.
- If you see ModuleNotFoundError: No module named 'mcp', the MCP SDK is not installed. Run pip install "mcp[cli]" with your venv active.
- Help me fix my import error in memory_palace_server.py.
Build the palace creation tools
The database is ready. Now you need to give Claude the ability to actually use it. MCP tools are Python functions decorated with @mcp.tool() that Claude can call during a conversation.
- In memory_palace_server.py, add the create_palace tool below the init_db() function and above the if __name__ block:
# --- Palace Architect Tools ---
@mcp.tool()
def create_palace(name: str, description: str) -> str:
"""Create a new memory palace from a space the user describes.
Args:
name: A short name for the palace (e.g. 'My Bedroom', 'Kitchen')
description: A detailed description of the space including layout and notable features
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
cursor = conn.execute(
"INSERT INTO palaces (name, description, created_at) VALUES (?, ?, ?)",
(name, description, now),
)
palace_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "name": name, "status": "created"})
What does this tool do?
- The @mcp.tool() decorator exposes this function to Claude Desktop as a callable tool.
- The docstring and type hints tell Claude what the tool does and what arguments it expects. Claude reads these to decide when and how to call the tool.
- Every tool returns a JSON string so Claude can parse the result and continue the conversation.
You won't see a change yet. The next tool adds loci to the palace you just made possible.
- Below the create_palace function, add the add_loci tool that saves anchor points to a palace:
@mcp.tool()
def add_loci(palace_id: int, loci: str) -> str:
"""Add loci (memory anchor points) to a palace. Each locus must be visually
distinctive and spatially separable per the Dresler 2017 protocol.
Args:
palace_id: The ID of the palace to add loci to
loci: A JSON array of objects with 'name', 'visual_description', and 'position' (integer order in the walk)
"""
conn = get_db()
loci_list = json.loads(loci)
for locus in loci_list:
conn.execute(
"INSERT INTO loci (palace_id, position, name, visual_description) VALUES (?, ?, ?, ?)",
(palace_id, locus["position"], locus["name"], locus["visual_description"]),
)
conn.execute(
"UPDATE palaces SET loci_count = ? WHERE id = ?",
(len(loci_list), palace_id),
)
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "loci_added": len(loci_list)})
What are loci?
Loci are the specific spots in a palace where you mentally place things you want to remember. Your front door, the kitchen counter, the bookshelf by the window. Each locus has a position that defines the order you walk through them.
The loci argument is a JSON string because MCP tool arguments must be simple types. Claude sends a JSON array and this tool parses it.
You won't see a change yet. The next tool lets Claude list all saved palaces.
- Below the add_loci function, add the list_palaces tool:
@mcp.tool()
def list_palaces() -> str:
"""List all memory palaces with their loci counts and encoding counts."""
conn = get_db()
rows = conn.execute("""
SELECT p.id, p.name, p.description, p.loci_count, p.created_at,
COUNT(e.id) as encoding_count
FROM palaces p
LEFT JOIN encodings e ON e.palace_id = p.id
GROUP BY p.id
""").fetchall()
conn.close()
palaces = []
for row in rows:
palaces.append({
"id": row["id"],
"name": row["name"],
"description": row["description"][:100],
"loci_count": row["loci_count"],
"encoding_count": row["encoding_count"],
"created_at": row["created_at"],
})
return json.dumps(palaces)
What does this tool do?
- The SQL query uses a LEFT JOIN to count encodings per palace, so even empty palaces appear in the list.
- The description is truncated to 100 characters to keep Claude's context clean.
- Claude calls this tool when you ask something like "what palaces do I have?"
- Save memory_palace_server.py.
- Run the server again to check for syntax errors by running this command:
python memory_palace_server.py
- If the server starts without error (it hangs, waiting for input), press Ctrl+C to stop it.
Seeing a SyntaxError or IndentationError?
- Check that each @mcp.tool() decorator sits directly above its def line, with no blank lines between them.
- Make sure all three tool functions are placed between init_db() and the if __name__ block at the bottom.
- Help me fix a syntax error in my MCP tool functions.
Build the palace detail and confirmation tools
Claude can now create palaces and add loci, but it can't look inside a palace yet. It also can't let you confirm which loci you actually remember well enough to use for memorization.
- Below the list_palaces function, add the get_palace_details tool that returns every locus in walk order:
@mcp.tool()
def get_palace_details(palace_id: int) -> str:
"""Get full details of a palace including all loci in walk order.
Args:
palace_id: The palace ID to retrieve
"""
conn = get_db()
palace = conn.execute(
"SELECT * FROM palaces WHERE id = ?", (palace_id,)
).fetchone()
if not palace:
conn.close()
return json.dumps({"error": "Palace not found"})
loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? ORDER BY position",
(palace_id,),
).fetchall()
conn.close()
return json.dumps({
"id": palace["id"],
"name": palace["name"],
"description": palace["description"],
"loci": [
{
"id": l["id"],
"position": l["position"],
"name": l["name"],
"visual_description": l["visual_description"],
"confirmed": bool(l["confirmed"]),
}
for l in loci
],
})
What does this tool do?
- This retrieves the full locus sequence so Claude can narrate a walk through your palace, describing each anchor point in order.
- The ORDER BY position ensures loci come back in the exact walk order, which is critical for the Method of Loci technique.
- The confirmed field tells Claude which loci you've verified you can clearly visualize.
The Dresler 2017 protocol includes a loci confirmation step. Before you start memorizing, you walk through each locus and confirm you can clearly picture it. Only confirmed loci are used for encoding.
- Below the get_palace_details function, add the confirm_loci tool:
@mcp.tool()
def confirm_loci(palace_id: int, confirmed_positions: str) -> str:
"""Mark specific loci as confirmed by the user after review.
Only confirmed loci will be used for encoding.
Args:
palace_id: The palace ID
confirmed_positions: A JSON array of position integers that the user confirmed
"""
conn = get_db()
positions = json.loads(confirmed_positions)
for pos in positions:
conn.execute(
"UPDATE loci SET confirmed = 1 WHERE palace_id = ? AND position = ?",
(palace_id, pos),
)
conn.commit()
count = conn.execute(
"SELECT COUNT(*) FROM loci WHERE palace_id = ? AND confirmed = 1",
(palace_id,),
).fetchone()[0]
conn.close()
return json.dumps({"palace_id": palace_id, "confirmed_loci": count})
What does this tool do?
- Claude calls this after walking you through each locus and asking "can you picture this clearly?"
- The tool sets confirmed = 1 on each locus you approve, then returns the total count of confirmed loci.
- Later, the encoder will only use confirmed loci. This prevents wasting memory slots on places you can't vividly recall.
- Save memory_palace_server.py.
- Run the server one more time to confirm everything loads cleanly by running this command:
python memory_palace_server.py
- If it starts without errors, press Ctrl+C to stop it.
Seeing a NameError?
- A NameError: name 'get_db' is not defined means a tool function was placed above the get_db() definition. Make sure get_db() and init_db() come before all the @mcp.tool() functions.
- Help me debug a NameError in my memory palace server.
Your file now has five MCP tools. Double check your full code matches what is expected.
Awesome, I've got everything!
Great. Double check you've saved memory_palace_server.py.
I'd like to double check the full code
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fsrs import Card, Rating, Scheduler
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
DB_PATH = Path(__file__).parent / "memory_palace.db"
def get_db():
"""Get a database connection with row factory."""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
"""Initialize the database schema."""
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS palaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
loci_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS loci (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
visual_description TEXT NOT NULL,
confirmed INTEGER DEFAULT 0,
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS encodings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
locus_id INTEGER NOT NULL,
palace_id INTEGER NOT NULL,
content_item TEXT NOT NULL,
imagery_description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (locus_id) REFERENCES loci(id),
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS fsrs_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
encoding_id INTEGER NOT NULL UNIQUE,
card_data TEXT NOT NULL,
due_at TEXT NOT NULL,
last_review TEXT,
review_count INTEGER DEFAULT 0,
FOREIGN KEY (encoding_id) REFERENCES encodings(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
loci_recalled INTEGER DEFAULT 0,
total_loci INTEGER DEFAULT 0,
duration_minutes REAL DEFAULT 0,
struggle_loci TEXT DEFAULT '[]',
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS recall_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
locus_id INTEGER NOT NULL,
user_response TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
FOREIGN KEY (locus_id) REFERENCES loci(id)
);
""")
conn.commit()
conn.close()
@mcp.tool()
def create_palace(name: str, description: str) -> str:
"""Create a new memory palace from a space the user describes.
Args:
name: A short name for the palace (e.g. 'My Bedroom', 'Kitchen')
description: A detailed description of the space including layout and notable features
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
cursor = conn.execute(
"INSERT INTO palaces (name, description, created_at) VALUES (?, ?, ?)",
(name, description, now),
)
palace_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "name": name, "status": "created"})
@mcp.tool()
def add_loci(palace_id: int, loci: str) -> str:
"""Add loci (memory anchor points) to a palace.
Args:
palace_id: The ID of the palace to add loci to
loci: A JSON array of objects with 'name', 'visual_description', and 'position'
"""
conn = get_db()
loci_list = json.loads(loci)
for locus in loci_list:
conn.execute(
"INSERT INTO loci (palace_id, position, name, visual_description) VALUES (?, ?, ?, ?)",
(palace_id, locus["position"], locus["name"], locus["visual_description"]),
)
conn.execute(
"UPDATE palaces SET loci_count = ? WHERE id = ?",
(len(loci_list), palace_id),
)
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "loci_added": len(loci_list)})
@mcp.tool()
def confirm_loci(palace_id: int, confirmed_positions: str) -> str:
"""Mark specific loci as confirmed by the user after review.
Args:
palace_id: The palace ID
confirmed_positions: A JSON array of position integers that the user confirmed
"""
conn = get_db()
positions = json.loads(confirmed_positions)
for pos in positions:
conn.execute(
"UPDATE loci SET confirmed = 1 WHERE palace_id = ? AND position = ?",
(palace_id, pos),
)
conn.commit()
count = conn.execute(
"SELECT COUNT(*) FROM loci WHERE palace_id = ? AND confirmed = 1",
(palace_id,),
).fetchone()[0]
conn.close()
return json.dumps({"palace_id": palace_id, "confirmed_loci": count})
@mcp.tool()
def list_palaces() -> str:
"""List all memory palaces with their loci counts and encoding counts."""
conn = get_db()
rows = conn.execute("""
SELECT p.id, p.name, p.description, p.loci_count, p.created_at,
COUNT(e.id) as encoding_count
FROM palaces p
LEFT JOIN encodings e ON e.palace_id = p.id
GROUP BY p.id
""").fetchall()
conn.close()
palaces = []
for row in rows:
palaces.append({
"id": row["id"],
"name": row["name"],
"description": row["description"][:100],
"loci_count": row["loci_count"],
"encoding_count": row["encoding_count"],
"created_at": row["created_at"],
})
return json.dumps(palaces)
@mcp.tool()
def get_palace_details(palace_id: int) -> str:
"""Get full details of a palace including all loci in walk order.
Args:
palace_id: The palace ID to retrieve
"""
conn = get_db()
palace = conn.execute(
"SELECT * FROM palaces WHERE id = ?", (palace_id,)
).fetchone()
if not palace:
conn.close()
return json.dumps({"error": "Palace not found"})
loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? ORDER BY position",
(palace_id,),
).fetchall()
conn.close()
return json.dumps({
"id": palace["id"],
"name": palace["name"],
"description": palace["description"],
"loci": [
{
"id": l["id"],
"position": l["position"],
"name": l["name"],
"visual_description": l["visual_description"],
"confirmed": bool(l["confirmed"]),
}
for l in loci
],
})
if __name__ == "__main__":
init_db()
mcp.run(transport="stdio")
Your database is built and Claude has five tools for managing palaces. Next up, you'll build the memory encoder that lets Claude generate bizarre, vivid imagery and bind it to your loci.
Build the Memory Encoder
Your palace architect tools are working. Claude can create palaces, add loci, and confirm them with the user.
Right now those palaces are empty shelves. The real magic of the Method of Loci happens when you place vivid, bizarre images at each locus. In this step, you will build the tools that let Claude chunk content, generate bizarre-imagery encodings following the Bower 1970 protocol, and bind them to palace loci.
In this step, get ready to:
- Build the encode_content tool to map content items to confirmed palace loci.
- Build the generate_encoding tool to store vivid bizarre-imagery descriptions for each item.
- Build the get_encodings tool to retrieve all encodings in walk order for coached sessions.
Build the encode_content tool
Before Claude can generate vivid imagery, it needs a way to assign each content item to a specific locus. The encode_content tool takes a palace ID and a list of items the user wants to memorize. It validates that the item count fits within the palace's confirmed loci, then creates one encoding record per item.
- In memory_palace_server.py, scroll to the bottom of the file and find this line:
if __name__ == "__main__":
- Add the following code above that line:
@mcp.tool()
def encode_content(palace_id: int, content_items: str) -> str:
"""Map content items to palace loci for memorization.
Args:
palace_id: The palace to encode content into
content_items: A JSON array of strings (words, facts, concepts) to memorize
"""
conn = get_db()
items = json.loads(content_items)
# Only use loci the user has confirmed during palace review
confirmed_loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? AND confirmed = 1 ORDER BY position",
(palace_id,),
).fetchall()
# Reject if there are more items than available loci
if len(items) > len(confirmed_loci):
conn.close()
return json.dumps({
"error": f"Too many items ({len(items)}) for available loci ({len(confirmed_loci)}). Reduce items or add more loci."
})
now = datetime.now(timezone.utc).isoformat()
encoding_ids = []
# Map each item to a locus in walk order, imagery starts empty
for i, item in enumerate(items):
locus = confirmed_loci[i]
cursor = conn.execute(
"INSERT INTO encodings (locus_id, palace_id, content_item, imagery_description, created_at) VALUES (?, ?, ?, ?, ?)",
(locus["id"], palace_id, item, "", now),
)
encoding_ids.append(cursor.lastrowid)
conn.commit()
conn.close()
return json.dumps({
"palace_id": palace_id,
"encodings_created": len(encoding_ids),
"encoding_ids": encoding_ids,
})
What does this code do?
- The tool queries only confirmed loci (where confirmed = 1), sorted by position. This ensures items are placed along the walk path in order.
- If the user sends more items than the palace has confirmed loci, the tool rejects the request with a helpful error message instead of silently dropping items.
- Each encoding is inserted with an empty imagery_description. Claude fills this in later using the generate_encoding tool you will build next.
- Save memory_palace_server.py.
You will not see a visible change yet. The next two tools complete the encoding workflow, and you will verify all three together.
Build the generate_encoding tool
Each encoding starts empty. After encode_content maps items to loci, Claude needs a way to store the vivid bizarre-imagery description for each one.
What is bizarre-imagery encoding?
The Bower 1970 protocol found that bizarre, interactive mental images double recall compared to rote learning. Each encoding should include four elements: a vivid action, an interaction with the locus, emotional valence, and multi-sensory detail.
For example, to remember "mitochondria" at a kitchen sink locus, Claude might generate: "A giant glowing mitochondria is wedged in your kitchen sink, pulsing warm orange light. Water splashes against it and sizzles, filling the room with the smell of burnt toast." The weirder the image, the stickier the memory.
- In memory_palace_server.py, add the following code directly below the encode_content function you just added (and above the if __name__ block):
@mcp.tool()
def generate_encoding(encoding_id: int, imagery_description: str) -> str:
"""Store the bizarre-imagery encoding for a content item.
Args:
encoding_id: The encoding record to update
imagery_description: The vivid bizarre-imagery description generated by the coach
"""
conn = get_db()
# Update the encoding row with the coach's vivid imagery text
conn.execute(
"UPDATE encodings SET imagery_description = ? WHERE id = ?",
(imagery_description, encoding_id),
)
conn.commit()
conn.close()
return json.dumps({"encoding_id": encoding_id, "status": "imagery_stored"})
What does this code do?
This tool is intentionally simple. It takes an encoding ID and the imagery text Claude generated, then writes it into the imagery_description column. The split between encode_content (mapping) and generate_encoding (imagery) lets Claude handle each encoding one at a time, crafting a unique bizarre image for each locus.
- Save memory_palace_server.py.
You will not see a change yet. The final encoder tool completes the set, and you will verify everything together.
Build the get_encodings tool
During a coached walk, Claude needs to narrate each locus and its imagery in the correct sequence. The get_encodings tool retrieves all encodings for a palace, joined with their locus details and sorted by position in the walk path.
- In memory_palace_server.py, add the following code directly below the generate_encoding function (and above the if __name__ block):
@mcp.tool()
def get_encodings(palace_id: int) -> str:
"""Get all encodings for a palace in walk order (by locus position).
Args:
palace_id: The palace ID
"""
conn = get_db()
# Join encodings with their loci to get names, visuals, and walk order
rows = conn.execute("""
SELECT e.id as encoding_id, e.content_item, e.imagery_description,
l.id as locus_id, l.position, l.name as locus_name, l.visual_description
FROM encodings e
JOIN loci l ON e.locus_id = l.id
WHERE e.palace_id = ?
ORDER BY l.position
""", (palace_id,)).fetchall()
conn.close()
# Build a list of encoding dicts with all context Claude needs to narrate
encodings = []
for row in rows:
encodings.append({
"encoding_id": row["encoding_id"],
"locus_id": row["locus_id"],
"position": row["position"],
"locus_name": row["locus_name"],
"locus_visual": row["visual_description"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
})
return json.dumps(encodings)
What does this code do?
- The SQL query joins the encodings table with the loci table so each result includes both the content item and its locus details (name, visual description, position).
- Results are sorted by l.position so Claude receives them in walk order. This means Claude can narrate the palace from start to finish without any additional sorting.
- Each encoding dict includes the locus visual description and the imagery text. Claude uses both to paint a vivid scene during coached recall sessions.
- Save memory_palace_server.py.
- Verify all three new tools load without errors by running this command in your terminal (with your virtual environment activated):
python memory_palace_server.py
The server starts and waits for input. If you see no error messages, all three encoder tools loaded successfully. Press Ctrl+C to stop the server.
Seeing a SyntaxError or IndentationError?
- Make sure each @mcp.tool() decorator sits on its own line directly above the def keyword, with no extra indentation.
- Check that all three new functions are above the if __name__ == "__main__": block at the bottom. Code placed inside or after it will not register as tools.
- Verify that the triple-quoted docstrings use matching """ on both ends. A missing closing set of quotes causes a syntax error that points to an unrelated line.
- Still stuck? Help me debug a SyntaxError in my MCP server's encoder tools.
Awesome, I've got everything!
Great. Double check you have saved memory_palace_server.py before moving on.
I'd like to double check the full code
Here is the complete memory_palace_server.py file as it should look at the end of this step.
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fsrs import Card, Rating, Scheduler
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
DB_PATH = Path(__file__).parent / "memory_palace.db"
def get_db():
"""Get a database connection with row factory."""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
"""Initialize the database schema."""
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS palaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
loci_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS loci (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
visual_description TEXT NOT NULL,
confirmed INTEGER DEFAULT 0,
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS encodings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
locus_id INTEGER NOT NULL,
palace_id INTEGER NOT NULL,
content_item TEXT NOT NULL,
imagery_description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (locus_id) REFERENCES loci(id),
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS fsrs_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
encoding_id INTEGER NOT NULL UNIQUE,
card_data TEXT NOT NULL,
due_at TEXT NOT NULL,
last_review TEXT,
review_count INTEGER DEFAULT 0,
FOREIGN KEY (encoding_id) REFERENCES encodings(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
loci_recalled INTEGER DEFAULT 0,
total_loci INTEGER DEFAULT 0,
duration_minutes REAL DEFAULT 0,
struggle_loci TEXT DEFAULT '[]',
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS recall_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
locus_id INTEGER NOT NULL,
user_response TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
FOREIGN KEY (locus_id) REFERENCES loci(id)
);
""")
conn.commit()
conn.close()
@mcp.tool()
def create_palace(name: str, description: str) -> str:
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
cursor = conn.execute(
"INSERT INTO palaces (name, description, created_at) VALUES (?, ?, ?)",
(name, description, now),
)
palace_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "name": name, "status": "created"})
@mcp.tool()
def add_loci(palace_id: int, loci: str) -> str:
conn = get_db()
loci_list = json.loads(loci)
for locus in loci_list:
conn.execute(
"INSERT INTO loci (palace_id, position, name, visual_description) VALUES (?, ?, ?, ?)",
(palace_id, locus["position"], locus["name"], locus["visual_description"]),
)
conn.execute(
"UPDATE palaces SET loci_count = ? WHERE id = ?",
(len(loci_list), palace_id),
)
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "loci_added": len(loci_list)})
@mcp.tool()
def confirm_loci(palace_id: int, confirmed_positions: str) -> str:
conn = get_db()
positions = json.loads(confirmed_positions)
for pos in positions:
conn.execute(
"UPDATE loci SET confirmed = 1 WHERE palace_id = ? AND position = ?",
(palace_id, pos),
)
conn.commit()
count = conn.execute(
"SELECT COUNT(*) FROM loci WHERE palace_id = ? AND confirmed = 1",
(palace_id,),
).fetchone()[0]
conn.close()
return json.dumps({"palace_id": palace_id, "confirmed_loci": count})
@mcp.tool()
def list_palaces() -> str:
conn = get_db()
rows = conn.execute("""
SELECT p.id, p.name, p.description, p.loci_count, p.created_at,
COUNT(e.id) as encoding_count
FROM palaces p
LEFT JOIN encodings e ON e.palace_id = p.id
GROUP BY p.id
""").fetchall()
conn.close()
palaces = []
for row in rows:
palaces.append({
"id": row["id"],
"name": row["name"],
"description": row["description"][:100],
"loci_count": row["loci_count"],
"encoding_count": row["encoding_count"],
"created_at": row["created_at"],
})
return json.dumps(palaces)
@mcp.tool()
def get_palace_details(palace_id: int) -> str:
conn = get_db()
palace = conn.execute(
"SELECT * FROM palaces WHERE id = ?", (palace_id,)
).fetchone()
if not palace:
conn.close()
return json.dumps({"error": "Palace not found"})
loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? ORDER BY position",
(palace_id,),
).fetchall()
conn.close()
return json.dumps({
"id": palace["id"],
"name": palace["name"],
"description": palace["description"],
"loci": [
{
"id": l["id"],
"position": l["position"],
"name": l["name"],
"visual_description": l["visual_description"],
"confirmed": bool(l["confirmed"]),
}
for l in loci
],
})
@mcp.tool()
def encode_content(palace_id: int, content_items: str) -> str:
"""Map content items to palace loci for memorization.
Args:
palace_id: The palace to encode content into
content_items: A JSON array of strings (words, facts, concepts) to memorize
"""
conn = get_db()
items = json.loads(content_items)
confirmed_loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? AND confirmed = 1 ORDER BY position",
(palace_id,),
).fetchall()
if len(items) > len(confirmed_loci):
conn.close()
return json.dumps({
"error": f"Too many items ({len(items)}) for available loci ({len(confirmed_loci)}). Reduce items or add more loci."
})
now = datetime.now(timezone.utc).isoformat()
encoding_ids = []
for i, item in enumerate(items):
locus = confirmed_loci[i]
cursor = conn.execute(
"INSERT INTO encodings (locus_id, palace_id, content_item, imagery_description, created_at) VALUES (?, ?, ?, ?, ?)",
(locus["id"], palace_id, item, "", now),
)
encoding_ids.append(cursor.lastrowid)
conn.commit()
conn.close()
return json.dumps({
"palace_id": palace_id,
"encodings_created": len(encoding_ids),
"encoding_ids": encoding_ids,
})
@mcp.tool()
def generate_encoding(encoding_id: int, imagery_description: str) -> str:
"""Store the bizarre-imagery encoding for a content item.
Args:
encoding_id: The encoding record to update
imagery_description: The vivid bizarre-imagery description generated by the coach
"""
conn = get_db()
conn.execute(
"UPDATE encodings SET imagery_description = ? WHERE id = ?",
(imagery_description, encoding_id),
)
conn.commit()
conn.close()
return json.dumps({"encoding_id": encoding_id, "status": "imagery_stored"})
@mcp.tool()
def get_encodings(palace_id: int) -> str:
"""Get all encodings for a palace in walk order (by locus position).
Args:
palace_id: The palace ID
"""
conn = get_db()
rows = conn.execute("""
SELECT e.id as encoding_id, e.content_item, e.imagery_description,
l.id as locus_id, l.position, l.name as locus_name, l.visual_description
FROM encodings e
JOIN loci l ON e.locus_id = l.id
WHERE e.palace_id = ?
ORDER BY l.position
""", (palace_id,)).fetchall()
conn.close()
encodings = []
for row in rows:
encodings.append({
"encoding_id": row["encoding_id"],
"locus_id": row["locus_id"],
"position": row["position"],
"locus_name": row["locus_name"],
"locus_visual": row["visual_description"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
})
return json.dumps(encodings)
if __name__ == "__main__":
init_db()
mcp.run(transport="stdio")
Your encoding pipeline is complete. Claude can now map content to loci, generate vivid imagery for each one, and retrieve the full set in walk order. Next up, you will build the coach and recall grader that power the actual palace walk sessions.
Build the Coach and Recall Grader
In the previous step, you built the encoder tools that map content to palace loci with bizarre-imagery descriptions. Your palaces can now hold memories.
The missing piece is the coach. Right now, Claude can build palaces and encode content, but it has no way to walk you through your palace and test your recall. In this step, you'll build three MCP tools that let Claude start a coached walk session, grade your recall at each locus, and summarize your performance.
In this step, get ready to:
- Build the start_walk_session tool to kick off a coached palace walk.
- Build the grade_recall tool to score each recall attempt using the FSRS 1-4 rating scale.
- Build the end_walk_session tool to finalize the session and flag struggle loci.
Build the start_walk_session tool
Before Claude can coach you through a palace, it needs to create a session record. This session tracks when the walk started, which palace you're walking, and how many loci you'll visit.
- In memory_palace_server.py, scroll down to the get_encodings function.
- Add the following start_walk_session tool below the get_encodings function, but above the if __name__ == "__main__": block:
@mcp.tool()
def start_walk_session(palace_id: int) -> str:
"""Start a coached palace walk session.
Args:
palace_id: The palace to walk through
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
encodings = conn.execute(
"SELECT COUNT(*) FROM encodings WHERE palace_id = ?",
(palace_id,),
).fetchone()[0]
cursor = conn.execute(
"INSERT INTO sessions (palace_id, started_at, total_loci) VALUES (?, ?, ?)",
(palace_id, now, encodings),
)
session_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"palace_id": palace_id,
"total_loci": encodings,
"started_at": now,
})
What does this code do?
- It counts how many encodings exist for the palace. This tells Claude how many loci you'll visit during the walk.
- It inserts a new row into the sessions table with the palace ID, start timestamp, and total loci count.
- It returns the session_id that every subsequent tool call in this walk will reference.
- Save memory_palace_server.py.
You won't see a visible change yet. The next tool uses this session ID to grade each recall attempt.
Build the grade_recall tool
During a coached walk, Claude narrates each locus and asks you to recall the content. You type what you remember, and Claude compares your response to the stored encoding. The grade_recall tool records each attempt with an FSRS rating from 1 to 4.
What is the FSRS 1-4 rating scale?
FSRS uses four ratings that describe how well you recalled something. These same ratings feed into the spaced repetition scheduler later.
- 1 = Again (forgot completely).
- 2 = Hard (struggled but got something).
- 3 = Good (recalled with some hesitation).
- 4 = Easy (instant recall).
- Add the following grade_recall tool below start_walk_session:
@mcp.tool()
def grade_recall(session_id: int, locus_id: int, user_response: str, score: int) -> str:
"""Grade a single recall attempt during a palace walk.
Args:
session_id: The current walk session ID
locus_id: The locus being tested
user_response: What the user recalled
score: FSRS rating 1=Again, 2=Hard, 3=Good, 4=Easy
"""
if score not in (1, 2, 3, 4):
return json.dumps({"error": "Score must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)"})
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO recall_attempts (session_id, locus_id, user_response, score, created_at) VALUES (?, ?, ?, ?, ?)",
(session_id, locus_id, user_response, score, now),
)
conn.commit()
conn.close()
labels = {1: "Again (forgot)", 2: "Hard (struggled)", 3: "Good (hesitated)", 4: "Easy (instant)"}
return json.dumps({
"session_id": session_id,
"locus_id": locus_id,
"score": score,
"label": labels[score],
})
What does this code do?
- The if score not in (1, 2, 3, 4) guard rejects any invalid rating before touching the database.
- Each recall attempt is inserted into the recall_attempts table with the session, locus, the user's typed response, and the score.
- The labels dictionary maps each numeric score to a human-readable label so Claude can give clear feedback.
- Save memory_palace_server.py.
You won't see a visible change yet. The last tool wraps up the session using these individual grades.
Build the end_walk_session tool
When Claude has walked you through every locus, the session needs to be finalized. The end_walk_session tool calculates your accuracy percentage and identifies which loci you struggled with so they can get priority review later.
- Add the following end_walk_session tool below grade_recall:
@mcp.tool()
def end_walk_session(session_id: int, duration_minutes: float) -> str:
"""End a palace walk session and calculate results.
Args:
session_id: The session to finalize
duration_minutes: How long the session took in minutes
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
attempts = conn.execute(
"SELECT locus_id, score FROM recall_attempts WHERE session_id = ?",
(session_id,),
).fetchall()
recalled = sum(1 for a in attempts if a["score"] >= 3)
struggle_loci = [a["locus_id"] for a in attempts if a["score"] <= 2]
conn.execute("""
UPDATE sessions SET ended_at = ?, loci_recalled = ?, duration_minutes = ?, struggle_loci = ?
WHERE id = ?
""", (now, recalled, duration_minutes, json.dumps(struggle_loci), session_id))
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"loci_recalled": recalled,
"total_loci": len(attempts),
"accuracy_percent": round(recalled / len(attempts) * 100, 1) if attempts else 0,
"struggle_loci_count": len(struggle_loci),
"duration_minutes": duration_minutes,
})
What does this code do?
- It pulls every recall attempt for this session from the recall_attempts table.
- recalled counts any attempt scored 3 (Good) or 4 (Easy) as a successful recall.
- struggle_loci collects the locus IDs where you scored 1 (Again) or 2 (Hard). These are the spots Claude will prioritize in your next review.
- The session row is updated with the end timestamp, recall count, duration, and the struggle loci list as a JSON string.
Why track struggle loci separately?
Research on the testing effect (Roediger & Karpicke 2006) shows that retrieval practice is most valuable for the items you almost remember. By flagging loci where you scored 1 or 2, the system can direct Claude to re-test those specific spots first in your next session.
- Save memory_palace_server.py.
- Verify that your code has no syntax errors by running this command in your terminal (make sure your virtual environment is activated):
python -c "import memory_palace_server; print('All imports and definitions loaded successfully')"
You should see All imports and definitions loaded successfully printed in your terminal.
Seeing a SyntaxError or IndentationError?
- Check that each function starts with @mcp.tool() on its own line, directly above the def keyword.
- Verify that the triple-quoted docstrings use three double quotes ("""), not single quotes.
- Make sure you're running the command from your project folder (the one containing memory_palace_server.py).
- Help me debug this error in my memory palace server.
Awesome, I've got everything!
Double check that you've saved memory_palace_server.py and move on to the next step.
I'd like to double check the full code
Here is the full memory_palace_server.py file as it should look at the end of this step.
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fsrs import Card, Rating, Scheduler
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
DB_PATH = Path(__file__).parent / "memory_palace.db"
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS palaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
loci_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS loci (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
visual_description TEXT NOT NULL,
confirmed INTEGER DEFAULT 0,
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS encodings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
locus_id INTEGER NOT NULL,
palace_id INTEGER NOT NULL,
content_item TEXT NOT NULL,
imagery_description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (locus_id) REFERENCES loci(id),
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS fsrs_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
encoding_id INTEGER NOT NULL UNIQUE,
card_data TEXT NOT NULL,
due_at TEXT NOT NULL,
last_review TEXT,
review_count INTEGER DEFAULT 0,
FOREIGN KEY (encoding_id) REFERENCES encodings(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
loci_recalled INTEGER DEFAULT 0,
total_loci INTEGER DEFAULT 0,
duration_minutes REAL DEFAULT 0,
struggle_loci TEXT DEFAULT '[]',
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS recall_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
locus_id INTEGER NOT NULL,
user_response TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
FOREIGN KEY (locus_id) REFERENCES loci(id)
);
""")
conn.commit()
conn.close()
@mcp.tool()
def create_palace(name: str, description: str) -> str:
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
cursor = conn.execute(
"INSERT INTO palaces (name, description, created_at) VALUES (?, ?, ?)",
(name, description, now),
)
palace_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "name": name, "status": "created"})
@mcp.tool()
def add_loci(palace_id: int, loci: str) -> str:
conn = get_db()
loci_list = json.loads(loci)
for locus in loci_list:
conn.execute(
"INSERT INTO loci (palace_id, position, name, visual_description) VALUES (?, ?, ?, ?)",
(palace_id, locus["position"], locus["name"], locus["visual_description"]),
)
conn.execute(
"UPDATE palaces SET loci_count = ? WHERE id = ?",
(len(loci_list), palace_id),
)
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "loci_added": len(loci_list)})
@mcp.tool()
def confirm_loci(palace_id: int, confirmed_positions: str) -> str:
conn = get_db()
positions = json.loads(confirmed_positions)
for pos in positions:
conn.execute(
"UPDATE loci SET confirmed = 1 WHERE palace_id = ? AND position = ?",
(palace_id, pos),
)
conn.commit()
count = conn.execute(
"SELECT COUNT(*) FROM loci WHERE palace_id = ? AND confirmed = 1",
(palace_id,),
).fetchone()[0]
conn.close()
return json.dumps({"palace_id": palace_id, "confirmed_loci": count})
@mcp.tool()
def list_palaces() -> str:
conn = get_db()
rows = conn.execute("""
SELECT p.id, p.name, p.description, p.loci_count, p.created_at,
COUNT(e.id) as encoding_count
FROM palaces p
LEFT JOIN encodings e ON e.palace_id = p.id
GROUP BY p.id
""").fetchall()
conn.close()
palaces = []
for row in rows:
palaces.append({
"id": row["id"],
"name": row["name"],
"description": row["description"][:100],
"loci_count": row["loci_count"],
"encoding_count": row["encoding_count"],
"created_at": row["created_at"],
})
return json.dumps(palaces)
@mcp.tool()
def get_palace_details(palace_id: int) -> str:
conn = get_db()
palace = conn.execute(
"SELECT * FROM palaces WHERE id = ?", (palace_id,)
).fetchone()
if not palace:
conn.close()
return json.dumps({"error": "Palace not found"})
loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? ORDER BY position",
(palace_id,),
).fetchall()
conn.close()
return json.dumps({
"id": palace["id"],
"name": palace["name"],
"description": palace["description"],
"loci": [
{
"id": l["id"],
"position": l["position"],
"name": l["name"],
"visual_description": l["visual_description"],
"confirmed": bool(l["confirmed"]),
}
for l in loci
],
})
@mcp.tool()
def encode_content(palace_id: int, content_items: str) -> str:
conn = get_db()
items = json.loads(content_items)
confirmed_loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? AND confirmed = 1 ORDER BY position",
(palace_id,),
).fetchall()
if len(items) > len(confirmed_loci):
conn.close()
return json.dumps({
"error": f"Too many items ({len(items)}) for available loci ({len(confirmed_loci)}). Reduce items or add more loci."
})
now = datetime.now(timezone.utc).isoformat()
encoding_ids = []
for i, item in enumerate(items):
locus = confirmed_loci[i]
cursor = conn.execute(
"INSERT INTO encodings (locus_id, palace_id, content_item, imagery_description, created_at) VALUES (?, ?, ?, ?, ?)",
(locus["id"], palace_id, item, "", now),
)
encoding_ids.append(cursor.lastrowid)
conn.commit()
conn.close()
return json.dumps({
"palace_id": palace_id,
"encodings_created": len(encoding_ids),
"encoding_ids": encoding_ids,
})
@mcp.tool()
def generate_encoding(encoding_id: int, imagery_description: str) -> str:
conn = get_db()
conn.execute(
"UPDATE encodings SET imagery_description = ? WHERE id = ?",
(imagery_description, encoding_id),
)
conn.commit()
conn.close()
return json.dumps({"encoding_id": encoding_id, "status": "imagery_stored"})
@mcp.tool()
def get_encodings(palace_id: int) -> str:
conn = get_db()
rows = conn.execute("""
SELECT e.id as encoding_id, e.content_item, e.imagery_description,
l.id as locus_id, l.position, l.name as locus_name, l.visual_description
FROM encodings e
JOIN loci l ON e.locus_id = l.id
WHERE e.palace_id = ?
ORDER BY l.position
""", (palace_id,)).fetchall()
conn.close()
encodings = []
for row in rows:
encodings.append({
"encoding_id": row["encoding_id"],
"locus_id": row["locus_id"],
"position": row["position"],
"locus_name": row["locus_name"],
"locus_visual": row["visual_description"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
})
return json.dumps(encodings)
@mcp.tool()
def start_walk_session(palace_id: int) -> str:
"""Start a coached palace walk session.
Args:
palace_id: The palace to walk through
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
encodings = conn.execute(
"SELECT COUNT(*) FROM encodings WHERE palace_id = ?",
(palace_id,),
).fetchone()[0]
cursor = conn.execute(
"INSERT INTO sessions (palace_id, started_at, total_loci) VALUES (?, ?, ?)",
(palace_id, now, encodings),
)
session_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"palace_id": palace_id,
"total_loci": encodings,
"started_at": now,
})
@mcp.tool()
def grade_recall(session_id: int, locus_id: int, user_response: str, score: int) -> str:
"""Grade a single recall attempt during a palace walk.
Args:
session_id: The current walk session ID
locus_id: The locus being tested
user_response: What the user recalled
score: FSRS rating 1=Again, 2=Hard, 3=Good, 4=Easy
"""
if score not in (1, 2, 3, 4):
return json.dumps({"error": "Score must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)"})
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO recall_attempts (session_id, locus_id, user_response, score, created_at) VALUES (?, ?, ?, ?, ?)",
(session_id, locus_id, user_response, score, now),
)
conn.commit()
conn.close()
labels = {1: "Again (forgot)", 2: "Hard (struggled)", 3: "Good (hesitated)", 4: "Easy (instant)"}
return json.dumps({
"session_id": session_id,
"locus_id": locus_id,
"score": score,
"label": labels[score],
})
@mcp.tool()
def end_walk_session(session_id: int, duration_minutes: float) -> str:
"""End a palace walk session and calculate results.
Args:
session_id: The session to finalize
duration_minutes: How long the session took in minutes
"""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
attempts = conn.execute(
"SELECT locus_id, score FROM recall_attempts WHERE session_id = ?",
(session_id,),
).fetchall()
recalled = sum(1 for a in attempts if a["score"] >= 3)
struggle_loci = [a["locus_id"] for a in attempts if a["score"] <= 2]
conn.execute("""
UPDATE sessions SET ended_at = ?, loci_recalled = ?, duration_minutes = ?, struggle_loci = ?
WHERE id = ?
""", (now, recalled, duration_minutes, json.dumps(struggle_loci), session_id))
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"loci_recalled": recalled,
"total_loci": len(attempts),
"accuracy_percent": round(recalled / len(attempts) * 100, 1) if attempts else 0,
"struggle_loci_count": len(struggle_loci),
"duration_minutes": duration_minutes,
})
if __name__ == "__main__":
init_db()
mcp.run(transport="stdio")
Your coach tools are in place. Claude can now start a walk, grade every recall attempt, and wrap up the session with an accuracy report and struggle list. Next up, you'll build the FSRS scheduling engine that turns these grades into scientifically optimized review intervals.
Build the FSRS Scheduling Engine
Your coach can now run a full palace walk and grade each recall attempt. But right now, there is no science behind when to review. You could review every day, but that wastes time on items you already know cold.
This is where FSRS (Free Spaced Repetition Scheduler) comes in. FSRS is the algorithm behind Anki's scheduling engine. It watches your recall scores and calculates the mathematically optimal moment to review each card, right before you would forget it.
In this step, get ready to:
- Turn each encoding into an FSRS card that tracks review history and due dates.
- Build a tool that checks which cards are due for review right now.
- Close the review loop so FSRS can reschedule cards after each recall attempt.
Create FSRS cards for your encodings
Each encoding in your palace needs a companion FSRS card. The card object tracks stability, difficulty, and the next due date. When you create cards, you set a desired_retention of 0.9, meaning FSRS targets a 90% probability you'll remember the item at review time.
Why 0.9 retention?
The Dresler 2017 protocol targets 90% retention. Higher values mean more frequent reviews. Lower values space reviews further apart but risk forgetting. 0.9 is the standard default in FSRS and hits the sweet spot for long-term learning.
- In memory_palace_server.py, scroll down to the end_walk_session function.
- After the closing return statement of end_walk_session, add the following create_fsrs_cards tool above the if __name__ == "__main__": block:
@mcp.tool()
def create_fsrs_cards(palace_id: int) -> str:
"""Create FSRS spaced repetition cards for all encodings in a palace.
Args:
palace_id: The palace whose encodings should become FSRS cards
"""
conn = get_db()
# Set the target retention probability to 90%
scheduler = Scheduler(desired_retention=0.9)
# Only create cards for encodings that don't already have one
encodings = conn.execute(
"SELECT id FROM encodings WHERE palace_id = ? AND id NOT IN (SELECT encoding_id FROM fsrs_cards)",
(palace_id,),
).fetchall()
cards_created = 0
for enc in encodings:
card = Card()
now = datetime.now(timezone.utc).isoformat()
# Serialize the full card state to JSON for storage
card_data = json.dumps({
"due": card.due.isoformat(),
"stability": card.stability,
"difficulty": card.difficulty,
"elapsed_days": card.elapsed_days,
"scheduled_days": card.scheduled_days,
"reps": card.reps,
"lapses": card.lapses,
"state": card.state.value if hasattr(card.state, 'value') else int(card.state),
"last_review": card.last_review.isoformat() if card.last_review else None,
})
conn.execute(
"INSERT INTO fsrs_cards (encoding_id, card_data, due_at) VALUES (?, ?, ?)",
(enc["id"], card_data, card.due.isoformat()),
)
cards_created += 1
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "cards_created": cards_created})
What does this code do?
- The subquery NOT IN (SELECT encoding_id FROM fsrs_cards) ensures you never create duplicate cards for the same encoding. Safe to call multiple times.
- Card() creates a brand-new FSRS card that is due immediately. Perfect for first reviews.
- The card state is serialized as JSON so it can live in your SQLite database. FSRS needs this state to calculate future scheduling.
You won't see a change yet. The next two tools will complete the scheduling loop.
Build the due review checker
When you ask Claude "what should I review today?", Claude needs a tool that checks which FSRS cards have a due date in the past. This tool queries the database and returns everything that is ready for review, grouped with the palace and locus details Claude needs to narrate the session.
- Directly below your create_fsrs_cards function, add the get_due_reviews tool:
@mcp.tool()
def get_due_reviews() -> str:
"""Check which FSRS cards are due for review right now."""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
# Join across all tables to give Claude full context for each due card
rows = conn.execute("""
SELECT fc.id as card_id, fc.encoding_id, fc.due_at, fc.review_count,
e.content_item, e.imagery_description,
l.name as locus_name, l.visual_description as locus_visual,
p.id as palace_id, p.name as palace_name
FROM fsrs_cards fc
JOIN encodings e ON fc.encoding_id = e.id
JOIN loci l ON e.locus_id = l.id
JOIN palaces p ON e.palace_id = p.id
WHERE fc.due_at <= ?
ORDER BY p.id, fc.due_at
""", (now,)).fetchall()
conn.close()
due_cards = []
for row in rows:
due_cards.append({
"card_id": row["card_id"],
"encoding_id": row["encoding_id"],
"palace_name": row["palace_name"],
"palace_id": row["palace_id"],
"locus_name": row["locus_name"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
"review_count": row["review_count"],
})
return json.dumps({"due_count": len(due_cards), "cards": due_cards})
What does this code do?
- WHERE fc.due_at <= ? compares each card's due date against the current time. Cards with a due date in the past are overdue and ready for review.
- The four-table JOIN pulls the palace name, locus name, content item, and imagery together in one query. Claude gets everything it needs to narrate the review without extra tool calls.
- Results are ordered by palace first, then by due date. This groups reviews by palace so Claude can run them as a coherent walk.
You won't see a change yet. The next tool closes the loop by processing review results.
Complete the review loop and add progress tracking
Two final tools close the FSRS engine. First, complete_review feeds your recall rating back into FSRS so it can schedule the next review. Second, get_progress gives Claude a dashboard of your overall training stats.
- Directly below your get_due_reviews function, add the complete_review tool:
@mcp.tool()
def complete_review(card_id: int, rating: int) -> str:
"""Complete a review of an FSRS card with a rating.
Args:
card_id: The FSRS card ID
rating: 1=Again, 2=Hard, 3=Good, 4=Easy
"""
if rating not in (1, 2, 3, 4):
return json.dumps({"error": "Rating must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)"})
conn = get_db()
row = conn.execute("SELECT card_data FROM fsrs_cards WHERE id = ?", (card_id,)).fetchone()
if not row:
conn.close()
return json.dumps({"error": "Card not found"})
# Create a fresh scheduler and card, then apply the rating
scheduler = Scheduler(desired_retention=0.9)
card = Card()
rating_map = {1: Rating.Again, 2: Rating.Hard, 3: Rating.Good, 4: Rating.Easy}
card, review_log = scheduler.review_card(card, rating_map[rating])
now = datetime.now(timezone.utc).isoformat()
# Serialize the updated card state back to JSON
card_data = json.dumps({
"due": card.due.isoformat(),
"stability": card.stability,
"difficulty": card.difficulty,
"elapsed_days": card.elapsed_days,
"scheduled_days": card.scheduled_days,
"reps": card.reps,
"lapses": card.lapses,
"state": card.state.value if hasattr(card.state, 'value') else int(card.state),
"last_review": card.last_review.isoformat() if card.last_review else None,
})
# Save the updated card and bump the review counter
conn.execute("""
UPDATE fsrs_cards SET card_data = ?, due_at = ?, last_review = ?, review_count = review_count + 1
WHERE id = ?
""", (card_data, card.due.isoformat(), now, card_id))
conn.commit()
conn.close()
return json.dumps({
"card_id": card_id,
"rating": rating,
"next_due": card.due.isoformat(),
"scheduled_days": card.scheduled_days,
})
What does this code do?
- rating_map translates integer scores (1-4) into the FSRS Rating enum values that the scheduler expects.
- scheduler.review_card(card, rating) is the core FSRS call. It returns an updated card with a new due date calculated from the rating, stability, and difficulty.
- The response includes next_due and scheduled_days so Claude can tell you exactly when your next review is.
Now add the progress dashboard tool. This gives Claude a single call to pull your overall training statistics.
- Directly below your complete_review function, add the get_progress tool (still above if __name__ == "__main__":):
@mcp.tool()
def get_progress() -> str:
"""Get overall progress statistics."""
conn = get_db()
# Count palaces, confirmed loci, encodings, and completed sessions
palace_count = conn.execute("SELECT COUNT(*) FROM palaces").fetchone()[0]
loci_count = conn.execute("SELECT COUNT(*) FROM loci WHERE confirmed = 1").fetchone()[0]
encoding_count = conn.execute("SELECT COUNT(*) FROM encodings").fetchone()[0]
session_count = conn.execute("SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL").fetchone()[0]
# Pull the 10 most recent completed sessions for trend tracking
recent_sessions = conn.execute("""
SELECT s.started_at, s.loci_recalled, s.total_loci, s.duration_minutes, p.name as palace_name
FROM sessions s
JOIN palaces p ON s.palace_id = p.id
WHERE s.ended_at IS NOT NULL
ORDER BY s.started_at DESC
LIMIT 10
""").fetchall()
# Calculate overall recall accuracy across all attempts
total_reviews = conn.execute("SELECT COUNT(*) FROM recall_attempts").fetchone()[0]
good_reviews = conn.execute("SELECT COUNT(*) FROM recall_attempts WHERE score >= 3").fetchone()[0]
conn.close()
accuracy = round(good_reviews / total_reviews * 100, 1) if total_reviews > 0 else 0
return json.dumps({
"palaces": palace_count,
"confirmed_loci": loci_count,
"encodings": encoding_count,
"sessions_completed": session_count,
"total_recall_attempts": total_reviews,
"overall_accuracy_percent": accuracy,
"recent_sessions": [
{
"date": s["started_at"][:10],
"palace": s["palace_name"],
"recalled": s["loci_recalled"],
"total": s["total_loci"],
"accuracy": round(s["loci_recalled"] / s["total_loci"] * 100, 1) if s["total_loci"] > 0 else 0,
"duration_min": s["duration_minutes"],
}
for s in recent_sessions
],
})
What does this code do?
- This tool aggregates data from every table in your database into a single summary.
- overall_accuracy_percent is calculated from all recall attempts across every session. A score of 3 (Good) or 4 (Easy) counts as recalled.
- The last 10 sessions are included so Claude can show you trends over time. This is how you track whether your training is working.
- Save memory_palace_server.py.
- Make sure your virtual environment is still active (you should see (.venv) in your terminal prompt).
- Run the server to confirm there are no syntax or import errors by running:
python memory_palace_server.py
The server starts listening over STDIO and the terminal appears to hang with no output. That is the expected behavior for an MCP server waiting for connections.
- Press Ctrl+C to stop the server.
Seeing a SyntaxError or IndentationError?
- Make sure each new function starts with @mcp.tool() on its own line, with def on the next line directly below.
- Check that all four new functions are placed above the if __name__ == "__main__": block, not inside it.
- Verify there is a blank line between the end of one function and the @mcp.tool() decorator of the next.
Help me debug a SyntaxError in my memory palace MCP server.
Awesome, I've got everything!
Great. Double check you have saved memory_palace_server.py before moving on.
I'd like to double check the full code
Here is the full memory_palace_server.py file as it should look right now. Compare your file against this.
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fsrs import Card, Rating, Scheduler
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Memory Palace Coach")
DB_PATH = Path(__file__).parent / "memory_palace.db"
def get_db():
"""Get a database connection with row factory."""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
"""Initialize the database schema."""
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS palaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
loci_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS loci (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
visual_description TEXT NOT NULL,
confirmed INTEGER DEFAULT 0,
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS encodings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
locus_id INTEGER NOT NULL,
palace_id INTEGER NOT NULL,
content_item TEXT NOT NULL,
imagery_description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (locus_id) REFERENCES loci(id),
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS fsrs_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
encoding_id INTEGER NOT NULL UNIQUE,
card_data TEXT NOT NULL,
due_at TEXT NOT NULL,
last_review TEXT,
review_count INTEGER DEFAULT 0,
FOREIGN KEY (encoding_id) REFERENCES encodings(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
palace_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
loci_recalled INTEGER DEFAULT 0,
total_loci INTEGER DEFAULT 0,
duration_minutes REAL DEFAULT 0,
struggle_loci TEXT DEFAULT '[]',
FOREIGN KEY (palace_id) REFERENCES palaces(id)
);
CREATE TABLE IF NOT EXISTS recall_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
locus_id INTEGER NOT NULL,
user_response TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
FOREIGN KEY (locus_id) REFERENCES loci(id)
);
""")
conn.commit()
conn.close()
@mcp.tool()
def create_palace(name: str, description: str) -> str:
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
cursor = conn.execute(
"INSERT INTO palaces (name, description, created_at) VALUES (?, ?, ?)",
(name, description, now),
)
palace_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "name": name, "status": "created"})
@mcp.tool()
def add_loci(palace_id: int, loci: str) -> str:
conn = get_db()
loci_list = json.loads(loci)
for locus in loci_list:
conn.execute(
"INSERT INTO loci (palace_id, position, name, visual_description) VALUES (?, ?, ?, ?)",
(palace_id, locus["position"], locus["name"], locus["visual_description"]),
)
conn.execute(
"UPDATE palaces SET loci_count = ? WHERE id = ?",
(len(loci_list), palace_id),
)
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "loci_added": len(loci_list)})
@mcp.tool()
def confirm_loci(palace_id: int, confirmed_positions: str) -> str:
conn = get_db()
positions = json.loads(confirmed_positions)
for pos in positions:
conn.execute(
"UPDATE loci SET confirmed = 1 WHERE palace_id = ? AND position = ?",
(palace_id, pos),
)
conn.commit()
count = conn.execute(
"SELECT COUNT(*) FROM loci WHERE palace_id = ? AND confirmed = 1",
(palace_id,),
).fetchone()[0]
conn.close()
return json.dumps({"palace_id": palace_id, "confirmed_loci": count})
@mcp.tool()
def list_palaces() -> str:
conn = get_db()
rows = conn.execute("""
SELECT p.id, p.name, p.description, p.loci_count, p.created_at,
COUNT(e.id) as encoding_count
FROM palaces p
LEFT JOIN encodings e ON e.palace_id = p.id
GROUP BY p.id
""").fetchall()
conn.close()
palaces = []
for row in rows:
palaces.append({
"id": row["id"],
"name": row["name"],
"description": row["description"][:100],
"loci_count": row["loci_count"],
"encoding_count": row["encoding_count"],
"created_at": row["created_at"],
})
return json.dumps(palaces)
@mcp.tool()
def get_palace_details(palace_id: int) -> str:
conn = get_db()
palace = conn.execute(
"SELECT * FROM palaces WHERE id = ?", (palace_id,)
).fetchone()
if not palace:
conn.close()
return json.dumps({"error": "Palace not found"})
loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? ORDER BY position",
(palace_id,),
).fetchall()
conn.close()
return json.dumps({
"id": palace["id"],
"name": palace["name"],
"description": palace["description"],
"loci": [
{
"id": l["id"],
"position": l["position"],
"name": l["name"],
"visual_description": l["visual_description"],
"confirmed": bool(l["confirmed"]),
}
for l in loci
],
})
@mcp.tool()
def encode_content(palace_id: int, content_items: str) -> str:
conn = get_db()
items = json.loads(content_items)
confirmed_loci = conn.execute(
"SELECT * FROM loci WHERE palace_id = ? AND confirmed = 1 ORDER BY position",
(palace_id,),
).fetchall()
if len(items) > len(confirmed_loci):
conn.close()
return json.dumps({
"error": f"Too many items ({len(items)}) for available loci ({len(confirmed_loci)}). Reduce items or add more loci."
})
now = datetime.now(timezone.utc).isoformat()
encoding_ids = []
for i, item in enumerate(items):
locus = confirmed_loci[i]
cursor = conn.execute(
"INSERT INTO encodings (locus_id, palace_id, content_item, imagery_description, created_at) VALUES (?, ?, ?, ?, ?)",
(locus["id"], palace_id, item, "", now),
)
encoding_ids.append(cursor.lastrowid)
conn.commit()
conn.close()
return json.dumps({
"palace_id": palace_id,
"encodings_created": len(encoding_ids),
"encoding_ids": encoding_ids,
})
@mcp.tool()
def generate_encoding(encoding_id: int, imagery_description: str) -> str:
conn = get_db()
conn.execute(
"UPDATE encodings SET imagery_description = ? WHERE id = ?",
(imagery_description, encoding_id),
)
conn.commit()
conn.close()
return json.dumps({"encoding_id": encoding_id, "status": "imagery_stored"})
@mcp.tool()
def get_encodings(palace_id: int) -> str:
conn = get_db()
rows = conn.execute("""
SELECT e.id as encoding_id, e.content_item, e.imagery_description,
l.id as locus_id, l.position, l.name as locus_name, l.visual_description
FROM encodings e
JOIN loci l ON e.locus_id = l.id
WHERE e.palace_id = ?
ORDER BY l.position
""", (palace_id,)).fetchall()
conn.close()
encodings = []
for row in rows:
encodings.append({
"encoding_id": row["encoding_id"],
"locus_id": row["locus_id"],
"position": row["position"],
"locus_name": row["locus_name"],
"locus_visual": row["visual_description"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
})
return json.dumps(encodings)
@mcp.tool()
def start_walk_session(palace_id: int) -> str:
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
encodings = conn.execute(
"SELECT COUNT(*) FROM encodings WHERE palace_id = ?",
(palace_id,),
).fetchone()[0]
cursor = conn.execute(
"INSERT INTO sessions (palace_id, started_at, total_loci) VALUES (?, ?, ?)",
(palace_id, now, encodings),
)
session_id = cursor.lastrowid
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"palace_id": palace_id,
"total_loci": encodings,
"started_at": now,
})
@mcp.tool()
def grade_recall(session_id: int, locus_id: int, user_response: str, score: int) -> str:
if score not in (1, 2, 3, 4):
return json.dumps({"error": "Score must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)"})
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO recall_attempts (session_id, locus_id, user_response, score, created_at) VALUES (?, ?, ?, ?, ?)",
(session_id, locus_id, user_response, score, now),
)
conn.commit()
conn.close()
labels = {1: "Again (forgot)", 2: "Hard (struggled)", 3: "Good (hesitated)", 4: "Easy (instant)"}
return json.dumps({
"session_id": session_id,
"locus_id": locus_id,
"score": score,
"label": labels[score],
})
@mcp.tool()
def end_walk_session(session_id: int, duration_minutes: float) -> str:
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
attempts = conn.execute(
"SELECT locus_id, score FROM recall_attempts WHERE session_id = ?",
(session_id,),
).fetchall()
recalled = sum(1 for a in attempts if a["score"] >= 3)
struggle_loci = [a["locus_id"] for a in attempts if a["score"] <= 2]
conn.execute("""
UPDATE sessions SET ended_at = ?, loci_recalled = ?, duration_minutes = ?, struggle_loci = ?
WHERE id = ?
""", (now, recalled, duration_minutes, json.dumps(struggle_loci), session_id))
conn.commit()
conn.close()
return json.dumps({
"session_id": session_id,
"loci_recalled": recalled,
"total_loci": len(attempts),
"accuracy_percent": round(recalled / len(attempts) * 100, 1) if attempts else 0,
"struggle_loci_count": len(struggle_loci),
"duration_minutes": duration_minutes,
})
@mcp.tool()
def create_fsrs_cards(palace_id: int) -> str:
"""Create FSRS spaced repetition cards for all encodings in a palace.
Args:
palace_id: The palace whose encodings should become FSRS cards
"""
conn = get_db()
scheduler = Scheduler(desired_retention=0.9)
encodings = conn.execute(
"SELECT id FROM encodings WHERE palace_id = ? AND id NOT IN (SELECT encoding_id FROM fsrs_cards)",
(palace_id,),
).fetchall()
cards_created = 0
for enc in encodings:
card = Card()
now = datetime.now(timezone.utc).isoformat()
card_data = json.dumps({
"due": card.due.isoformat(),
"stability": card.stability,
"difficulty": card.difficulty,
"elapsed_days": card.elapsed_days,
"scheduled_days": card.scheduled_days,
"reps": card.reps,
"lapses": card.lapses,
"state": card.state.value if hasattr(card.state, 'value') else int(card.state),
"last_review": card.last_review.isoformat() if card.last_review else None,
})
conn.execute(
"INSERT INTO fsrs_cards (encoding_id, card_data, due_at) VALUES (?, ?, ?)",
(enc["id"], card_data, card.due.isoformat()),
)
cards_created += 1
conn.commit()
conn.close()
return json.dumps({"palace_id": palace_id, "cards_created": cards_created})
@mcp.tool()
def get_due_reviews() -> str:
"""Check which FSRS cards are due for review right now."""
conn = get_db()
now = datetime.now(timezone.utc).isoformat()
rows = conn.execute("""
SELECT fc.id as card_id, fc.encoding_id, fc.due_at, fc.review_count,
e.content_item, e.imagery_description,
l.name as locus_name, l.visual_description as locus_visual,
p.id as palace_id, p.name as palace_name
FROM fsrs_cards fc
JOIN encodings e ON fc.encoding_id = e.id
JOIN loci l ON e.locus_id = l.id
JOIN palaces p ON e.palace_id = p.id
WHERE fc.due_at <= ?
ORDER BY p.id, fc.due_at
""", (now,)).fetchall()
conn.close()
due_cards = []
for row in rows:
due_cards.append({
"card_id": row["card_id"],
"encoding_id": row["encoding_id"],
"palace_name": row["palace_name"],
"palace_id": row["palace_id"],
"locus_name": row["locus_name"],
"content_item": row["content_item"],
"imagery": row["imagery_description"],
"review_count": row["review_count"],
})
return json.dumps({"due_count": len(due_cards), "cards": due_cards})
@mcp.tool()
def complete_review(card_id: int, rating: int) -> str:
"""Complete a review of an FSRS card with a rating.
Args:
card_id: The FSRS card ID
rating: 1=Again, 2=Hard, 3=Good, 4=Easy
"""
if rating not in (1, 2, 3, 4):
return json.dumps({"error": "Rating must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)"})
conn = get_db()
row = conn.execute("SELECT card_data FROM fsrs_cards WHERE id = ?", (card_id,)).fetchone()
if not row:
conn.close()
return json.dumps({"error": "Card not found"})
scheduler = Scheduler(desired_retention=0.9)
card = Card()
rating_map = {1: Rating.Again, 2: Rating.Hard, 3: Rating.Good, 4: Rating.Easy}
card, review_log = scheduler.review_card(card, rating_map[rating])
now = datetime.now(timezone.utc).isoformat()
card_data = json.dumps({
"due": card.due.isoformat(),
"stability": card.stability,
"difficulty": card.difficulty,
"elapsed_days": card.elapsed_days,
"scheduled_days": card.scheduled_days,
"reps": card.reps,
"lapses": card.lapses,
"state": card.state.value if hasattr(card.state, 'value') else int(card.state),
"last_review": card.last_review.isoformat() if card.last_review else None,
})
conn.execute("""
UPDATE fsrs_cards SET card_data = ?, due_at = ?, last_review = ?, review_count = review_count + 1
WHERE id = ?
""", (card_data, card.due.isoformat(), now, card_id))
conn.commit()
conn.close()
return json.dumps({
"card_id": card_id,
"rating": rating,
"next_due": card.due.isoformat(),
"scheduled_days": card.scheduled_days,
})
@mcp.tool()
def get_progress() -> str:
"""Get overall progress statistics."""
conn = get_db()
palace_count = conn.execute("SELECT COUNT(*) FROM palaces").fetchone()[0]
loci_count = conn.execute("SELECT COUNT(*) FROM loci WHERE confirmed = 1").fetchone()[0]
encoding_count = conn.execute("SELECT COUNT(*) FROM encodings").fetchone()[0]
session_count = conn.execute("SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL").fetchone()[0]
recent_sessions = conn.execute("""
SELECT s.started_at, s.loci_recalled, s.total_loci, s.duration_minutes, p.name as palace_name
FROM sessions s
JOIN palaces p ON s.palace_id = p.id
WHERE s.ended_at IS NOT NULL
ORDER BY s.started_at DESC
LIMIT 10
""").fetchall()
total_reviews = conn.execute("SELECT COUNT(*) FROM recall_attempts").fetchone()[0]
good_reviews = conn.execute("SELECT COUNT(*) FROM recall_attempts WHERE score >= 3").fetchone()[0]
conn.close()
accuracy = round(good_reviews / total_reviews * 100, 1) if total_reviews > 0 else 0
return json.dumps({
"palaces": palace_count,
"confirmed_loci": loci_count,
"encodings": encoding_count,
"sessions_completed": session_count,
"total_recall_attempts": total_reviews,
"overall_accuracy_percent": accuracy,
"recent_sessions": [
{
"date": s["started_at"][:10],
"palace": s["palace_name"],
"recalled": s["loci_recalled"],
"total": s["total_loci"],
"accuracy": round(s["loci_recalled"] / s["total_loci"] * 100, 1) if s["total_loci"] > 0 else 0,
"duration_min": s["duration_minutes"],
}
for s in recent_sessions
],
})
if __name__ == "__main__":
init_db()
mcp.run(transport="stdio")
Your scheduling engine is complete. Every encoding can now become an FSRS card, Claude can check what is due, and each review feeds back into the algorithm. Next up, you will wire this server into Claude Desktop and run your first real memory palace session.
Connect to Claude Desktop and Run Your First Session
In the previous step, you built an FSRS scheduling engine that knows exactly when to schedule your next review.
Now it is time to bring everything to life. Your MCP server has 16 tools sitting on your machine, but Claude Desktop does not know they exist yet. In this step, you will register your server in Claude Desktop's config file, restart the app, and run a complete end-to-end memory palace session.
In this step, get ready to:
- Register the MCP server in Claude Desktop's config file and verify the connection.
- Create your first memory palace from a real space you know.
- Run a full encode-and-recall session with FSRS card creation.
Register your MCP server in Claude Desktop
Claude Desktop discovers MCP servers through a JSON config file. You need to tell it the exact path to your virtual environment's Python executable and your server script.
First, you need to grab the absolute paths that the config file requires. Make sure your virtual environment is activated in PowerShell (you should see (.venv) in your prompt).
- Print your Python executable path and your server script path by running this command:
python -c "import sys; from pathlib import Path; print('Python:', sys.executable); print('Script:', Path('memory_palace_server.py').resolve())"
You should see two lines of output showing full paths on your machine, something like:
Python: C:\Users\YourName\Desktop\memory-palace\.venv\Scripts\python.exe
Script: C:\Users\YourName\Desktop\memory-palace\memory_palace_server.py
- Copy both paths and save them somewhere (a sticky note or a blank text file). You will paste them into the config file next.
Now you need to open the Claude Desktop config file. On Windows, it lives at %APPDATA%\Claude\claude_desktop_config.json.
- Open the config file in Notepad by running this command:
notepad "$env:APPDATA\Claude\claude_desktop_config.json"
What if the file does not exist?
If Notepad asks whether you want to create a new file, click Yes. If the Claude folder itself does not exist, create it first by running:
mkdir "$env:APPDATA\Claude" -ErrorAction SilentlyContinue
Then run the Notepad command again.
- Replace the entire contents of the file with the following JSON, substituting the two paths you saved earlier:
{
"mcpServers": {
"memory-palace": {
"command": "[[VENV_PYTHON_PATH=\"C:\\\\Users\\\\YourName\\\\Desktop\\\\memory-palace\\\\.venv\\\\Scripts\\\\python.exe\"]]",
"args": ["[[SERVER_SCRIPT_PATH=\"C:\\\\Users\\\\YourName\\\\Desktop\\\\memory-palace\\\\memory_palace_server.py\"]]"]
}
}
}
Why the double backslashes?
JSON uses the backslash as an escape character. Every single \ in your Windows path needs to be written as \\ in the JSON file. For example, C:\Users\Name becomes C:\\Users\\Name in the config.
Pro tip: auto-generate the config
If you want to skip manual path escaping, run this command in PowerShell to generate the config JSON with properly escaped paths automatically:
python -c "import sys, json; from pathlib import Path; print(json.dumps({'mcpServers': {'memory-palace': {'command': sys.executable, 'args': [str(Path('memory_palace_server.py').resolve())]}}}, indent=2))"
Copy the output and paste it directly into your config file.
- Save the file in Notepad by pressing Ctrl+S.
- Close Notepad.
Now you need to fully restart Claude Desktop. On Windows, closing the window does not actually quit the app. It keeps running in the system tray.
- Look for the Claude icon in your system tray (the small icons area on the right side of your taskbar). You may need to click the up-arrow to show hidden icons.
- Right-click the Claude icon and select Quit.
- Reopen Claude Desktop by pressing the Windows key, typing Claude, and pressing Enter.
- In the chat input area, click the plus icon (labeled Add files, connectors, and more) and hover over the Connectors menu.
- Verify that memory-palace appears in the list of connected servers.
Server not showing up?
- Make sure you fully quit Claude from the system tray, not just closed the window. Closing the window leaves the old config loaded.
- Open your claude_desktop_config.json and verify the JSON syntax. A missing comma or extra bracket will silently break the config.
- Confirm that both paths in the config are absolute (starting with C:\\) and point to files that actually exist on your machine.
- Try running your server manually to check for errors:
python memory_palace_server.py
If this hangs silently (no error), the server is working. Press Ctrl+C to stop it. If you see an import error, make sure your virtual environment is activated.
Still stuck? Help me troubleshoot why my MCP server is not connecting to Claude Desktop on Windows.
Create your first memory palace
Your MCP server is connected. Every tool you built across the previous steps is now available to Claude. Time to use them.
You will create a palace from a real space you know well. Think of a room you could walk through with your eyes closed. Your bedroom, your kitchen, or your office are all good choices.
- Start a new conversation in Claude Desktop.
- Type a message describing a space you know well. For example:
I want to create a memory palace from my bedroom. Here's what it looks like:
When you walk in, the door is on the south wall. Immediately to the left is a tall bookshelf.
Along the west wall there's a desk with a monitor and a lamp. Next to the desk is a window
with blue curtains. The bed is along the north wall with a nightstand on each side. There's a
closet with sliding doors on the east wall, and a small rug in the center of the room.
What happens next?
Claude reads your description and calls your MCP tools behind the scenes. It will call create_palace to save the palace, then add_loci to extract anchor points from your description. Claude will ask you to approve each tool call before it runs.
- Click Allow each time Claude asks permission to use a tool. You will see tool names like create_palace and add_loci appear in the conversation.
After Claude extracts the loci, it will list them back to you and ask you to confirm them. This is the loci confirmation step from the Dresler protocol.
- Review the list of loci Claude proposes. Mentally walk through your space and check that each anchor point matches a real, distinct spot you can visualize.
- Tell Claude to confirm all the loci, or ask it to adjust any that do not match your mental image. You should aim for at least 10 confirmed loci.
Claude calls confirm_loci to lock in your approved anchor points. Your palace is now saved in the SQLite database and ready for encoding.
Encode items and run your first recall session
With your palace built, you can now memorize something real. You will give Claude a list of 10 items. Claude will generate vivid bizarre-imagery encodings for each one, then immediately test your recall.
- In the same conversation, type a message asking Claude to help you memorize a list. For example:
Help me memorize these 10 items using my bedroom palace:
1. Elephant
2. Guitar
3. Telescope
4. Pineapple
5. Volcano
6. Skateboard
7. Lightning
8. Compass
9. Jellyfish
10. Typewriter
What does Claude do with this list?
Claude calls encode_content to map each item to one of your confirmed loci. Then for each pairing, it calls generate_encoding to store a vivid bizarre-imagery description following the Bower 1970 protocol.
Each encoding includes an action, an interaction with the locus, emotional valence, and multi-sensory detail. This is the core mechanism that doubles recall compared to rote memorization.
- Click Allow for each tool call as Claude generates the encodings. Read each bizarre-imagery description carefully and try to visualize it at the locus.
After all 10 encodings are stored, Claude will offer to run a coached walk. This is where the real training happens.
- Tell Claude to start the recall session. It calls start_walk_session to begin.
- Claude will describe each locus one at a time and ask you to recall what is stored there. Type your answer for each one.
- After each response, Claude compares your answer to the stored encoding and calls grade_recall with a score from 1 to 4 (Again, Hard, Good, Easy).
- Continue through all 10 loci. At the end, Claude calls end_walk_session and shows your accuracy score and any struggle loci.
Finally, ask Claude to set up spaced repetition for everything you just memorized.
- Type a message like: "Create FSRS cards for my palace so I know when to review."
- Claude calls create_fsrs_cards and confirms how many cards were created. Each card tracks its own optimal review schedule at 0.9 target retention.
- Ask Claude to show your progress by typing: "Show me my progress stats."
Claude calls get_progress and returns a summary including your palace count, confirmed loci, encodings, session accuracy, and total recall attempts.
What should I see at this point?
Your progress stats should show 1 palace, 10+ confirmed loci, 10 encodings, 1 completed session, and 10 FSRS cards. The memory_palace.db file now exists in your project folder with all of this data stored permanently.
Claude is not calling any tools?
- Check that the MCP server is still connected by clicking the plus icon and hovering over Connectors. If memory-palace is missing, fully quit Claude from the system tray and reopen it.
- Try being more explicit in your prompt. Say "Use your memory palace tools to create a palace called My Bedroom" instead of a general request.
- If Claude says it cannot find a tool, the server may have crashed. Fully quit and reopen Claude Desktop to restart the server process.
Still having trouble? Help me get Claude Desktop to call my MCP tools during a memory palace session.
Secret mission
Add a Dresler Protocol Certification Test
World Memory Championship competitors do not just train. They test themselves against a standardized 72-word recall protocol. Can you add a timed certification mode that scores your recall against the published research baselines?
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database and a Python MCP server, so there are no ongoing cloud costs.
Resources you used:
- Python virtual environment (.venv folder in your project directory).
- MCP server file (memory_palace_server.py).
- SQLite database (memory_palace.db) containing your palaces, loci, encodings, FSRS cards, and session logs.
- Claude Desktop MCP configuration entry in claude_desktop_config.json.
- Installed Python packages (mcp[cli] and fsrs) inside the virtual environment.
Keep everything running
No action needed. Choose this if you are still actively building or plan to use your memory palace coach for daily training sessions. The MCP server only runs when Claude Desktop is open, so it uses no resources in the background.
- Your palaces, encodings, and FSRS review schedules stay in the SQLite database ready for your next session.
- Open Claude Desktop anytime and ask "What should I review today?" to pick up where you left off.
Pause - I'll come back to this later
Disconnect the MCP server from Claude Desktop but keep all your project files intact. You can reconnect later without losing any data.
- Open claude_desktop_config.json at %APPDATA%\Claude\claude_desktop_config.json.
- Remove the memory-palace entry from the mcpServers object (or comment it out).
- Restart Claude Desktop.
- Your project folder, virtual environment, and memory_palace.db remain untouched. To reconnect later, add the config entry back and restart Claude Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your palaces, encodings, session history, and FSRS review data.
- Open claude_desktop_config.json and remove the memory-palace entry from mcpServers.
- Restart Claude Desktop so it stops looking for the server.
- Delete your entire project folder. This removes memory_palace_server.py, memory_palace.db, and the .venv virtual environment all at once.
- If you installed Python specifically for this project and no longer need it, uninstall it from Settings, then Apps, then Installed apps.
Mission accomplished
Nice Work!
You just built a fully functional AI memory coach that lives inside Claude Desktop. It creates memory palaces, generates bizarre-imagery encodings backed by cognitive science, runs coached recall sessions, and schedules reviews at scientifically optimal intervals. This is the same training protocol used by 9 out of 10 World Memory Championship competitors.
Here's what you learned:
- Built a Python MCP server with 10+ tools that give Claude persistent memory for palace management, encoding, coached recall, and progress tracking.
- Implemented the Dresler 2017 method-of-loci protocol with a SQLite database storing your personal palace graph of palaces, loci, encodings, and session logs.
- Integrated the FSRS spaced repetition algorithm to schedule palace walk reviews at the exact intervals that maximize long-term retention.
- Added a Dresler-protocol certification test that scores your 72-word recall against the published research baselines.
Ready to quiz yourself?