claude-os/mcp_server/server.py

1663 lines
57 KiB
Python
Raw Normal View History

2025-10-22 13:29:28 -05:00
"""
2025-10-27 17:41:44 -05:00
MCP Server for Claude OS.
2025-10-22 13:29:28 -05:00
Exposes RAG functionality via Model Context Protocol with HTTP transport.
"""
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List
2025-10-22 13:29:28 -05:00
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, Request, UploadFile, File, HTTPException
2025-10-22 13:29:28 -05:00
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
2025-10-22 13:29:28 -05:00
import uvicorn
import requests
from typing import Optional
2025-10-22 13:29:28 -05:00
2025-10-27 17:41:44 -05:00
from app.core.sqlite_manager import get_sqlite_manager
2025-10-22 13:29:28 -05:00
from app.core.config import Config
from app.core.kb_metadata import get_collection_stats, get_documents_metadata
from app.core.kb_types import KBType
2025-10-22 13:29:28 -05:00
from app.core.rag_engine import RAGEngine
from app.core.agent_os_ingestion import AgentOSIngestion
from app.core.agent_os_parser import AgentOSContentType
2025-10-27 17:41:44 -05:00
from app.core.hooks import get_project_hook
from app.core.file_watcher import get_global_watcher
2025-10-24 16:17:47 -05:00
from functools import lru_cache
import time
from threading import Lock
2025-10-22 13:29:28 -05:00
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# RAGEngine cache with TTL - Optimized for M4 Pro with 48GB RAM
2025-10-24 16:17:47 -05:00
RAG_ENGINE_CACHE = {}
RAG_ENGINE_CACHE_LOCK = Lock()
RAG_ENGINE_CACHE_TTL = 3600 # 1 hour TTL with plenty of RAM
2025-10-24 16:17:47 -05:00
# Initialize FastAPI
2025-10-27 17:41:44 -05:00
app = FastAPI(title="Claude OS MCP Server")
2025-10-22 13:29:28 -05:00
# Add CORS middleware
2025-10-22 13:29:28 -05:00
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
2025-10-24 16:17:47 -05:00
def get_cached_rag_engine(kb_name: str) -> RAGEngine:
"""
Get or create a cached RAGEngine instance.
Implements singleton pattern with TTL to avoid recreating engines for every query.
Args:
kb_name: Name of the knowledge base
Returns:
Cached or new RAGEngine instance
"""
global RAG_ENGINE_CACHE
with RAG_ENGINE_CACHE_LOCK:
current_time = time.time()
# Check if engine exists and is not expired
if kb_name in RAG_ENGINE_CACHE:
engine, timestamp = RAG_ENGINE_CACHE[kb_name]
if current_time - timestamp < RAG_ENGINE_CACHE_TTL:
logger.info(f"Using cached RAGEngine for {kb_name} (age: {current_time - timestamp:.1f}s)")
return engine
else:
logger.info(f"RAGEngine cache expired for {kb_name}, creating new instance")
del RAG_ENGINE_CACHE[kb_name]
# Create new engine
logger.info(f"Creating new RAGEngine for {kb_name}")
start_time = time.time()
engine = RAGEngine(kb_name)
creation_time = time.time() - start_time
logger.info(f"RAGEngine created for {kb_name} in {creation_time:.2f}s")
# Cache the engine
RAG_ENGINE_CACHE[kb_name] = (engine, current_time)
# Clean up old entries (keep max 50 engines in cache with 48GB RAM)
if len(RAG_ENGINE_CACHE) > 50:
2025-10-24 16:17:47 -05:00
oldest_kb = min(RAG_ENGINE_CACHE.items(), key=lambda x: x[1][1])[0]
del RAG_ENGINE_CACHE[oldest_kb]
logger.info(f"Evicted oldest RAGEngine from cache: {oldest_kb}")
return engine
# MCP Tools Registry
TOOLS = {
"search_knowledge_base": {
"description": "Search a knowledge base using RAG with optional advanced features",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the knowledge base to search"},
"query": {"type": "string", "description": "The search query"},
"use_hybrid": {"type": "boolean", "description": "Enable hybrid search (vector + BM25)", "default": False},
"use_rerank": {"type": "boolean", "description": "Enable reranking for better relevance", "default": False},
"use_agentic": {"type": "boolean", "description": "Enable agentic RAG for complex queries", "default": False}
},
"required": ["kb_name", "query"]
}
},
"list_knowledge_bases": {
"description": "List all available knowledge bases with type metadata",
"inputSchema": {
"type": "object",
"properties": {}
}
},
"list_knowledge_bases_by_type": {
"description": "List knowledge bases filtered by type (e.g., agent-os, code, documentation)",
"inputSchema": {
"type": "object",
"properties": {
"kb_type": {
"type": "string",
"description": "KB type to filter by",
"enum": ["generic", "code", "documentation", "agent-os"]
}
},
"required": ["kb_type"]
}
},
"create_knowledge_base": {
"description": "Create a new knowledge base with specified type",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name for the new knowledge base"},
"kb_type": {
"type": "string",
"description": "Type of knowledge base",
"enum": ["generic", "code", "documentation", "agent-os"],
"default": "generic"
},
"description": {"type": "string", "description": "Optional description", "default": ""}
},
"required": ["name"]
}
},
"get_kb_stats": {
"description": "Get statistics for a knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the knowledge base"}
},
"required": ["kb_name"]
}
},
"list_documents": {
"description": "List all documents in a knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the knowledge base"}
},
"required": ["kb_name"]
}
},
"ingest_agent_os_profile": {
"description": "Ingest an Agent OS profile directory into a knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"},
"profile_path": {"type": "string", "description": "Path to the Agent OS profile directory"}
},
"required": ["kb_name", "profile_path"]
}
},
"get_agent_os_stats": {
"description": "Get statistics about Agent OS content in a knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"}
},
"required": ["kb_name"]
}
},
"get_standards": {
"description": "Get coding standards from an Agent OS knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"},
"query": {"type": "string", "description": "Optional search query to filter standards", "default": ""}
},
"required": ["kb_name"]
}
},
"get_workflows": {
"description": "Get development workflows from an Agent OS knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"},
"query": {"type": "string", "description": "Optional search query to filter workflows", "default": ""}
},
"required": ["kb_name"]
}
},
"get_specs": {
"description": "Get feature specifications from an Agent OS knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"},
"query": {"type": "string", "description": "Optional search query to filter specs", "default": ""}
},
"required": ["kb_name"]
}
},
"get_product_context": {
"description": "Get product vision and context from an Agent OS knowledge base",
"inputSchema": {
"type": "object",
"properties": {
"kb_name": {"type": "string", "description": "Name of the Agent OS knowledge base"}
},
"required": ["kb_name"]
}
}
}
2025-10-22 13:29:28 -05:00
# Tool implementation functions
async def search_knowledge_base(kb_name: str, query: str, use_hybrid: bool = False,
use_rerank: bool = False, use_agentic: bool = False) -> dict:
2025-10-24 16:17:47 -05:00
"""Search a knowledge base using RAG with cached engine for performance."""
2025-10-22 13:29:28 -05:00
try:
2025-10-24 16:17:47 -05:00
start_time = time.time()
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
return {"error": f"Knowledge base '{kb_name}' not found", "answer": "", "sources": []}
2025-10-27 17:41:44 -05:00
count = db_manager.get_collection_count(kb_name)
2025-10-22 13:29:28 -05:00
if count == 0:
return {"error": f"Knowledge base '{kb_name}' is empty", "answer": "", "sources": []}
2025-10-24 16:17:47 -05:00
# Use cached RAGEngine instance instead of creating new one
rag_engine = get_cached_rag_engine(kb_name)
query_start = time.time()
result = rag_engine.query(question=query, use_hybrid=use_hybrid,
use_rerank=use_rerank, use_agentic=use_agentic)
2025-10-24 16:17:47 -05:00
query_time = time.time() - query_start
total_time = time.time() - start_time
logger.info(f"Query executed on {kb_name}: {query[:50]}... (total: {total_time:.2f}s, query: {query_time:.2f}s)")
# Add timing info to result for debugging
result['_timing'] = {
'total_time': total_time,
'query_time': query_time
}
2025-10-22 13:29:28 -05:00
return result
except Exception as e:
logger.error(f"Search failed: {e}")
return {"error": str(e), "answer": "", "sources": []}
2025-10-22 13:29:28 -05:00
async def list_knowledge_bases() -> List[Dict[str, any]]:
"""List all available knowledge bases with metadata."""
2025-10-22 13:29:28 -05:00
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
kbs = db_manager.list_collections()
2025-10-22 13:29:28 -05:00
logger.info(f"Listed {len(kbs)} knowledge bases")
return kbs
except Exception as e:
logger.error(f"Failed to list knowledge bases: {e}")
return []
async def get_kb_stats(kb_name: str) -> dict:
"""Get statistics for a knowledge base."""
2025-10-22 13:29:28 -05:00
try:
stats = get_collection_stats(kb_name)
logger.info(f"Retrieved stats for {kb_name}")
return stats
except Exception as e:
logger.error(f"Failed to get stats for {kb_name}: {e}")
return {"error": str(e), "total_documents": 0, "total_chunks": 0, "last_updated": None}
2025-10-22 13:29:28 -05:00
async def list_documents(kb_name: str) -> List[dict]:
"""List all documents in a knowledge base."""
2025-10-22 13:29:28 -05:00
try:
docs = get_documents_metadata(kb_name)
logger.info(f"Listed {len(docs)} documents in {kb_name}")
return docs
except Exception as e:
logger.error(f"Failed to list documents in {kb_name}: {e}")
return []
async def list_knowledge_bases_by_type(kb_type: str) -> List[Dict[str, any]]:
"""List knowledge bases filtered by type."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
kb_type_enum = KBType(kb_type)
2025-10-27 17:41:44 -05:00
kbs = db_manager.list_collections_by_type(kb_type_enum)
logger.info(f"Listed {len(kbs)} knowledge bases of type {kb_type}")
return kbs
except Exception as e:
logger.error(f"Failed to list knowledge bases by type {kb_type}: {e}")
return []
2025-10-22 13:29:28 -05:00
async def create_knowledge_base(name: str, kb_type: str = "generic", description: str = "") -> dict:
"""Create a new knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
# Check if already exists
2025-10-27 17:41:44 -05:00
if db_manager.collection_exists(name):
return {
"success": False,
"error": f"Knowledge base '{name}' already exists"
}
# Create collection
kb_type_enum = KBType(kb_type)
2025-10-27 17:41:44 -05:00
success = db_manager.create_collection(
name=name,
kb_type=kb_type_enum,
description=description
)
if success:
logger.info(f"Created knowledge base: {name} (type: {kb_type})")
return {
"success": True,
"name": name,
"kb_type": kb_type,
"description": description
}
else:
return {
"success": False,
"error": "Failed to create knowledge base"
}
except Exception as e:
logger.error(f"Failed to create knowledge base {name}: {e}")
return {
"success": False,
"error": str(e)
}
async def ingest_agent_os_profile(kb_name: str, profile_path: str) -> dict:
"""Ingest an Agent OS profile into a knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
stats = agent_os_ingestion.ingest_profile(
kb_name=kb_name,
profile_path=profile_path
)
logger.info(f"Ingested Agent OS profile from {profile_path} into {kb_name}")
return stats
except Exception as e:
logger.error(f"Failed to ingest Agent OS profile: {e}")
return {
"success": False,
"error": str(e),
"documents_processed": 0
}
async def get_agent_os_stats(kb_name: str) -> dict:
"""Get statistics about Agent OS content in a knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
stats = agent_os_ingestion.get_profile_stats(kb_name)
logger.info(f"Retrieved Agent OS stats for {kb_name}")
return stats
except Exception as e:
logger.error(f"Failed to get Agent OS stats for {kb_name}: {e}")
return {
"total_documents": 0,
"documents_by_type": {},
"error": str(e)
}
async def get_standards(kb_name: str, query: str = "") -> List[dict]:
"""Get coding standards from an Agent OS knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
results = agent_os_ingestion.search_by_type(
kb_name=kb_name,
content_type=AgentOSContentType.STANDARD,
query=query if query else None,
limit=20
)
logger.info(f"Retrieved {len(results)} standards from {kb_name}")
return results
except Exception as e:
logger.error(f"Failed to get standards from {kb_name}: {e}")
return []
async def get_workflows(kb_name: str, query: str = "") -> List[dict]:
"""Get development workflows from an Agent OS knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
results = agent_os_ingestion.search_by_type(
kb_name=kb_name,
content_type=AgentOSContentType.WORKFLOW,
query=query if query else None,
limit=20
)
logger.info(f"Retrieved {len(results)} workflows from {kb_name}")
return results
except Exception as e:
logger.error(f"Failed to get workflows from {kb_name}: {e}")
return []
async def get_specs(kb_name: str, query: str = "") -> List[dict]:
"""Get feature specifications from an Agent OS knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
results = agent_os_ingestion.search_by_type(
kb_name=kb_name,
content_type=AgentOSContentType.SPEC,
query=query if query else None,
limit=20
)
logger.info(f"Retrieved {len(results)} specs from {kb_name}")
return results
except Exception as e:
logger.error(f"Failed to get specs from {kb_name}: {e}")
return []
async def get_product_context(kb_name: str) -> List[dict]:
"""Get product vision and context from an Agent OS knowledge base."""
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
agent_os_ingestion = AgentOSIngestion(db_manager)
results = agent_os_ingestion.search_by_type(
kb_name=kb_name,
content_type=AgentOSContentType.PRODUCT,
query=None,
limit=20
)
logger.info(f"Retrieved {len(results)} product context documents from {kb_name}")
return results
except Exception as e:
logger.error(f"Failed to get product context from {kb_name}: {e}")
return []
# Pydantic models for REST API
class CreateKBRequest(BaseModel):
name: str
kb_type: str = "generic"
description: str = ""
class ChatRequest(BaseModel):
query: str
use_hybrid: bool = False
use_rerank: bool = False
use_agentic: bool = False
# REST API Endpoints for UI
@app.get("/api/kb")
async def api_list_knowledge_bases():
"""REST API: List all knowledge bases."""
kbs = await list_knowledge_bases()
return {"knowledge_bases": kbs}
@app.post("/api/kb")
async def api_create_knowledge_base(request: CreateKBRequest):
"""REST API: Create a new knowledge base."""
result = await create_knowledge_base(
name=request.name,
kb_type=request.kb_type,
description=request.description
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error"))
return result
@app.delete("/api/kb/{kb_name}")
async def api_delete_knowledge_base(kb_name: str):
"""REST API: Delete a knowledge base."""
2025-10-22 13:29:28 -05:00
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
2025-10-27 17:41:44 -05:00
success = db_manager.delete_collection(kb_name)
if success:
return {"success": True, "message": f"Knowledge base '{kb_name}' deleted"}
else:
raise HTTPException(status_code=500, detail="Failed to delete knowledge base")
except HTTPException:
raise
2025-10-22 13:29:28 -05:00
except Exception as e:
logger.error(f"Failed to delete KB {kb_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/kb/{kb_name}/stats")
async def api_get_kb_stats(kb_name: str):
"""REST API: Get knowledge base statistics."""
stats = await get_kb_stats(kb_name)
if "error" in stats:
raise HTTPException(status_code=404, detail=stats["error"])
return stats
@app.get("/api/kb/{kb_name}/documents")
async def api_list_documents(kb_name: str):
"""REST API: List documents in a knowledge base."""
docs = await list_documents(kb_name)
return {"documents": docs}
@app.post("/api/kb/{kb_name}/chat")
async def api_chat(kb_name: str, request: ChatRequest):
"""REST API: Chat with a knowledge base."""
result = await search_knowledge_base(
kb_name=kb_name,
query=request.query,
use_hybrid=request.use_hybrid,
use_rerank=request.use_rerank,
use_agentic=request.use_agentic
)
if "error" in result and result["error"]:
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/kb/{kb_name}/upload")
async def api_upload_document(kb_name: str, file: UploadFile = File(...)):
"""REST API: Upload a single document to a knowledge base."""
import tempfile
import os
from app.core.ingestion import ingest_file
# Validate KB exists
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
# Save uploaded file to temp location
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = tmp_file.name
# Ingest the file
result = ingest_file(tmp_path, kb_name, file.filename)
# Clean up temp file
os.unlink(tmp_path)
if result["status"] == "error":
raise HTTPException(status_code=400, detail=result.get("error", "Upload failed"))
2025-10-22 13:29:28 -05:00
return {
"success": True,
"filename": result["filename"],
"chunks": result["chunks"],
"file_type": result["file_type"]
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Upload failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/kb/{kb_name}/import")
async def api_import_directory(kb_name: str, directory_path: str):
"""REST API: Import all files from a directory into a knowledge base."""
from app.core.ingestion import ingest_directory
# Validate KB exists
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
# Validate directory exists
if not Path(directory_path).exists():
raise HTTPException(status_code=404, detail=f"Directory not found: {directory_path}")
try:
results = ingest_directory(directory_path, kb_name)
# Count successes and failures
successes = [r for r in results if r.get("status") == "success"]
failures = [r for r in results if r.get("status") == "error"]
return {
"success": True,
"total_files": len(results),
"successful": len(successes),
"failed": len(failures),
"results": results
}
except Exception as e:
logger.error(f"Directory import failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/kb/{kb_name}/documents/{filename}")
async def api_delete_document(kb_name: str, filename: str):
"""REST API: Delete a document from a knowledge base by filename."""
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
# Validate KB exists
2025-10-27 17:41:44 -05:00
if not db_manager.collection_exists(kb_name):
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
try:
# Get the collection ID
2025-10-27 17:41:44 -05:00
conn = db_manager.get_connection()
try:
with conn.cursor() as cur:
# Get collection ID
cur.execute(
"SELECT id FROM knowledge_bases WHERE name = %s",
(kb_name,)
)
result = cur.fetchone()
if not result:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
collection_id = result[0]
# Delete documents with this filename
cur.execute(
"""
DELETE FROM documents
WHERE kb_id = %s AND metadata->>'filename' = %s
RETURNING id
""",
(collection_id, filename)
)
deleted_count = len(cur.fetchall())
conn.commit()
if deleted_count == 0:
raise HTTPException(status_code=404, detail=f"No documents found with filename '{filename}'")
logger.info(f"Deleted {deleted_count} document(s) with filename '{filename}' from KB '{kb_name}'")
return {
"success": True,
"message": f"Deleted {deleted_count} document(s)",
"filename": filename,
"kb_name": kb_name
}
finally:
2025-10-27 17:41:44 -05:00
db_manager.return_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete document: {e}")
raise HTTPException(status_code=500, detail=str(e))
2025-10-27 17:41:44 -05:00
# ============================================================================
# PROJECT MANAGEMENT ENDPOINTS (NEW - for Claude OS project integration)
# ============================================================================
class ProjectRequest(BaseModel):
"""Request model for creating a project."""
name: str
path: str
description: Optional[str] = ""
class ProjectKBFolderRequest(BaseModel):
"""Request model for setting KB folder."""
mcp_type: str # knowledge_docs, project_profile, project_index, project_memories
folder_path: str
auto_sync: bool = False
class ProjectDocumentIngestRequest(BaseModel):
"""Request model for ingesting a document into project MCP."""
mcp_type: str # knowledge_docs, project_profile, project_index, project_memories
filename: str
content: str
@app.get("/api/projects")
async def api_list_projects():
"""REST API: List all projects."""
try:
db_manager = get_sqlite_manager()
projects = db_manager.list_projects()
return {"projects": projects}
except Exception as e:
logger.error(f"Failed to list projects: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/projects")
async def api_create_project(request: ProjectRequest):
"""REST API: Create a new project with 4 required MCPs."""
try:
db_manager = get_sqlite_manager()
# Create project
project = db_manager.create_project(
name=request.name,
path=request.path,
description=request.description
)
logger.info(f"Created project: {request.name}")
# Create 4 required MCPs
mcp_types = ["knowledge_docs", "project_profile", "project_index", "project_memories"]
mcps_created = []
for i, mcp_type in enumerate(mcp_types):
kb_name = f"{request.name}-{mcp_type}"
# Create KB for this MCP type
kb = db_manager.create_collection(
name=kb_name,
kb_type=KBType.GENERIC,
description=f"{mcp_type.replace('_', ' ').title()} for {request.name}"
)
# Link KB to project
db_manager.assign_kb_to_project(
project["id"],
kb["id"],
mcp_type
)
mcps_created.append({
"mcp_type": mcp_type,
"kb_name": kb_name,
"kb_id": kb["id"]
})
logger.info(f"Created MCP {mcp_type} KB: {kb_name}")
return {
"project": project,
"mcps": mcps_created,
"message": f"Project '{request.name}' created with 4 required MCPs"
}
except Exception as e:
logger.error(f"Failed to create project: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/projects/{project_id}")
async def api_get_project(project_id: int):
"""REST API: Get project details with MCP assignments."""
try:
db_manager = get_sqlite_manager()
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Get MCP KBs for this project
project_kbs = db_manager.get_project_kbs(project_id)
project_folders = db_manager.get_kb_folders(project_id)
return {
"project": project,
"mcps": project_kbs,
"folders": project_folders
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get project: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/projects/{project_id}/mcps")
async def api_get_project_mcps(project_id: int):
"""REST API: Get detailed MCP info for a project (KB names and IDs)."""
try:
db_manager = get_sqlite_manager()
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Get detailed MCP info with KB names (returns dict)
mcps_dict = db_manager.get_project_mcps_detailed(project_id)
# Convert to list format for frontend
mcps_list = []
for mcp_type, mcp_data in mcps_dict.items():
# Get KB slug
kb = db_manager.get_collection_by_id(mcp_data['kb_id'])
mcps_list.append({
"mcp_type": mcp_type,
"kb_id": mcp_data['kb_id'],
"kb_name": mcp_data['kb_name'],
"kb_slug": kb.get('slug', '') if kb else ''
})
return {
"project_id": project_id,
"project_name": project['name'],
"project_path": project['path'],
"mcps": mcps_list
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get project MCPs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/projects/{project_id}/folders")
async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest):
"""REST API: Set the folder path for a project's MCP KB."""
try:
db_manager = get_sqlite_manager()
# Verify project exists
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Verify MCP type is valid
valid_mcp_types = ["knowledge_docs", "project_profile", "project_index", "project_memories"]
if request.mcp_type not in valid_mcp_types:
raise HTTPException(
status_code=400,
detail=f"Invalid MCP type. Must be one of: {', '.join(valid_mcp_types)}"
)
# Verify folder exists
from pathlib import Path
folder_path = Path(request.folder_path)
if not folder_path.exists():
raise HTTPException(
status_code=400,
detail=f"Folder does not exist: {request.folder_path}"
)
# Get the KB name for this MCP type
mcps = db_manager.get_project_mcps_detailed(project_id)
if request.mcp_type not in mcps:
raise HTTPException(
status_code=404,
detail=f"MCP type {request.mcp_type} not found for project {project_id}"
)
kb_name = mcps[request.mcp_type]['kb_name']
# Set folder for this MCP type
result = db_manager.set_kb_folder(
project_id,
request.mcp_type,
request.folder_path,
request.auto_sync
)
logger.info(f"Set {request.mcp_type} folder for project {project_id}: {request.folder_path} (auto_sync={request.auto_sync})")
# Automatically sync the folder contents
from app.core.ingestion import ingest_directory
sync_results = None
try:
logger.info(f"Auto-syncing folder {request.folder_path} to {kb_name}...")
results = ingest_directory(request.folder_path, kb_name)
successes = [r for r in results if r.get("status") == "success"]
failures = [r for r in results if r.get("status") == "error"]
sync_results = {
"total_files": len(results),
"successful": len(successes),
"failed": len(failures)
}
logger.info(f"Folder sync complete: {len(successes)}/{len(results)} files uploaded successfully")
except Exception as e:
logger.error(f"Failed to sync folder: {e}")
# Don't fail the whole request if sync fails
sync_results = {
"error": str(e)
}
return {
"project_id": project_id,
"mcp_type": request.mcp_type,
"folder_path": request.folder_path,
"auto_sync": request.auto_sync,
"sync_results": sync_results,
"message": f"Folder configured for {request.mcp_type}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to set KB folder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/projects/{project_id}/folders")
async def api_get_kb_folders(project_id: int):
"""REST API: Get folder configurations for a project's MCPs."""
try:
db_manager = get_sqlite_manager()
# Verify project exists
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Get folder configurations
folders = db_manager.get_kb_folders(project_id)
return {
"project_id": project_id,
"folders": folders
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get KB folders: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/projects/{project_id}/ingest-document")
async def api_ingest_document(project_id: int, request: ProjectDocumentIngestRequest):
"""REST API: Ingest a document directly into a project's MCP KB."""
try:
db_manager = get_sqlite_manager()
# Verify project exists
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Verify MCP type is valid
valid_mcp_types = ["knowledge_docs", "project_profile", "project_index", "project_memories"]
if request.mcp_type not in valid_mcp_types:
raise HTTPException(
status_code=400,
detail=f"Invalid MCP type. Must be one of: {', '.join(valid_mcp_types)}"
)
# Get the KB name for this MCP type
mcps = db_manager.get_project_mcps_detailed(project_id)
if request.mcp_type not in mcps:
raise HTTPException(
status_code=404,
detail=f"MCP type {request.mcp_type} not found for project {project_id}"
)
kb_name = mcps[request.mcp_type]['kb_name']
# Ingest the document into the KB
from app.core.ingestion import ingest_documents
try:
logger.info(f"Ingesting document {request.filename} to {kb_name} for project {project_id}...")
result = ingest_documents(
collection_name=kb_name,
documents=[request.content],
metadatas=[{
"source": request.filename,
"type": "text/markdown",
"project_id": project_id
}]
)
logger.info(f"Document {request.filename} ingested successfully")
return {
"project_id": project_id,
"mcp_type": request.mcp_type,
"filename": request.filename,
"kb_name": kb_name,
"status": "success",
"message": f"Document {request.filename} ingested into {request.mcp_type}"
}
except Exception as e:
logger.error(f"Failed to ingest document {request.filename}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to ingest document: {str(e)}")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to ingest document: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/projects/{project_id}")
async def api_delete_project(project_id: int):
"""REST API: Delete a project and its associated KBs."""
try:
db_manager = get_sqlite_manager()
# Get project to verify it exists
project = db_manager.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
# Get all KBs for this project
project_kbs = db_manager.get_project_kbs(project_id)
# Delete all KBs associated with this project
for mcp_type, kb_id in project_kbs.items():
# Get KB name to delete
collections = db_manager.list_collections()
for kb in collections:
if kb.get("id") == kb_id:
db_manager.delete_collection(kb["name"])
logger.info(f"Deleted KB {kb['name']} for project {project_id}")
break
# Delete project (cascades to project_mcps and project_kb_folders)
# Note: SQLite doesn't have CASCADE by default, so we manually delete
conn = db_manager.get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM project_kb_folders WHERE project_id = ?", (project_id,))
cursor.execute("DELETE FROM project_mcps WHERE project_id = ?", (project_id,))
cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,))
conn.commit()
finally:
conn.close()
logger.info(f"Deleted project {project_id}")
return {
"message": f"Project {project_id} and all associated KBs deleted",
"project_id": project_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete project: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PROJECT HOOKS ENDPOINTS (for KB folder synchronization)
# ============================================================================
class HookRequest(BaseModel):
"""Request model for configuring hooks."""
folder_path: str
file_patterns: Optional[List[str]] = None
class SyncRequest(BaseModel):
"""Request model for syncing KB folders."""
mcp_type: Optional[str] = None # If None, sync all
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/enable")
async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest):
"""Enable automatic KB synchronization for a folder."""
try:
hook = get_project_hook(project_id)
# Enable hook
hook_config = hook.enable_kb_autosync(
mcp_type,
request.folder_path,
request.file_patterns
)
return {
"project_id": project_id,
"mcp_type": mcp_type,
"hook": hook_config,
"message": f"Hook enabled for {mcp_type}"
}
except Exception as e:
logger.error(f"Failed to enable hook: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/disable")
async def api_disable_hook(project_id: int, mcp_type: str):
"""Disable automatic KB synchronization."""
try:
hook = get_project_hook(project_id)
hook.disable_kb_autosync(mcp_type)
return {
"project_id": project_id,
"mcp_type": mcp_type,
"message": f"Hook disabled for {mcp_type}"
}
except Exception as e:
logger.error(f"Failed to disable hook: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/projects/{project_id}/hooks/sync")
async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None):
"""Manually sync KB folders for a project."""
try:
hook = get_project_hook(project_id)
if request and request.mcp_type:
# Sync specific MCP type
result = hook.sync_kb_folder(request.mcp_type)
return {
"project_id": project_id,
"sync_result": result
}
else:
# Sync all folders
results = hook.sync_all_folders()
return {
"project_id": project_id,
"sync_results": results
}
except Exception as e:
logger.error(f"Failed to sync hooks: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/projects/{project_id}/hooks")
async def api_get_hooks_status(project_id: int):
"""Get hook status for a project."""
try:
hook = get_project_hook(project_id)
status = hook.get_hook_status()
return {
"project_id": project_id,
"status": status
}
except Exception as e:
logger.error(f"Failed to get hooks status: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# FILE WATCHER ENDPOINTS (for automatic folder synchronization)
# ============================================================================
@app.post("/api/watcher/start/{project_id}")
async def api_start_watcher(project_id: int):
"""Start file watcher for a project."""
try:
watcher = get_global_watcher()
watcher.start_project(project_id)
return {
"project_id": project_id,
"message": "File watcher started",
"status": watcher.get_status()
}
except Exception as e:
logger.error(f"Failed to start watcher: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watcher/stop/{project_id}")
async def api_stop_watcher(project_id: int):
"""Stop file watcher for a project."""
try:
watcher = get_global_watcher()
watcher.stop_project(project_id)
return {
"project_id": project_id,
"message": "File watcher stopped",
"status": watcher.get_status()
}
except Exception as e:
logger.error(f"Failed to stop watcher: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watcher/restart/{project_id}")
async def api_restart_watcher(project_id: int):
"""Restart file watcher for a project."""
try:
watcher = get_global_watcher()
watcher.restart_project(project_id)
return {
"project_id": project_id,
"message": "File watcher restarted",
"status": watcher.get_status()
}
except Exception as e:
logger.error(f"Failed to restart watcher: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/watcher/status")
async def api_watcher_status():
"""Get file watcher status."""
try:
watcher = get_global_watcher()
return {"status": watcher.get_status()}
except Exception as e:
logger.error(f"Failed to get watcher status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ollama/models")
async def api_list_ollama_models():
"""REST API: List available Ollama models."""
try:
response = requests.get(f"{Config.OLLAMA_HOST}/api/tags", timeout=2)
if response.status_code == 200:
models = response.json().get("models", [])
return {"models": models}
else:
raise HTTPException(status_code=503, detail="Ollama service unavailable")
except Exception as e:
logger.error(f"Failed to list Ollama models: {e}")
raise HTTPException(status_code=503, detail=str(e))
2025-10-27 17:41:44 -05:00
@app.get("/api/browse-directory")
async def api_browse_directory(path: str = None):
"""REST API: Browse directories and return subdirectories."""
try:
from pathlib import Path
import os
# If no path provided or empty string, start from home directory
if not path or path.strip() == '':
path = str(Path.home())
# Normalize and validate path
dir_path = Path(path).expanduser().resolve()
# Security check: ensure path exists and is a directory
if not dir_path.exists():
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
if not dir_path.is_dir():
raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
# Get subdirectories
subdirs = []
try:
for item in sorted(dir_path.iterdir()):
if item.is_dir() and not item.name.startswith('.'):
subdirs.append({
"name": item.name,
"path": str(item),
"is_dir": True
})
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}")
return {
"current_path": str(dir_path),
"parent_path": str(dir_path.parent) if dir_path.parent != dir_path else None,
"subdirectories": subdirs
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to browse directory: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Health check endpoint (detailed version at end of file)
# MCP Protocol Endpoints
@app.post("/")
async def mcp_root(request: Request):
"""Main MCP endpoint - handles JSON-RPC requests."""
return await handle_mcp_request(request)
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""Global MCP endpoint - exposes all KBs."""
return await handle_mcp_request(request, kb_filter=None)
@app.post("/mcp/kb/{kb_slug}")
async def mcp_kb_endpoint(kb_slug: str, request: Request):
"""KB-specific MCP endpoint - only exposes tools for this KB."""
# Get KB name from slug
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
kb_name = db_manager.get_kb_by_slug(kb_slug)
if not kb_name:
return JSONResponse({
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32000,
"message": f"Knowledge base with slug '{kb_slug}' not found"
}
}, status_code=404)
return await handle_mcp_request(request, kb_filter=kb_name)
async def handle_mcp_request(request: Request, kb_filter: Optional[str] = None) -> JSONResponse:
"""
Handle MCP JSON-RPC requests.
Args:
request: FastAPI request object
kb_filter: If provided, only expose tools for this specific KB
"""
try:
body = await request.json()
method = body.get("method")
params = body.get("params", {})
request_id = body.get("id")
logger.info(f"MCP request: {method} (kb_filter={kb_filter})")
# Handle MCP protocol methods
if method == "initialize":
2025-10-27 17:41:44 -05:00
server_name = f"claude-os-{kb_filter}" if kb_filter else "claude-os"
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": server_name,
"version": "1.0.0"
}
}
})
elif method == "tools/list":
# Filter tools based on kb_filter
if kb_filter:
# KB-specific endpoint: only expose search tool for this KB
tools_list = [
{
"name": "search",
"description": f"Search the '{kb_filter}' knowledge base using RAG with optional advanced features",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query"},
"use_hybrid": {"type": "boolean", "description": "Enable hybrid search (vector + BM25)", "default": False},
"use_rerank": {"type": "boolean", "description": "Enable reranking for better relevance", "default": False},
"use_agentic": {"type": "boolean", "description": "Enable agentic RAG for complex queries", "default": False}
},
"required": ["query"]
}
},
{
"name": "get_stats",
"description": f"Get statistics for the '{kb_filter}' knowledge base",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "list_documents",
"description": f"List all documents in the '{kb_filter}' knowledge base",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
else:
# Global endpoint: expose all tools
tools_list = [
{"name": name, **info}
for name, info in TOOLS.items()
]
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"result": {"tools": tools_list}
})
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
# Handle KB-specific endpoint tool calls
if kb_filter:
# Inject kb_name into arguments for KB-specific tools
if tool_name == "search":
result = await search_knowledge_base(kb_name=kb_filter, **arguments)
elif tool_name == "get_stats":
result = await get_kb_stats(kb_name=kb_filter)
elif tool_name == "list_documents":
result = await list_documents(kb_name=kb_filter)
else:
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": f"Unknown tool: {tool_name}"
}
})
else:
# Global endpoint: route to appropriate tool function
if tool_name == "search_knowledge_base":
result = await search_knowledge_base(**arguments)
elif tool_name == "list_knowledge_bases":
result = await list_knowledge_bases()
elif tool_name == "list_knowledge_bases_by_type":
result = await list_knowledge_bases_by_type(**arguments)
elif tool_name == "create_knowledge_base":
result = await create_knowledge_base(**arguments)
elif tool_name == "get_kb_stats":
result = await get_kb_stats(**arguments)
elif tool_name == "list_documents":
result = await list_documents(**arguments)
elif tool_name == "ingest_agent_os_profile":
result = await ingest_agent_os_profile(**arguments)
elif tool_name == "get_agent_os_stats":
result = await get_agent_os_stats(**arguments)
elif tool_name == "get_standards":
result = await get_standards(**arguments)
elif tool_name == "get_workflows":
result = await get_workflows(**arguments)
elif tool_name == "get_specs":
result = await get_specs(**arguments)
elif tool_name == "get_product_context":
result = await get_product_context(**arguments)
else:
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": f"Unknown tool: {tool_name}"
}
})
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
})
else:
return JSONResponse({
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": f"Method not found: {method}"
}
})
except Exception as e:
logger.error(f"MCP request failed: {e}", exc_info=True)
return JSONResponse({
2025-10-22 13:29:28 -05:00
"jsonrpc": "2.0",
"id": body.get("id") if 'body' in locals() else None,
2025-10-22 13:29:28 -05:00
"error": {
"code": -32603,
"message": str(e)
}
})
@app.get("/health")
async def health_check():
"""
Health check endpoint that verifies all system components.
Returns detailed status for monitoring.
"""
health_status = {
"status": "healthy",
"components": {},
"timestamp": None
}
from datetime import datetime
health_status["timestamp"] = datetime.utcnow().isoformat()
# Check PostgreSQL
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
collections = db_manager.list_collections()
# Try to get schema version
2025-10-27 17:41:44 -05:00
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';")
table_count = cursor.fetchone()[0]
cursor.close()
2025-10-27 17:41:44 -05:00
db_manager.return_connection(conn)
health_status["components"]["postgresql"] = {
"status": "healthy",
"connected": True,
"database": "codeforge",
"tables": table_count,
"knowledge_bases": len(collections)
}
except Exception as e:
health_status["status"] = "unhealthy"
health_status["components"]["postgresql"] = {
"status": "unhealthy",
"connected": False,
"error": str(e)
2025-10-22 13:29:28 -05:00
}
# Check Ollama
try:
response = requests.get(f"{Config.OLLAMA_HOST}/api/tags", timeout=2)
if response.status_code == 200:
models = response.json().get("models", [])
model_names = [m["name"] for m in models]
# Check for required models
required_models = ["nomic-embed-text:latest", "llama3.2:3b"]
missing_models = [m for m in required_models if m not in model_names]
health_status["components"]["ollama"] = {
"status": "healthy" if not missing_models else "degraded",
"running": True,
"models_installed": len(models),
"required_models": required_models,
"missing_models": missing_models
}
if missing_models:
health_status["status"] = "degraded"
else:
health_status["status"] = "degraded"
health_status["components"]["ollama"] = {
"status": "degraded",
"running": True,
"error": f"Unexpected status code: {response.status_code}"
}
except Exception as e:
health_status["status"] = "unhealthy"
health_status["components"]["ollama"] = {
"status": "unhealthy",
"running": False,
"error": str(e)
}
# Check pgvector extension
try:
2025-10-27 17:41:44 -05:00
db_manager = get_sqlite_manager()
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute("SELECT extversion FROM pg_extension WHERE extname='vector';")
result = cursor.fetchone()
cursor.close()
2025-10-27 17:41:44 -05:00
db_manager.return_connection(conn)
if result:
health_status["components"]["pgvector"] = {
"status": "healthy",
"installed": True,
"version": result[0]
}
else:
health_status["status"] = "unhealthy"
health_status["components"]["pgvector"] = {
"status": "unhealthy",
"installed": False,
"error": "pgvector extension not found"
}
except Exception as e:
health_status["status"] = "unhealthy"
health_status["components"]["pgvector"] = {
"status": "unhealthy",
"installed": False,
"error": str(e)
}
# Return appropriate HTTP status code
status_code = 200 if health_status["status"] in ["healthy", "degraded"] else 503
return JSONResponse(content=health_status, status_code=status_code)
2025-10-22 13:29:28 -05:00
def main():
"""Start the MCP server."""
2025-10-27 17:41:44 -05:00
logger.info(f"Starting Claude OS MCP Server on {Config.MCP_SERVER_HOST}:{Config.MCP_SERVER_PORT}")
2025-10-22 13:29:28 -05:00
logger.info(f"MCP endpoint: http://{Config.MCP_SERVER_HOST}:{Config.MCP_SERVER_PORT}/mcp")
2025-10-22 13:29:28 -05:00
uvicorn.run(
app,
host=Config.MCP_SERVER_HOST,
port=Config.MCP_SERVER_PORT,
log_level="info"
)
if __name__ == "__main__":
main()