Skip to main content
Glama
JrmHsr

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