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:
commit
0ed5e1e016
40 changed files with 3381 additions and 0 deletions
3
src/claude_memory/__init__.py
Normal file
3
src/claude_memory/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Claude Memory MCP — standalone memory server with multi-user support."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
4
src/claude_memory/__main__.py
Normal file
4
src/claude_memory/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Allow running as `python -m claude_memory`."""
|
||||
from claude_memory.mcp_server import main
|
||||
|
||||
main()
|
||||
0
src/claude_memory/api/__init__.py
Normal file
0
src/claude_memory/api/__init__.py
Normal file
337
src/claude_memory/api/app.py
Normal file
337
src/claude_memory/api/app.py
Normal 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
|
||||
32
src/claude_memory/api/auth.py
Normal file
32
src/claude_memory/api/auth.py
Normal 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)
|
||||
55
src/claude_memory/api/database.py
Normal file
55
src/claude_memory/api/database.py
Normal 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
|
||||
32
src/claude_memory/api/models.py
Normal file
32
src/claude_memory/api/models.py
Normal 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"
|
||||
51
src/claude_memory/api/vault_service.py
Normal file
51
src/claude_memory/api/vault_service.py
Normal 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)
|
||||
76
src/claude_memory/credential_detector.py
Normal file
76
src/claude_memory/credential_detector.py
Normal 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
|
||||
71
src/claude_memory/crypto.py
Normal file
71
src/claude_memory/crypto.py
Normal 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))
|
||||
550
src/claude_memory/mcp_server.py
Normal file
550
src/claude_memory/mcp_server.py
Normal 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()
|
||||
84
src/claude_memory/vault_client.py
Normal file
84
src/claude_memory/vault_client.py
Normal 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 []
|
||||
Loading…
Add table
Add a link
Reference in a new issue