claude-memory-mcp/src/claude_memory/api/app.py
Viktor Barzin 0ed5e1e016
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.
2026-03-14 09:42:05 +00:00

337 lines
11 KiB
Python

"""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