mcp-persistent-context
by JrmHsr
README.md
# 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`](https://github.com/modelcontextprotocol/servers/tree/main/src/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
```bash
pip install "mcp[cli]"
python mcp_memory_server.py --transport streamable-http --port 8770
```
For Claude Desktop (stdio):
```json
{
"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
```python
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:**
```python
# 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.
```python
# 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
```python
# 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
```python
# 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)
```python
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_memory` at 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`
- [ ] `content` format: `key=value | key=value` (soft warn if no `=`)
- [ ] Key-based dedup on `(client_id, namespace, category)`
- [ ] `content` truncated at `MAX_CONTENT_LEN` (default 500)
- [ ] Max `MAX_ENTRIES_PER_CLIENT` entries (default 200)
- [ ] TTL pruning on `get_memory`
- [ ] `client_id` defaults to `_default`
- [ ] `namespace` defaults to `general`
- [ ] Categories: recommended set + custom `x_` prefix (warn, don't reject)
- [ ] `type` enum: decision, exclusion, insight, action, anomaly
## License
MIT
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues