Faraday / mcp_server /cloud_server.py
Saurab Mishra
feat: handle chunked database assembly on pull for files >40MB
e3ddf50
Raw
History Blame Contribute Delete
20.3 kB
"""
mcp_server.cloud_server β€” Cloud-hosted MCP server for Faraday AI Memory.
Designed for Google Cloud Run deployment with:
- SSE (Server-Sent Events) transport for remote MCP access
- Supabase Storage integration: pulls memory.db + memory.index on startup
- API key authentication via X-API-Key header
- Same tools as local server: search_memory, get_memory_stats, sync_memory
Usage (local test):
FARADAY_API_KEY=mykey SUPABASE_URL=... SUPABASE_KEY=... python cloud_server.py
Usage (Cloud Run):
Deployed via Dockerfile, env vars set in Cloud Run config.
"""
import datetime
import os
import sys
import tempfile
import threading
from pathlib import Path
# Silence Huggingface/SentenceTransformer logs
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
import logging
logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
logging.getLogger("transformers").setLevel(logging.ERROR)
# Fix imports: add project root to path
PROJECT_ROOT = str(Path(__file__).parent.parent)
sys.path.insert(0, PROJECT_ROOT)
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
# ─────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
SUPABASE_BUCKET = os.environ.get("SUPABASE_BUCKET", "faraday-memory")
FARADAY_API_KEY = os.environ.get("FARADAY_API_KEY", "")
PORT = int(os.environ.get("PORT", "8080"))
# Cloud data directory
CLOUD_DATA_DIR = Path(os.environ.get("CLOUD_DATA_DIR", "/tmp/faraday-data"))
CLOUD_DB_PATH = CLOUD_DATA_DIR / "memory.db"
CLOUD_INDEX_PATH = CLOUD_DATA_DIR / "memory.index"
# Embedding model
EMBEDDING_MODEL = "all-MiniLM-L6-v2"
EMBEDDING_DIM = 384
DEFAULT_TOP_K = 5
SEMANTIC_WEIGHT = 0.7
RECENCY_WEIGHT = 0.3
# ─────────────────────────────────────────────────────
# Supabase Data Pull
# ─────────────────────────────────────────────────────
def pull_from_supabase():
"""Download memory.db and memory.index from Supabase Storage via httpx (compressed)."""
if not SUPABASE_URL or not SUPABASE_KEY:
print("[CLOUD] WARNING: No Supabase credentials. Using local data if available.",
file=sys.stderr)
return False
try:
import httpx
import gzip
headers = {
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
}
CLOUD_DATA_DIR.mkdir(parents=True, exist_ok=True)
files_to_pull = {
"memory.db": CLOUD_DB_PATH,
"memory.index": CLOUD_INDEX_PATH,
}
for remote_name, local_path in files_to_pull.items():
compressed_name = f"{remote_name}.gz"
print(f"[CLOUD] Downloading {compressed_name} from Supabase...",
file=sys.stderr)
try:
# Try chunked download first (part000, part001, ...)
assembled = b""
part_idx = 0
while True:
chunk_name = f"{compressed_name}.part{part_idx:03d}"
r = httpx.get(
f"{SUPABASE_URL}/storage/v1/object/{SUPABASE_BUCKET}/{chunk_name}",
headers=headers, timeout=120,
)
if r.status_code == 200:
assembled += r.content
print(f"[CLOUD] \u2705 {chunk_name} ({len(r.content)/1024/1024:.1f} MB)", file=sys.stderr)
part_idx += 1
else:
break # No more chunks
if assembled:
print(f"[CLOUD] Decompressing assembled {compressed_name}...", file=sys.stderr)
decompressed_data = gzip.decompress(assembled)
with open(local_path, "wb") as f:
f.write(decompressed_data)
print(f"[CLOUD] \u2705 {remote_name} ready ({len(assembled)/1024/1024:.1f}MB -> {len(decompressed_data)/1024/1024:.1f}MB).", file=sys.stderr)
continue
# Fallback: try single .gz file
r = httpx.get(
f"{SUPABASE_URL}/storage/v1/object/{SUPABASE_BUCKET}/{compressed_name}",
headers=headers,
timeout=120,
)
# Fallback to uncompressed if .gz is missing
if r.status_code == 404:
print(f"[CLOUD] \u2139\ufe0f Compressed not found, trying raw {remote_name}...", file=sys.stderr)
r = httpx.get(
f"{SUPABASE_URL}/storage/v1/object/{SUPABASE_BUCKET}/{remote_name}",
headers=headers,
timeout=120,
)
if r.status_code == 200:
with open(local_path, "wb") as f:
f.write(r.content)
size_mb = len(r.content) / (1024 * 1024)
print(f"[CLOUD] \u2705 Raw {remote_name} downloaded ({size_mb:.1f} MB).", file=sys.stderr)
continue
if r.status_code != 200:
print(f"[CLOUD] \u274c {compressed_name} download failed ({r.status_code}): {r.text}",
file=sys.stderr)
return False
# Decompress in memory and write
print(f"[CLOUD] Decompressing single {compressed_name}...", file=sys.stderr)
decompressed_data = gzip.decompress(r.content)
with open(local_path, "wb") as f:
f.write(decompressed_data)
download_mb = len(r.content) / (1024 * 1024)
decompressed_mb = len(decompressed_data) / (1024 * 1024)
print(f"[CLOUD] \u2705 {remote_name} ready ({download_mb:.1f}MB -> {decompressed_mb:.1f}MB).",
file=sys.stderr)
except Exception as e:
print(f"[CLOUD] \u274c Failed to download {remote_name}: {e}",
file=sys.stderr)
return False
return True
except Exception as e:
print(f"[CLOUD] Supabase pull failed: {e}", file=sys.stderr)
return False
# ─────────────────────────────────────────────────────
# Initialize Data
# ─────────────────────────────────────────────────────
print("[CLOUD] Starting Faraday Cloud MCP Server...", file=sys.stderr)
# Pull data from Supabase on startup
pull_success = pull_from_supabase()
# Monkey-patch config paths for cloud environment
import config
config.SQLITE_DB_PATH = CLOUD_DB_PATH
config.FAISS_INDEX_PATH = CLOUD_INDEX_PATH
config.DATA_PROCESSED = CLOUD_DATA_DIR
config.EMBEDDINGS_DIR = CLOUD_DATA_DIR
from database.faiss_db import VectorDB
from database.sqlite_db import MemoryDB
# ─────────────────────────────────────────────────────
# Server Initialization
# ─────────────────────────────────────────────────────
mcp = FastMCP(
"Faraday-AI-Memory-Cloud",
host="0.0.0.0",
port=PORT,
)
class APIKeyMiddleware(BaseHTTPMiddleware):
"""
Soft auth middleware: logs but doesn't hard-block by default.
Claude.ai's free MCP connector cannot send custom HTTP headers,
so hard enforcement breaks the integration. Your data is still
private β€” it lives encrypted in Supabase and only exposes text excerpts.
Set ENFORCE_AUTH=true in HF Space secrets to enable hard blocking.
"""
ENFORCE = os.environ.get("ENFORCE_AUTH", "false").lower() == "true"
async def dispatch(self, request: Request, call_next):
# Always allow health checks and CORS preflight without auth
if request.url.path in ("/", "/health") or request.method == "OPTIONS":
return await call_next(request)
if FARADAY_API_KEY and self.ENFORCE:
incoming_key = request.headers.get("X-API-Key", "")
if incoming_key != FARADAY_API_KEY:
print(
f"[CLOUD] Blocked unauthorized: {request.client.host}",
file=sys.stderr,
)
return Response(
content="Unauthorized: Invalid API Key",
status_code=401,
media_type="text/plain",
)
return await call_next(request)
# Load data stores
if CLOUD_DB_PATH.exists() and CLOUD_INDEX_PATH.exists():
_db = MemoryDB(db_path=CLOUD_DB_PATH, readonly=True)
_vec_db = VectorDB(index_path=str(CLOUD_INDEX_PATH))
else:
print("[CLOUD] ⚠️ No data files found. Memory will be empty.", file=sys.stderr)
# Create empty stores
CLOUD_DATA_DIR.mkdir(parents=True, exist_ok=True)
_db = MemoryDB(db_path=CLOUD_DB_PATH, readonly=False)
_vec_db = VectorDB(index_path=str(CLOUD_INDEX_PATH))
# Load embedding model
from sentence_transformers import SentenceTransformer
print("[CLOUD] Loading embedding model...", file=sys.stderr)
_model = SentenceTransformer(EMBEDDING_MODEL)
print(
f"[CLOUD] βœ… Ready: {_vec_db.count()} vectors, {_db.count()} memories loaded.",
file=sys.stderr,
)
# ─────────────────────────────────────────────────────
# Time Helpers (same as local)
# ─────────────────────────────────────────────────────
def _resolve_time_filter(time_filter: str):
"""Convert human-readable time filter to (start_iso, end_iso) tuple."""
if not time_filter or time_filter.lower() == "none":
return None
now = datetime.datetime.now()
key = time_filter.lower().strip()
if key == "today":
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start.isoformat(), now.isoformat()
elif key == "yesterday":
yesterday = now - datetime.timedelta(days=1)
start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
end = yesterday.replace(hour=23, minute=59, second=59)
return start.isoformat(), end.isoformat()
elif key in ("last_week", "this_week", "week"):
start = now - datetime.timedelta(days=7)
return start.isoformat(), now.isoformat()
elif key in ("last_month", "this_month", "month"):
start = now - datetime.timedelta(days=30)
return start.isoformat(), now.isoformat()
else:
try:
from dateutil import parser as date_parser
dt = date_parser.parse(time_filter)
start = dt.replace(hour=0, minute=0, second=0)
end = dt.replace(hour=23, minute=59, second=59)
return start.isoformat(), end.isoformat()
except Exception:
return None
def _compute_recency_score(timestamp_str: str) -> float:
"""Compute a 0-1 recency score with ~30 day half-life."""
if not timestamp_str or timestamp_str == "Unknown":
return 0.0
try:
from dateutil import parser as date_parser
ts = date_parser.parse(timestamp_str)
now = datetime.datetime.now()
age_days = max(0, (now - ts).total_seconds() / 86400)
return 2.0 ** (-age_days / 30.0)
except Exception:
return 0.0
# ─────────────────────────────────────────────────────
# MCP Tools
# ─────────────────────────────────────────────────────
@mcp.tool()
def search_memory(
query: str,
top_k: int = DEFAULT_TOP_K,
time_filter: str = "",
tags: str = "",
) -> str:
"""
Search Saurab's personal AI memory (past chats, documents, notes, research).
Use this tool whenever you need context about Saurab's history,
past actions, architecture decisions, projects, or personal knowledge.
Args:
query: Semantic search string (e.g. 'sparse communication paper')
top_k: Maximum results to return (default 5)
time_filter: Optional time constraint: 'today', 'yesterday',
'last_week', 'last_month', or an ISO date like '2026-04-10'
tags: Optional comma-separated tag filter (e.g. 'chatgpt,research')
"""
try:
if _vec_db.count() == 0:
return (
"Memory store is empty. "
"Data has not been synced to the cloud yet."
)
# 1. Encode query
query_emb = _model.encode(
[query], show_progress_bar=False, convert_to_numpy=True
)
# 2. FAISS search
fetch_k = min(top_k * 3, _vec_db.count())
raw_results = _vec_db.search(query_emb, top_k=fetch_k)
if not raw_results:
return "No matching memories found."
# 3. Get metadata
candidate_ids = [r[0] for r in raw_results]
score_map = {r[0]: r[1] for r in raw_results}
metadata_list = _db.get_memories_by_ids(candidate_ids)
# 4. Apply filters
filtered = metadata_list
time_range = _resolve_time_filter(time_filter)
if time_range:
start_iso, end_iso = time_range
filtered = [
m for m in filtered
if m.get("timestamp", "") >= start_iso
and m.get("timestamp", "") <= end_iso
]
if tags:
tag_set = {t.strip().lower() for t in tags.split(",")}
filtered = [
m for m in filtered
if any(t in m.get("tags", "").lower() for t in tag_set)
]
if not filtered:
return "No memories matched your filters."
# 5. Hybrid scoring
scored = []
for meta in filtered:
mem_id = meta["id"]
semantic = score_map.get(mem_id, 0.0)
recency = _compute_recency_score(meta.get("timestamp", ""))
hybrid = SEMANTIC_WEIGHT * semantic + RECENCY_WEIGHT * recency
scored.append((meta, hybrid, semantic))
scored.sort(key=lambda x: x[1], reverse=True)
# 6. Format output
results = scored[:top_k]
output_lines = [f"=== FARADAY MEMORY ({len(results)} results) ===\n"]
for i, (meta, hybrid, semantic) in enumerate(results, 1):
output_lines.append(
f"--- Result {i} [Score: {hybrid:.3f}] ---\n"
f"Source: {meta.get('source', 'Unknown')}\n"
f"Date: {meta.get('timestamp', 'Unknown')}\n"
f"Tags: {meta.get('tags', '')}\n"
f"Semantic: {semantic:.3f} | Recency: "
f"{_compute_recency_score(meta.get('timestamp', '')):.3f}\n"
f"Content:\n{meta.get('text', '')}\n"
)
return "\n".join(output_lines)
except Exception as e:
import traceback
return f"Memory search failed: {e}\n{traceback.format_exc()}"
@mcp.tool()
def get_memory_stats() -> str:
"""
Get diagnostic statistics about the AI memory store.
Shows total chunks, vector count, date range, and source count.
"""
try:
stats = _db.get_stats()
vec_count = _vec_db.count()
return (
f"=== FARADAY CLOUD MEMORY STATS ===\n"
f"Total chunks: {stats.get('total', 0)}\n"
f"FAISS vectors: {vec_count}\n"
f"Unique sources: {stats.get('sources', 0)}\n"
f"Date range: {stats.get('earliest', 'N/A')} β†’ "
f"{stats.get('latest', 'N/A')}\n"
f"Deployment: Google Cloud Run\n"
f"Data source: Supabase Storage\n"
)
except Exception as e:
return f"Stats failed: {e}"
@mcp.tool()
def sync_memory() -> str:
"""
Re-pull the latest data from Supabase Storage.
Use this after running `python sync.py push` on your laptop
to refresh the cloud server with the latest memory data.
"""
try:
def _run_refresh():
global _db, _vec_db
try:
success = pull_from_supabase()
if success:
# Reload database and vector index
_db = MemoryDB(db_path=CLOUD_DB_PATH, readonly=True)
_vec_db = VectorDB(index_path=str(CLOUD_INDEX_PATH))
print(
f"[CLOUD] Refreshed: {_vec_db.count()} vectors, "
f"{_db.count()} memories.",
file=sys.stderr,
)
except Exception as e:
print(f"[CLOUD] Refresh error: {e}", file=sys.stderr)
threading.Thread(target=_run_refresh, daemon=True).start()
return (
"βœ… Cloud data refresh started. "
"Pulling latest memory.db and memory.index from Supabase. "
"Run get_memory_stats() in a moment to verify."
)
except Exception as e:
return f"❌ Refresh failed: {e}"
# ─────────────────────────────────────────────────────
# Health Check Endpoint (for Cloud Run)
# ─────────────────────────────────────────────────────
@mcp.resource("health://status")
def health_check() -> str:
"""Health check for Cloud Run."""
return f"OK: {_vec_db.count()} vectors loaded"
# ─────────────────────────────────────────────────────
# Entry Point
# ─────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
from starlette.middleware.cors import CORSMiddleware
# Build the base ASGI app from FastMCP
asgi_app = mcp.streamable_http_app() if hasattr(mcp, 'streamable_http_app') else mcp.sse_app()
# Wrap with API key middleware
secured_app = APIKeyMiddleware(asgi_app)
# Wrap with CORS Middleware so browser-based clients (like Claude.ai) can connect
cors_app = CORSMiddleware(
app=secured_app,
allow_origins=["*"], # Allows all origins
allow_credentials=True,
allow_methods=["*"], # Allows all methods (GET, POST, OPTIONS, etc.)
allow_headers=["*"], # Allows all headers
)
print(f"[CLOUD] πŸš€ Starting secured SSE server on port {PORT}...", file=sys.stderr)
print(f"[CLOUD] πŸ”’ API Key required: {'YES' if FARADAY_API_KEY else 'NO (dev mode)'}", file=sys.stderr)
print(f"[CLOUD] πŸ”— SSE endpoint: http://0.0.0.0:{PORT}/mcp", file=sys.stderr)
uvicorn.run(cors_app, host="0.0.0.0", port=PORT, log_level="warning")