feat: standalone claude-memory-mcp with multi-user support and Vault integration

Extracted from private infra repo into standalone open-source project.

Three operating modes:
- Local: SQLite + FTS5 (zero dependencies)
- Server: PostgreSQL via HTTP API with multi-user auth
- Full: PostgreSQL + HashiCorp Vault for secret management

Features:
- MCP stdio server with 5 tools (store/recall/list/delete/secret_get)
- FastAPI HTTP API with multi-user Bearer token auth (API_KEYS JSON map)
- Regex-based credential detection with auto-redaction
- AES-256-GCM encryption fallback for non-Vault deployments
- Vault KV v2 client (stdlib urllib, K8s SA auto-auth)
- Per-user data isolation (all queries scoped by user_id)
- Secret migration endpoint for existing plain-text credentials
- Backward-compatible env var aliases (CLAUDE_MEMORY_API_URL)

Infrastructure:
- Docker + docker-compose (API + PostgreSQL + optional Vault)
- Woodpecker CI (test → build → push → kubectl deploy)
- GitHub Actions CI (Python 3.11/3.12/3.13) + Release (GHCR + PyPI)
- Helm chart + raw Kubernetes manifests

96 tests passing across 6 test files.
This commit is contained in:
Viktor Barzin 2026-03-14 09:42:05 +00:00
commit 0ed5e1e016
No known key found for this signature in database
GPG key ID: 0EB088298288D958
40 changed files with 3381 additions and 0 deletions

View file

@ -0,0 +1,3 @@
"""Claude Memory MCP — standalone memory server with multi-user support."""
__version__ = "1.0.0"

View file

@ -0,0 +1,4 @@
"""Allow running as `python -m claude_memory`."""
from claude_memory.mcp_server import main
main()

View file

View file

@ -0,0 +1,337 @@
"""Claude Memory API -- shared persistent memory with PostgreSQL full-text search."""
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException
from claude_memory.api.auth import AuthUser, get_current_user
from claude_memory.api.database import close_pool, get_pool, init_pool
from claude_memory.api.models import MemoryRecall, MemoryResponse, MemoryStore, SecretResponse
from claude_memory.api.vault_service import (
delete_secret,
get_secret,
is_vault_configured,
store_secret,
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_pool()
yield
await close_pool()
app = FastAPI(title="Claude Memory API", lifespan=lifespan)
def _detect_sensitive(content: str) -> bool:
"""Check if content contains credentials using the credential detector."""
try:
from claude_memory.credential_detector import detect_credentials
findings = detect_credentials(content)
return len(findings) > 0
except ImportError:
return False
def _redact_content(content: str) -> str:
"""Redact sensitive content for storage in the main DB."""
try:
from claude_memory.credential_detector import detect_credentials, redact_credentials
creds = detect_credentials(content)
if creds:
return redact_credentials(content, creds)
return content
except ImportError:
return "[REDACTED]"
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/api/memories", response_model=MemoryResponse)
async def store_memory(body: MemoryStore, user: AuthUser = Depends(get_current_user)):
pool = await get_pool()
is_sensitive = body.force_sensitive or _detect_sensitive(body.content)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, category, importance
""",
user.user_id,
body.content if not is_sensitive else _redact_content(body.content),
body.category,
body.tags,
body.expanded_keywords,
body.importance,
is_sensitive,
)
memory_id = row["id"]
if is_sensitive and is_vault_configured():
vault_path = await store_secret(user.user_id, memory_id, body.content)
await conn.execute(
"UPDATE memories SET vault_path = $1 WHERE id = $2",
vault_path,
memory_id,
)
return MemoryResponse(id=row["id"], category=row["category"], importance=row["importance"])
@app.post("/api/memories/recall")
async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_current_user)):
pool = await get_pool()
query_text = f"{body.context} {body.expanded_query}".strip()
order_clause = "ts_rank(search_vector, query) DESC"
if body.sort_by == "importance":
order_clause = "importance DESC, ts_rank(search_vector, query) DESC"
elif body.sort_by == "recency":
order_clause = "created_at DESC"
category_filter = ""
params: list = [user.user_id, query_text, body.limit]
if body.category:
category_filter = "AND category = $4"
params.append(body.category)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, content, category, tags, importance, is_sensitive,
ts_rank(search_vector, query) AS rank,
created_at, updated_at
FROM memories, plainto_tsquery('english', $2) query
WHERE user_id = $1
AND (search_vector @@ query OR $2 = '')
{category_filter}
ORDER BY {order_clause}
LIMIT $3
""",
*params,
)
results = []
for row in rows:
content = row["content"]
if row["is_sensitive"]:
content = f"[SENSITIVE - use secret_get(id={row['id']})]"
results.append(
{
"id": row["id"],
"content": content,
"category": row["category"],
"tags": row["tags"],
"importance": row["importance"],
"is_sensitive": row["is_sensitive"],
"rank": float(row["rank"]),
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
}
)
return results
@app.get("/api/memories")
async def list_memories(
category: Optional[str] = None,
limit: int = 50,
user: AuthUser = Depends(get_current_user),
):
pool = await get_pool()
if category:
query = """
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
FROM memories WHERE user_id = $1 AND category = $2
ORDER BY importance DESC LIMIT $3
"""
params: list = [user.user_id, category, limit]
else:
query = """
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
FROM memories WHERE user_id = $1
ORDER BY importance DESC LIMIT $2
"""
params = [user.user_id, limit]
async with pool.acquire() as conn:
rows = await conn.fetch(query, *params)
results = []
for row in rows:
content = row["content"]
if row["is_sensitive"]:
content = f"[SENSITIVE - use secret_get(id={row['id']})]"
results.append(
{
"id": row["id"],
"content": content,
"category": row["category"],
"tags": row["tags"],
"importance": row["importance"],
"is_sensitive": row["is_sensitive"],
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
}
)
return results
@app.delete("/api/memories/{memory_id}")
async def delete_memory(memory_id: int, user: AuthUser = Depends(get_current_user)):
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, vault_path FROM memories WHERE id = $1 AND user_id = $2",
memory_id,
user.user_id,
)
if not row:
raise HTTPException(status_code=404, detail="Memory not found")
if row["vault_path"]:
await delete_secret(user.user_id, row["vault_path"])
await conn.execute(
"DELETE FROM memories WHERE id = $1 AND user_id = $2",
memory_id,
user.user_id,
)
return {"deleted": memory_id}
@app.post("/api/memories/{memory_id}/secret", response_model=SecretResponse)
async def get_memory_secret(memory_id: int, user: AuthUser = Depends(get_current_user)):
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, content, is_sensitive, vault_path, encrypted_content
FROM memories WHERE id = $1 AND user_id = $2
""",
memory_id,
user.user_id,
)
if not row:
raise HTTPException(status_code=404, detail="Memory not found")
if not row["is_sensitive"]:
return SecretResponse(id=row["id"], content=row["content"], source="plaintext")
if row["vault_path"]:
secret = await get_secret(user.user_id, row["vault_path"])
if secret:
return SecretResponse(id=row["id"], content=secret, source="vault")
if row["encrypted_content"]:
return SecretResponse(
id=row["id"],
content="[ENCRYPTED - decryption not available]",
source="encrypted",
)
return SecretResponse(id=row["id"], content=row["content"], source="plaintext")
@app.post("/api/memories/migrate-secrets")
async def migrate_secrets(user: AuthUser = Depends(get_current_user)):
pool = await get_pool()
migrated = 0
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, content FROM memories
WHERE user_id = $1 AND is_sensitive = FALSE
""",
user.user_id,
)
for row in rows:
if _detect_sensitive(row["content"]):
original_content = row["content"]
redacted = _redact_content(original_content)
vault_path = None
if is_vault_configured():
vault_path = await store_secret(user.user_id, row["id"], original_content)
await conn.execute(
"""
UPDATE memories
SET is_sensitive = TRUE, content = $1, vault_path = $2,
updated_at = NOW()
WHERE id = $3 AND user_id = $4
""",
redacted,
vault_path,
row["id"],
user.user_id,
)
migrated += 1
return {"migrated": migrated}
@app.post("/api/memories/import")
async def import_memories(
memories: list[MemoryStore], user: AuthUser = Depends(get_current_user)
):
pool = await get_pool()
imported = []
async with pool.acquire() as conn:
for mem in memories:
is_sensitive = mem.force_sensitive or _detect_sensitive(mem.content)
content = mem.content if not is_sensitive else _redact_content(mem.content)
row = await conn.fetchrow(
"""
INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, category, importance
""",
user.user_id,
content,
mem.category,
mem.tags,
mem.expanded_keywords,
mem.importance,
is_sensitive,
)
if is_sensitive and is_vault_configured():
vault_path = await store_secret(user.user_id, row["id"], mem.content)
await conn.execute(
"UPDATE memories SET vault_path = $1 WHERE id = $2",
vault_path,
row["id"],
)
imported.append(
MemoryResponse(id=row["id"], category=row["category"], importance=row["importance"])
)
return imported

View file

@ -0,0 +1,32 @@
import json
import os
from dataclasses import dataclass
from fastapi import Header, HTTPException
@dataclass
class AuthUser:
user_id: str
# Multi-user mode: API_KEYS='{"viktor": "key1", "user2": "key2"}'
# Single-user mode: API_KEY="some-key" (backward compatible, user_id="default")
_api_keys_json = os.environ.get("API_KEYS", "")
_api_key_single = os.environ.get("API_KEY", "")
_key_to_user: dict[str, str] = {}
if _api_keys_json:
_user_to_key = json.loads(_api_keys_json)
_key_to_user = {v: k for k, v in _user_to_key.items()}
elif _api_key_single:
_key_to_user = {_api_key_single: "default"}
async def get_current_user(authorization: str = Header(...)) -> AuthUser:
token = authorization.removeprefix("Bearer ").strip()
user_id = _key_to_user.get(token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid API key")
return AuthUser(user_id=user_id)

View file

@ -0,0 +1,55 @@
import os
import asyncpg
DATABASE_URL = os.environ.get("DATABASE_URL", "")
pool: asyncpg.Pool | None = None
async def init_pool() -> asyncpg.Pool:
global pool
pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
async with pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id SERIAL PRIMARY KEY,
user_id VARCHAR(100) NOT NULL DEFAULT 'default',
content TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'facts',
tags TEXT DEFAULT '',
expanded_keywords TEXT DEFAULT '',
importance REAL DEFAULT 0.5,
is_sensitive BOOLEAN DEFAULT FALSE,
vault_path TEXT DEFAULT NULL,
encrypted_content BYTEA DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(content, '')), 'A') ||
setweight(to_tsvector('english', coalesce(expanded_keywords, '')), 'B') ||
setweight(to_tsvector('english', coalesce(tags, '')), 'C') ||
setweight(to_tsvector('english', coalesce(category, '')), 'D')
) STORED
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memories_search ON memories USING GIN(search_vector)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id)"
)
return pool
async def close_pool():
global pool
if pool:
await pool.close()
pool = None
async def get_pool() -> asyncpg.Pool:
if pool is None:
raise RuntimeError("Database pool not initialized")
return pool

View file

@ -0,0 +1,32 @@
from typing import Optional
from pydantic import BaseModel, Field
class MemoryStore(BaseModel):
content: str
category: str = "facts"
tags: str = ""
expanded_keywords: str = ""
importance: float = Field(default=0.5, ge=0.0, le=1.0)
force_sensitive: bool = False
class MemoryRecall(BaseModel):
context: str
expanded_query: str = ""
category: Optional[str] = None
sort_by: str = "importance"
limit: int = 10
class MemoryResponse(BaseModel):
id: int
category: str
importance: float
class SecretResponse(BaseModel):
id: int
content: str
source: str # "vault", "encrypted", "plaintext"

View file

@ -0,0 +1,51 @@
import os
import logging
logger = logging.getLogger(__name__)
VAULT_ADDR = os.environ.get("VAULT_ADDR", "")
VAULT_TOKEN = os.environ.get("VAULT_TOKEN", "")
VAULT_MOUNT = os.environ.get("VAULT_MOUNT", "secret")
VAULT_PREFIX = os.environ.get("VAULT_PREFIX", "claude-memory")
def is_vault_configured() -> bool:
return bool(VAULT_ADDR and VAULT_TOKEN)
async def store_secret(user_id: str, memory_id: int, content: str) -> str:
"""Store secret content in Vault. Returns the vault path."""
if not is_vault_configured():
raise RuntimeError("Vault not configured")
from claude_memory.vault_client import VaultClient
client = VaultClient(VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT)
path = f"{VAULT_PREFIX}/{user_id}/mem-{memory_id}"
client.write(path, {"content": content})
return path
async def get_secret(user_id: str, vault_path: str) -> str | None:
"""Retrieve secret content from Vault."""
if not is_vault_configured():
return None
from claude_memory.vault_client import VaultClient
client = VaultClient(VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT)
data = client.read(vault_path)
if data:
return data.get("content")
return None
async def delete_secret(user_id: str, vault_path: str) -> bool:
"""Delete secret from Vault."""
if not is_vault_configured():
return False
from claude_memory.vault_client import VaultClient
client = VaultClient(VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT)
return client.delete(vault_path)

View file

@ -0,0 +1,76 @@
"""Detect credentials and secrets in text content."""
import re
from dataclasses import dataclass
@dataclass
class DetectedCredential:
type: str # e.g. "password", "api_key", "private_key", "connection_string", "token"
confidence: float # 0.0 to 1.0
start: int # position in text
end: int # position in text
matched_text: str # the actual matched text (for redaction)
# Patterns ordered by confidence
_PATTERNS: list[tuple[str, str, float]] = [
# High confidence (0.9+)
("private_key", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", 0.95),
("connection_string", r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s'\"]+", 0.9),
("aws_key", r"(?:AKIA|ASIA)[A-Z0-9]{16}", 0.95),
("github_token", r"gh[pousr]_[A-Za-z0-9_]{36,}", 0.95),
# Medium confidence (0.7-0.89)
("api_key", r"(?:api[_-]?key|apikey)\s*[:=]\s*['\"]?([A-Za-z0-9_\-]{20,})['\"]?", 0.8),
("password", r"(?:password|passwd|pwd)\s*[:=]\s*['\"]?([^\s'\"]{8,})['\"]?", 0.8),
("token", r"(?:token|secret|bearer)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{20,})['\"]?", 0.75),
("basic_auth", r"(?:Basic\s+)[A-Za-z0-9+/=]{20,}", 0.85),
("bearer_token", r"Bearer\s+[A-Za-z0-9_\-\.]{20,}", 0.85),
# Lower confidence (0.5-0.69)
("generic_secret", r"(?:secret|credential|auth)\s*[:=]\s*['\"]?([^\s'\"]{12,})['\"]?", 0.6),
("hex_key", r"(?:key|secret)\s*[:=]\s*['\"]?([0-9a-fA-F]{32,})['\"]?", 0.65),
]
def detect_credentials(text: str, min_confidence: float = 0.5) -> list[DetectedCredential]:
"""Scan text for potential credentials and secrets."""
results: list[DetectedCredential] = []
for cred_type, pattern, confidence in _PATTERNS:
if confidence < min_confidence:
continue
for match in re.finditer(pattern, text, re.IGNORECASE):
results.append(DetectedCredential(
type=cred_type,
confidence=confidence,
start=match.start(),
end=match.end(),
matched_text=match.group(0),
))
# Deduplicate overlapping matches, keeping highest confidence
results.sort(key=lambda c: (-c.confidence, c.start))
filtered: list[DetectedCredential] = []
for cred in results:
if not any(c.start <= cred.start and c.end >= cred.end for c in filtered):
filtered.append(cred)
return sorted(filtered, key=lambda c: c.start)
def redact_credentials(text: str, credentials: list[DetectedCredential]) -> str:
"""Replace detected credentials with [REDACTED] markers."""
if not credentials:
return text
parts: list[str] = []
last_end = 0
for cred in sorted(credentials, key=lambda c: c.start):
parts.append(text[last_end:cred.start])
parts.append(f"[REDACTED:{cred.type}]")
last_end = cred.end
parts.append(text[last_end:])
return "".join(parts)
def is_sensitive(text: str, min_confidence: float = 0.7) -> bool:
"""Quick check if text likely contains credentials."""
return len(detect_credentials(text, min_confidence)) > 0

View file

@ -0,0 +1,71 @@
"""AES-256-GCM encryption for memory content when Vault is not available."""
import base64
import hashlib
import os
ENCRYPTION_KEY_ENV = "MEMORY_ENCRYPTION_KEY"
def _get_key() -> bytes | None:
"""Get 32-byte encryption key from environment."""
raw = os.environ.get(ENCRYPTION_KEY_ENV)
if not raw:
return None
# Accept hex-encoded 32-byte key or derive from passphrase
try:
key = bytes.fromhex(raw)
if len(key) == 32:
return key
except ValueError:
pass
# Derive key from passphrase using SHA-256
return hashlib.sha256(raw.encode()).digest()
def is_encryption_configured() -> bool:
return _get_key() is not None
def encrypt(plaintext: str) -> bytes:
"""Encrypt text using AES-256-GCM. Returns nonce + ciphertext + tag."""
key = _get_key()
if key is None:
raise RuntimeError(f"{ENCRYPTION_KEY_ENV} not set")
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError:
raise RuntimeError("cryptography package required for encryption: pip install cryptography")
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
return nonce + ciphertext # 12 bytes nonce + ciphertext + 16 bytes tag
def decrypt(data: bytes) -> str:
"""Decrypt AES-256-GCM encrypted data."""
key = _get_key()
if key is None:
raise RuntimeError(f"{ENCRYPTION_KEY_ENV} not set")
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError:
raise RuntimeError("cryptography package required for encryption: pip install cryptography")
nonce = data[:12]
ciphertext = data[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, None).decode()
def encrypt_b64(plaintext: str) -> str:
"""Encrypt and return base64-encoded string."""
return base64.b64encode(encrypt(plaintext)).decode()
def decrypt_b64(data: str) -> str:
"""Decrypt from base64-encoded string."""
return decrypt(base64.b64decode(data))

View file

@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""
Claude Memory MCP Server standalone memory server with multi-user support.
Supports two modes:
1. HTTP API mode: connects to a shared PostgreSQL-backed API server
2. SQLite fallback: local file-based storage when no API key is configured
Uses only stdlib (urllib) no pip install required.
"""
import json
import logging
import os
import sys
import urllib.error
import urllib.request
from typing import Any
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "claude-memory"
SERVER_VERSION = "1.0.0"
# API configuration — support both MEMORY_* (primary) and CLAUDE_MEMORY_* (fallback) env vars
API_BASE_URL = os.environ.get("MEMORY_API_URL") or os.environ.get("CLAUDE_MEMORY_API_URL", "http://localhost:8080")
API_KEY = os.environ.get("MEMORY_API_KEY") or os.environ.get("CLAUDE_MEMORY_API_KEY", "")
# Fallback to SQLite if API is not configured
SQLITE_FALLBACK = not API_KEY
def _api_request(method: str, path: str, body: dict | None = None) -> dict:
"""Make an HTTP request to the memory API."""
url = f"{API_BASE_URL}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else str(e)
raise RuntimeError(f"API error {e.code}: {error_body}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"API connection error: {e.reason}") from e
# ─── SQLite fallback (local storage when API not configured) ─────────────────
def _init_sqlite(db_path: str | None = None):
"""Initialize SQLite database as fallback."""
import sqlite3
from pathlib import Path
if db_path is None:
memory_home = os.path.expandvars(
os.path.expanduser(os.environ.get("MEMORY_HOME", "~/.claude/claude-memory"))
)
db_path = os.environ.get(
"MEMORY_DB",
os.path.join(memory_home, "memory", "memory.db"),
)
db_path = os.path.expandvars(os.path.expanduser(db_path))
Path(os.path.dirname(db_path)).mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path, timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
category TEXT DEFAULT 'facts',
tags TEXT DEFAULT '',
expanded_keywords TEXT DEFAULT '',
importance REAL DEFAULT 0.5,
is_sensitive INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content, category, tags, expanded_keywords,
content='memories', content_rowid='id'
)
""")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, category, tags, expanded_keywords)
VALUES (new.id, new.content, new.category, new.tags, new.expanded_keywords);
END
""")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, category, tags, expanded_keywords)
VALUES ('delete', old.id, old.content, old.category, old.tags, old.expanded_keywords);
END
""")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, category, tags, expanded_keywords)
VALUES ('delete', old.id, old.content, old.category, old.tags, old.expanded_keywords);
INSERT INTO memories_fts(rowid, content, category, tags, expanded_keywords)
VALUES (new.id, new.content, new.category, new.tags, new.expanded_keywords);
END
""")
conn.commit()
return conn
# ─── Tool definitions ────────────────────────────────────────────────────────
TOOLS = [
{
"name": "memory_store",
"description": "Store a fact or memory in persistent storage. Use this to remember important information about the user, their preferences, projects, decisions, or people they mention.",
"inputSchema": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The fact or memory to store"},
"category": {
"type": "string",
"enum": ["facts", "preferences", "projects", "people", "decisions"],
"description": "Category for organizing the memory",
"default": "facts",
},
"tags": {"type": "string", "description": "Comma-separated tags", "default": ""},
"importance": {
"type": "number",
"description": "Importance 0.0-1.0",
"default": 0.5,
"minimum": 0.0,
"maximum": 1.0,
},
"expanded_keywords": {
"type": "string",
"description": "REQUIRED. Space-separated semantically related search terms (MINIMUM 5 words). Generate keywords that someone might search for when this memory would be relevant. Include synonyms, related concepts, and adjacent topics.",
},
"force_sensitive": {
"type": "boolean",
"description": "If true, mark this memory as sensitive regardless of auto-detection. Sensitive memories have their content encrypted at rest.",
"default": False,
},
},
"required": ["content", "expanded_keywords"],
},
},
{
"name": "memory_recall",
"description": "Retrieve relevant memories based on context. Uses full-text search to find stored memories.",
"inputSchema": {
"type": "object",
"properties": {
"context": {"type": "string", "description": "The context or topic to recall memories about"},
"expanded_query": {
"type": "string",
"description": "REQUIRED. Space-separated semantically related search terms (MINIMUM 5 words).",
},
"category": {
"type": "string",
"enum": ["facts", "preferences", "projects", "people", "decisions"],
"description": "Optional: filter results to a specific category",
},
"sort_by": {
"type": "string",
"enum": ["importance", "relevance"],
"description": "Sort order",
"default": "importance",
},
"limit": {"type": "integer", "description": "Max results", "default": 10},
},
"required": ["context", "expanded_query"],
},
},
{
"name": "memory_list",
"description": "List recent memories, optionally filtered by category.",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["facts", "preferences", "projects", "people", "decisions"],
},
"limit": {"type": "integer", "default": 20},
},
},
},
{
"name": "memory_delete",
"description": "Delete a memory by ID.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "The ID of the memory to delete"},
},
"required": ["id"],
},
},
{
"name": "secret_get",
"description": "Retrieve the decrypted content of a sensitive memory. Only works for memories marked as sensitive.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "The ID of the sensitive memory to retrieve"},
},
"required": ["id"],
},
},
]
class MemoryServer:
"""MCP server for persistent memory management."""
def __init__(self, sqlite_db_path: str | None = None) -> None:
self.sqlite_conn = None
if SQLITE_FALLBACK:
self.sqlite_conn = _init_sqlite(sqlite_db_path)
# ── HTTP-backed methods ──────────────────────────────────────────
def memory_store(self, args: dict[str, Any]) -> str:
content = args.get("content")
if not content:
raise ValueError("content is required")
category = args.get("category", "facts")
tags = args.get("tags", "")
importance = max(0.0, min(1.0, float(args.get("importance", 0.5))))
expanded_keywords = args.get("expanded_keywords", "")
force_sensitive = bool(args.get("force_sensitive", False))
if SQLITE_FALLBACK:
return self._sqlite_store(content, category, tags, importance, expanded_keywords, force_sensitive)
result = _api_request("POST", "/api/memories", {
"content": content,
"category": category,
"tags": tags,
"expanded_keywords": expanded_keywords,
"importance": importance,
"force_sensitive": force_sensitive,
})
return f"Stored memory #{result['id']} in category '{result['category']}' with importance {result['importance']:.1f}"
def memory_recall(self, args: dict[str, Any]) -> str:
context = args.get("context")
if not context:
raise ValueError("context is required")
expanded_query = args.get("expanded_query", "")
category = args.get("category")
sort_by = args.get("sort_by", "importance")
limit = args.get("limit", 10)
if SQLITE_FALLBACK:
return self._sqlite_recall(context, expanded_query, category, sort_by, limit)
result = _api_request("POST", "/api/memories/recall", {
"context": context,
"expanded_query": expanded_query,
"category": category,
"sort_by": sort_by,
"limit": limit,
})
rows = result.get("memories", [])
if not rows:
filter_desc = f" in category '{category}'" if category else ""
return f"No memories found matching: {context}{filter_desc}"
sort_desc = "by relevance" if sort_by == "relevance" else "by importance"
filter_desc = f" in '{category}'" if category else ""
results = []
for row in rows:
results.append(
f"#{row['id']} [{row['category']}] (importance: {row['importance']:.1f}) {row['content']}"
f"\n Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
)
return f"Found {len(rows)} memories{filter_desc} ({sort_desc}):\n\n" + "\n\n".join(results)
def memory_list(self, args: dict[str, Any]) -> str:
category = args.get("category")
limit = args.get("limit", 20)
if SQLITE_FALLBACK:
return self._sqlite_list(category, limit)
params = f"?limit={limit}"
if category:
params += f"&category={category}"
result = _api_request("GET", f"/api/memories{params}")
rows = result.get("memories", [])
if not rows:
return f"No memories in category '{category}'" if category else "No memories stored yet"
results = []
for row in rows:
results.append(
f"#{row['id']} [{row['category']}] {row['content']}"
f"\n Importance: {row['importance']:.1f} | Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
)
header = "Recent memories"
if category:
header += f" in '{category}'"
return header + f" ({len(rows)} shown):\n\n" + "\n\n".join(results)
def memory_delete(self, args: dict[str, Any]) -> str:
memory_id = args.get("id")
if memory_id is None:
raise ValueError("id is required")
if SQLITE_FALLBACK:
return self._sqlite_delete(memory_id)
result = _api_request("DELETE", f"/api/memories/{memory_id}")
return f"Deleted memory #{result['deleted']}: {result['preview']}..."
def secret_get(self, args: dict[str, Any]) -> str:
memory_id = args.get("id")
if memory_id is None:
raise ValueError("id is required")
if SQLITE_FALLBACK:
return self._sqlite_secret_get(memory_id)
result = _api_request("POST", f"/api/memories/{memory_id}/secret")
return f"#{result['id']} [{result['category']}] {result['content']}"
# ── SQLite fallback methods ──────────────────────────────────────
def _sqlite_store(self, content, category, tags, importance, expanded_keywords, force_sensitive=False):
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
is_sensitive = 1 if force_sensitive else 0
cursor = self.sqlite_conn.cursor()
cursor.execute(
"INSERT INTO memories (content, category, tags, expanded_keywords, importance, is_sensitive, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(content, category, tags, expanded_keywords, importance, is_sensitive, now, now),
)
self.sqlite_conn.commit()
return f"Stored memory #{cursor.lastrowid} in category '{category}' with importance {importance:.1f}"
def _sqlite_recall(self, context, expanded_query, category, sort_by, limit):
import sqlite3
all_terms = f"{context} {expanded_query}".strip()
words = all_terms.split()
fts_query = " OR ".join(f'"{w.replace(chr(34), "")}"' for w in words if w)
order = (
"bm25(memories_fts), m.importance DESC"
if sort_by == "relevance"
else "m.importance DESC, m.created_at DESC"
)
cursor = self.sqlite_conn.cursor()
try:
if category:
cursor.execute(
f"SELECT m.id, m.content, m.category, m.tags, m.importance, m.created_at "
f"FROM memories m JOIN memories_fts fts ON m.id = fts.rowid "
f"WHERE memories_fts MATCH ? AND m.category = ? ORDER BY {order} LIMIT ?",
(fts_query, category, limit),
)
else:
cursor.execute(
f"SELECT m.id, m.content, m.category, m.tags, m.importance, m.created_at "
f"FROM memories m JOIN memories_fts fts ON m.id = fts.rowid "
f"WHERE memories_fts MATCH ? ORDER BY {order} LIMIT ?",
(fts_query, limit),
)
rows = cursor.fetchall()
except sqlite3.OperationalError:
like = f"%{context}%"
if category:
cursor.execute(
"SELECT id, content, category, tags, importance, created_at FROM memories "
"WHERE (content LIKE ? OR tags LIKE ?) AND category = ? ORDER BY importance DESC LIMIT ?",
(like, like, category, limit),
)
else:
cursor.execute(
"SELECT id, content, category, tags, importance, created_at FROM memories "
"WHERE content LIKE ? OR tags LIKE ? ORDER BY importance DESC LIMIT ?",
(like, like, limit),
)
rows = cursor.fetchall()
if not rows:
return f"No memories found matching: {context}"
results = []
for row in rows:
results.append(
f"#{row['id']} [{row['category']}] (importance: {row['importance']:.1f}) {row['content']}"
f"\n Tags: {row['tags'] or 'none'} | Stored: {row['created_at']}"
)
return (
f"Found {len(rows)} memories (by {'relevance' if sort_by == 'relevance' else 'importance'}):\n\n"
+ "\n\n".join(results)
)
def _sqlite_list(self, category, limit):
cursor = self.sqlite_conn.cursor()
if category:
cursor.execute(
"SELECT id, content, category, tags, importance, created_at FROM memories "
"WHERE category = ? ORDER BY created_at DESC LIMIT ?",
(category, limit),
)
else:
cursor.execute(
"SELECT id, content, category, tags, importance, created_at FROM memories "
"ORDER BY created_at DESC LIMIT ?",
(limit,),
)
rows = cursor.fetchall()
if not rows:
return f"No memories in category '{category}'" if category else "No memories stored yet"
results = []
for row in rows:
results.append(
f"#{row['id']} [{row['category']}] {row['content']}"
f"\n Importance: {row['importance']:.1f} | Tags: {row['tags'] or 'none'} | Stored: {row['created_at']}"
)
header = "Recent memories" + (f" in '{category}'" if category else "")
return header + f" ({len(rows)} shown):\n\n" + "\n\n".join(results)
def _sqlite_delete(self, memory_id):
cursor = self.sqlite_conn.cursor()
cursor.execute("SELECT id, content FROM memories WHERE id = ?", (memory_id,))
row = cursor.fetchone()
if not row:
return f"Memory #{memory_id} not found"
preview = row["content"][:50]
cursor.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
self.sqlite_conn.commit()
return f"Deleted memory #{memory_id}: {preview}..."
def _sqlite_secret_get(self, memory_id):
cursor = self.sqlite_conn.cursor()
cursor.execute(
"SELECT id, content, category, is_sensitive FROM memories WHERE id = ?",
(memory_id,),
)
row = cursor.fetchone()
if not row:
return f"Memory #{memory_id} not found"
if not row["is_sensitive"]:
return f"Memory #{memory_id} is not marked as sensitive"
return f"#{row['id']} [{row['category']}] {row['content']}"
# ── MCP protocol ─────────────────────────────────────────────────
def handle_initialize(self, params: dict[str, Any]) -> dict[str, Any]:
return {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
}
def handle_tools_list(self, params: dict[str, Any]) -> dict[str, Any]:
return {"tools": TOOLS}
def handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
tool_name = params.get("name")
arguments = params.get("arguments", {})
try:
handler = {
"memory_store": self.memory_store,
"memory_recall": self.memory_recall,
"memory_list": self.memory_list,
"memory_delete": self.memory_delete,
"secret_get": self.secret_get,
}.get(tool_name)
if handler is None:
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
result = handler(arguments)
return {"content": [{"type": "text", "text": result}]}
except Exception as e:
return {"content": [{"type": "text", "text": f"Error: {e!s}"}], "isError": True}
def process_message(self, message: dict[str, Any]) -> dict[str, Any] | None:
method = message.get("method")
params = message.get("params", {})
msg_id = message.get("id")
if msg_id is None:
return None
result = None
error = None
try:
if method == "initialize":
result = self.handle_initialize(params)
elif method == "tools/list":
result = self.handle_tools_list(params)
elif method == "tools/call":
result = self.handle_tools_call(params)
else:
error = {"code": -32601, "message": f"Method not found: {method}"}
except Exception as e:
error = {"code": -32603, "message": str(e)}
response: dict[str, Any] = {"jsonrpc": "2.0", "id": msg_id}
if error:
response["error"] = error
else:
response["result"] = result
return response
def run(self) -> None:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError as e:
print(
json.dumps({
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": f"Parse error: {e}"},
}),
flush=True,
)
continue
response = self.process_message(message)
if response is not None:
print(json.dumps(response), flush=True)
def main() -> None:
server = MemoryServer()
server.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,84 @@
"""HashiCorp Vault KV v2 client using stdlib urllib."""
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any
logger = logging.getLogger(__name__)
class VaultClient:
"""Simple Vault KV v2 client using stdlib."""
def __init__(
self,
addr: str | None = None,
token: str | None = None,
mount: str = "secret",
):
self.addr = (addr or os.environ.get("VAULT_ADDR", "")).rstrip("/")
self.token = token or os.environ.get("VAULT_TOKEN", "")
self.mount = mount
if not self.addr:
raise ValueError("Vault address not configured (set VAULT_ADDR)")
# Auto-detect Kubernetes SA token
if not self.token:
sa_token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
if os.path.exists(sa_token_path):
self._login_kubernetes(sa_token_path)
def _login_kubernetes(self, sa_token_path: str) -> None:
"""Authenticate with Vault using Kubernetes service account."""
with open(sa_token_path) as f:
jwt = f.read().strip()
role = os.environ.get("VAULT_ROLE", "claude-memory")
resp = self._request("POST", "/v1/auth/kubernetes/login", {"jwt": jwt, "role": role})
self.token = resp.get("auth", {}).get("client_token", "")
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
"""Make HTTP request to Vault."""
url = f"{self.addr}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
url, data=data, method=method,
headers={"X-Vault-Token": self.token, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
if e.code == 404:
return {}
error_body = e.read().decode() if e.fp else str(e)
raise RuntimeError(f"Vault error {e.code}: {error_body}") from e
def read(self, path: str) -> dict[str, Any] | None:
"""Read a secret from KV v2."""
resp = self._request("GET", f"/v1/{self.mount}/data/{path}")
data = resp.get("data", {})
return data.get("data") if data else None
def write(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
"""Write a secret to KV v2."""
return self._request("POST", f"/v1/{self.mount}/data/{path}", {"data": data})
def delete(self, path: str) -> bool:
"""Delete a secret from KV v2."""
try:
self._request("DELETE", f"/v1/{self.mount}/data/{path}")
return True
except RuntimeError:
return False
def list_secrets(self, path: str) -> list[str]:
"""List secrets at a path."""
try:
resp = self._request("LIST", f"/v1/{self.mount}/metadata/{path}")
return resp.get("data", {}).get("keys", [])
except RuntimeError:
return []