MCP Persistent Context
A lightweight memory layer for custom MCP servers — persist user context across LLM sessions with minimal token cost.
4 tools. ~600 schema tokens. Key-value dedup. Multi-tenant. Cross-MCP.
Why?
LLMs forget everything between sessions. Every conversation starts from zero.
The official @modelcontextprotocol/server-memory solves this with a knowledge graph (entities, relations, observations). It's powerful — but can be overkill for simple context persistence:
| | server-memory | This project | |---|---|---| | Tools | 9 (~1500 schema tokens) | 4 (~600 schema tokens) | | Read cost | read_graph returns full graph | Paginated, filtered, compact | | Multi-tenant | No | Yes (client_id) | | Cross-MCP | No | Yes (namespace) | | Dedup | By entity name | By key within (client_id, namespace, category) | | TTL | No | Optional per-entry expiration |
They are complementary. Use server-memory when you need entity relationships. Use this when you need fast, cheap, structured context for custom MCP projects.
Which version should I use?
Do you have 1 MCP server or multiple?
1 MCP server ──→ Embed (tools_memory.py)
Copy into your project, register tools, done.
2+ MCP servers ─→ Standalone (mcp_memory_server.py)
Run as a separate MCP. Domain servers stay clean.
Context is shared across all MCPs.
| | Embedded | Standalone | |---|---|---| | File | tools_memory.py | mcp_memory_server.py | | Setup | Import + register in your server | Run as separate process | | Cross-MCP | No (lives inside one MCP) | Yes (shared by all MCPs) | | Schema cost | Adds ~600 tokens to your MCP | ~600 tokens in its own MCP | | Best for | Single MCP projects | Multi-MCP architectures |
Quick Start
Standalone server
pip install "mcp[cli]"
python mcp_memory_server.py --transport streamable-http --port 8770
For Claude Desktop (stdio):
{
"mcpServers": {
"memory": {
"command": "python",
"args": ["path/to/mcp_memory_server.py"],
"env": {
"MEMORY_DIR": "/path/to/memory_data",
"MAX_ENTRIES_PER_CLIENT": "500"
}
}
}
}
Embedded in your MCP server
from mcp.server.fastmcp import FastMCP
from tools_memory import register_memory_tools
from pathlib import Path
from datetime import datetime
mcp = FastMCP("My App")
# Register your domain tools
@mcp.tool()
def do_something(query: str) -> str:
return process(query)
# Register memory tools (4 tools added to your server)
register_memory_tools(
mcp,
memory_dir=Path("./memory_data"),
)
mcp.run()
Tools
save_memory
save_memory(
category="business_context",
type="insight",
content="AS=0 | persona=seniors | monetization=affiliate",
reason="Client business context for SEO strategy.",
client_id="_default",
namespace="general",
ttl_days=0
)
→ "Saved. business_context | insight | 4 entries"
get_memory
get_memory(client_id="acme_corp")
→ Memory 'acme_corp' (4/4):
2026-02-26 INSIGHT | general/business_context | AS=0 | persona=seniors | monetization=affiliate
2026-02-26 DECISION | seo/domain_context | pillar=cloud_computing | approach=editorial_first
2026-02-26 EXCLUSION | seo/domain_context | exclude=serverless | reason=off_topic
2026-02-15 ACTION | general/project_config | stack=React+Node | deploy=Vercel [90d]
delete_memory
delete_memory(content_match="persona", client_id="acme_corp")
→ "Deleted: AS=0 | persona=seniors | monetization=affiliate
3 entries remaining"
memory_status
memory_status(client_id="acme_corp")
→ "'acme_corp': 3 entries | ns: general, seo | cat: business_context, domain_context | 2026-02-15 → 2026-02-26 | 1 with TTL"
Content Format
key=value | key=value | key=value
Why key=value, not JSON?
- 2-3x fewer tokens (
{"key":"value"}= 7 tokens,key=value= 3) - Enables key-based dedup without NLP
- LLMs naturally produce and parse it
- Works across any domain
Key=value is recommended, not enforced. The server warns if no = is detected, but still saves the entry. Some use cases need free text (e.g. content="Client confirmed budget by phone"). The dedup engine simply skips entries without parseable keys.
Examples across domains:
# Marketing / SEO
"AS=0 | persona=seniors | monetization=affiliate+partnerships"
# Healthcare
"allergy=penicillin | blood_type=O+ | primary_care=Dr.Smith"
# Software Engineering
"stack=React+Node | deploy=Vercel | CI=GitHub_Actions"
# Legal
"jurisdiction=FR | entity=SAS | fiscal_year=calendar"
# Education
"level=grade10 | learning_style=visual | weakness=algebra"
Key-Based Dedup
Same (client_id, namespace, category) + overlapping key → merge, don't duplicate:
Existing: "AS=0 | persona=seniors"
Incoming: "AS=12 | site=launched"
Result: "AS=12 | persona=seniors | site=launched"
No parseable keys → append as new entry (no dedup attempted).
Integration Examples
Example 1: Domain MCP delegates memory to standalone server
Your domain MCP does its job. Memory lives elsewhere.
# my_domain_mcp.py — zero memory logic
@mcp.tool()
def analyze_data(query: str) -> str:
results = run_analysis(query)
return json.dumps(results)
Claude's system prompt handles the memory calls:
You have access to two MCP servers: Domain and Memory.
At session start: call get_memory() to load user context.
When the user shares business context, preferences, or decisions:
call save_memory() with key=value content.
Claude sees both MCPs, calls get_memory() at start, calls domain tools for work, calls save_memory() when the user shares context. The domain MCP never touches memory.
Example 2: Single MCP with embedded memory
# my_mcp_server.py
from mcp.server.fastmcp import FastMCP
from tools_memory import register_memory_tools
mcp = FastMCP("My App")
@mcp.tool()
def do_something(query: str) -> str:
result = process(query)
# Trigger reminder in response
return f"{result}\n\nMEMORY: save_memory() if user shared context."
register_memory_tools(mcp, memory_dir=Path("./data"))
mcp.run()
Example 3: Multi-tenant with namespace filtering
# User works with client "acme_corp" across multiple domains
# Session 1 (SEO context)
save_memory(client_id="acme_corp", namespace="seo",
category="business_context", type="insight",
content="AS=45 | market=US | vertical=saas",
reason="SEO baseline metrics")
# Session 2 (Ads context) — can read SEO memory too
get_memory(client_id="acme_corp")
# → returns BOTH seo and ads entries
get_memory(client_id="acme_corp", namespace="ads")
# → returns only ads entries
Categories
Recommended (cover most domains):
| Category | What it stores | |----------|---------------| | business_context | Company, market, monetization, personas | | project_config | Stack, architecture, conventions | | user_preference | Workflow, tone, formatting style | | domain_context | Domain-specific decisions | | analysis_context | Recurring findings, baselines | | content_strategy | Editorial guidelines, content types |
Custom: Use x_ prefix (x_medical_history, x_legal_discovery). The server warns on unknown categories but does not reject them.
Types
| Type | When to use | |------|-------------| | decision | User chose between options | | exclusion | User explicitly rejected something | | insight | Factual context about user/project | | action | User committed to a plan | | anomaly | Unexpected finding worth remembering |
TTL (Time-To-Live)
save_memory(..., ttl_days=90) # expires in 90 days
save_memory(..., ttl_days=0) # permanent (default)
- Permanent: Business identity, user preferences, architecture decisions
- 90 days: Campaign context, quarterly goals
- 30 days: Temporary constraints, short-term priorities
Expired entries are pruned automatically on get_memory.
Architecture: 1 Memory MCP, N Domain MCPs
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ MCP SEO │ │ MCP Ads │ │ MCP Email │
│ 0 memory │ │ 0 memory │ │ 0 memory │
│ tools │ │ tools │ │ tools │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────┬────────┴────────┬────────┘
│ │
┌──────┴──────┐ │
│ MCP Memory │◄────────┘
│ 4 tools │
│ shared ctx │
│ ~600 tok │
└─────────────┘
Benefits:
- Schema tokens: ~600 total (not ~600 x N)
- 1
get_memoryat session start (not N) - Context from SEO visible in Ads and vice versa
- Domain MCPs stay focused on their job
Triggering Memory Calls
System prompt instructions alone do NOT reliably trigger LLM memory calls.
Strategy A — Dedicated Memory MCP (recommended for multi-MCP):
Add to system prompt: `` At session start: call get_memory() to load user context. After state-changing tools: if the user shared context, call save_memory(). ``
Strategy B — Embedded in domain MCP (for single-MCP setups):
Inject short reminders in tool responses: `` MEMORY: context shared? → save_memory() | correction? → delete_memory() ``
Keep trigger text under 25 tokens per tool response.
What to persist
| Persist | Don't persist | |---------|--------------| | User decisions and preferences | Tool outputs or raw data | | Business constraints | Intermediate calculations | | Explicit corrections | Session-specific state | | What changes future behavior | What can be re-derived |
Server-Side Guards
| Guard | Rule | |-------|------| | Key dedup | Same (client_id, ns, category) + overlapping key → merge | | Truncate | content capped at 500 chars | | Prune | Max entries per client (default: 200, configurable) | | TTL | Expired entries pruned on read | | Content warning | Soft warn if no = detected (does not reject) | | Category warning | Soft warn on non-standard categories (does not reject) |
Configuration
| Variable | Default | Description | |----------|---------|-------------| | MEMORY_DIR | ./memory_data | Base directory for memory files | | MEMORY_PORT | 8770 | HTTP port (streamable-http transport) | | MAX_CONTENT_LEN | 500 | Max characters per content field | | MAX_ENTRIES_PER_CLIENT | 200 | Max entries per client before pruning oldest |
Storage
{MEMORY_DIR}/{client_id}/memory.json
Each entry: ``json { "namespace": "seo", "category": "business_context", "type": "insight", "content": "AS=0 | persona=seniors | monetization=affiliate", "reason": "Client business profile.", "date": "2026-02-26T14:30:00", "ttl_days": 90 } ``
Implementation Checklist
- [ ] 4 tools:
save_memory,get_memory,delete_memory,memory_status - [ ]
contentformat:key=value | key=value(soft warn if no=) - [ ] Key-based dedup on
(client_id, namespace, category) - [ ]
contenttruncated atMAX_CONTENT_LEN(default 500) - [ ] Max
MAX_ENTRIES_PER_CLIENTentries (default 200) - [ ] TTL pruning on
get_memory - [ ]
client_iddefaults to_default - [ ]
namespacedefaults togeneral - [ ] Categories: recommended set + custom
x_prefix (warn, don't reject) - [ ]
typeenum: decision, exclusion, insight, action, anomaly
License
MIT











