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

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)