"""Hindsight MCP Server implementation using FastMCP.""" import json import logging import os from contextvars import ContextVar from fastmcp import FastMCP from hindsight_api import MemoryEngine from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES from hindsight_api.models import RequestContext # Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable _log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower() _log_level_map = { "critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING, "info": logging.INFO, "debug": logging.DEBUG, "trace": logging.DEBUG, } logging.basicConfig( level=_log_level_map.get(_log_level_str, logging.INFO), format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", ) logger = logging.getLogger(__name__) # Default bank_id from environment variable DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default") # Context variable to hold the current bank_id _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None) def get_current_bank_id() -> str | None: """Get the current bank_id from context.""" return _current_bank_id.get() def create_mcp_server(memory: MemoryEngine) -> FastMCP: """ Create and configure the Hindsight MCP server. Args: memory: MemoryEngine instance (required) Returns: Configured FastMCP server instance with stateless_http enabled """ # Use stateless_http=True for Claude Code compatibility mcp = FastMCP("hindsight-mcp-server", stateless_http=True) @mcp.tool() async def retain( content: str, context: str = "general", async_processing: bool = True, bank_id: str | None = None, ) -> str: """ Store important information to long-term memory. Use this tool PROACTIVELY whenever the user shares: - Personal facts, preferences, or interests - Important events or milestones - User history, experiences, or background - Decisions, opinions, or stated preferences - Goals, plans, or future intentions - Relationships or people mentioned - Work context, projects, or responsibilities Args: content: The fact/memory to store (be specific and include relevant details) context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations. """ try: target_bank = bank_id or get_current_bank_id() if target_bank is None: return "Error: No bank_id configured" contents = [{"content": content, "context": context}] if async_processing: # Queue for background processing and return immediately result = await memory.submit_async_retain( bank_id=target_bank, contents=contents, request_context=RequestContext() ) return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})" else: # Wait for completion await memory.retain_batch_async( bank_id=target_bank, contents=contents, request_context=RequestContext(), ) return f"Memory stored successfully in bank '{target_bank}'" except Exception as e: logger.error(f"Error storing memory: {e}", exc_info=True) return f"Error: {str(e)}" @mcp.tool() async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str: """ Search memories to provide personalized, context-aware responses. Use this tool PROACTIVELY to: - Check user's preferences before making suggestions - Recall user's history to provide continuity - Remember user's goals and context - Personalize responses based on past interactions Args: query: Natural language search query (e.g., "user's food preferences", "what projects is user working on") max_tokens: Maximum tokens in the response (default: 4096) bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations. """ try: target_bank = bank_id or get_current_bank_id() if target_bank is None: return "Error: No bank_id configured" from hindsight_api.engine.memory_engine import Budget recall_result = await memory.recall_async( bank_id=target_bank, query=query, fact_type=list(VALID_RECALL_FACT_TYPES), budget=Budget.HIGH, max_tokens=max_tokens, request_context=RequestContext(), ) # Use model's JSON serialization return recall_result.model_dump_json(indent=2) except Exception as e: logger.error(f"Error searching: {e}", exc_info=True) return f'{{"error": "{e}", "results": []}}' @mcp.tool() async def reflect(query: str, context: str | None = None, budget: str = "low", bank_id: str | None = None) -> str: """ Generate thoughtful analysis by synthesizing stored memories with the bank's personality. WHEN TO USE THIS TOOL: Use reflect when you need reasoned analysis, not just fact retrieval. This tool thinks through the question using everything the bank knows and its personality traits. EXAMPLES OF GOOD QUERIES: - "What patterns have emerged in how I approach debugging?" - "Based on my past decisions, what architectural style do I prefer?" - "What might be the best approach for this problem given what you know about me?" - "How should I prioritize these tasks based on my goals?" HOW IT DIFFERS FROM RECALL: - recall: Returns raw facts matching your search (fast lookup) - reflect: Reasons across memories to form a synthesized answer (deeper analysis) Use recall for "what did I say about X?" and reflect for "what should I do about X?" Args: query: The question or topic to reflect on context: Optional context about why this reflection is needed budget: Search budget - 'low', 'mid', or 'high' (default: 'low') bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations. """ try: target_bank = bank_id or get_current_bank_id() if target_bank is None: return "Error: No bank_id configured" from hindsight_api.engine.memory_engine import Budget # Map string budget to enum budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} budget_enum = budget_map.get(budget.lower(), Budget.LOW) reflect_result = await memory.reflect_async( bank_id=target_bank, query=query, budget=budget_enum, context=context, request_context=RequestContext(), ) return reflect_result.model_dump_json(indent=2) except Exception as e: logger.error(f"Error reflecting: {e}", exc_info=True) return f'{{"error": "{e}", "text": ""}}' @mcp.tool() async def list_banks() -> str: """ List all available memory banks. Use this tool to discover what memory banks exist in the system. Each bank is an isolated memory store (like a separate "brain"). Returns: JSON list of banks with their IDs, names, dispositions, and missions. """ try: banks = await memory.list_banks(request_context=RequestContext()) return json.dumps({"banks": banks}, indent=2) except Exception as e: logger.error(f"Error listing banks: {e}", exc_info=True) return f'{{"error": "{e}", "banks": []}}' @mcp.tool() async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str: """ Create a new memory bank or get an existing one. Memory banks are isolated stores - each one is like a separate "brain" for a user/agent. Banks are auto-created with default settings if they don't exist. Args: bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha') name: Optional human-friendly name for the bank mission: Optional mission describing who the agent is and what they're trying to accomplish """ try: # get_bank_profile auto-creates bank if it doesn't exist profile = await memory.get_bank_profile(bank_id, request_context=RequestContext()) # Update name/mission if provided if name is not None or mission is not None: await memory.update_bank( bank_id, name=name, mission=mission, request_context=RequestContext(), ) # Fetch updated profile profile = await memory.get_bank_profile(bank_id, request_context=RequestContext()) # Serialize disposition if it's a Pydantic model if "disposition" in profile and hasattr(profile["disposition"], "model_dump"): profile["disposition"] = profile["disposition"].model_dump() return json.dumps(profile, indent=2) except Exception as e: logger.error(f"Error creating bank: {e}", exc_info=True) return f'{{"error": "{e}"}}' return mcp class MCPMiddleware: """ASGI middleware that extracts bank_id from header or path and sets context. Bank ID can be provided via: 1. X-Bank-Id header (recommended for Claude Code) 2. URL path: /mcp/{bank_id}/ 3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback default) For Claude Code, configure with: claude mcp add --transport http hindsight http://localhost:8888/mcp \\ --header "X-Bank-Id: my-bank" """ def __init__(self, app, memory: MemoryEngine): self.app = app self.memory = memory self.mcp_server = create_mcp_server(memory) self.mcp_app = self.mcp_server.http_app(path="/") # Expose the lifespan for the parent app to chain self.lifespan = self.mcp_app.lifespan_handler if hasattr(self.mcp_app, "lifespan_handler") else None def _get_header(self, scope: dict, name: str) -> str | None: """Extract a header value from ASGI scope.""" name_lower = name.lower().encode() for header_name, header_value in scope.get("headers", []): if header_name.lower() == name_lower: return header_value.decode() return None async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.mcp_app(scope, receive, send) return path = scope.get("path", "") # Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped root_path = scope.get("root_path", "") if root_path and path.startswith(root_path): path = path[len(root_path) :] or "/" # Also handle case where mount path wasn't stripped (e.g., /mcp/...) if path.startswith("/mcp/"): path = path[4:] # Remove /mcp prefix elif path == "/mcp": path = "/" # Try to get bank_id from header first (for Claude Code compatibility) bank_id = self._get_header(scope, "X-Bank-Id") # MCP endpoint paths that should not be treated as bank_ids MCP_ENDPOINTS = {"sse", "messages"} # If no header, try to extract from path: /{bank_id}/... new_path = path if not bank_id and path.startswith("/") and len(path) > 1: parts = path[1:].split("/", 1) # Don't treat MCP endpoints as bank_ids if parts[0] and parts[0] not in MCP_ENDPOINTS: # First segment looks like a bank_id bank_id = parts[0] new_path = "/" + parts[1] if len(parts) > 1 else "/" # Fall back to default bank_id if not bank_id: bank_id = DEFAULT_BANK_ID logger.debug(f"Using default bank_id: {bank_id}") # Set bank_id context token = _current_bank_id.set(bank_id) try: new_scope = scope.copy() new_scope["path"] = new_path # Clear root_path since we're passing directly to the app new_scope["root_path"] = "" # Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing async def send_wrapper(message): if message["type"] == "http.response.body": body = message.get("body", b"") if body and b"/messages" in body: # Rewrite /messages to /{bank_id}/messages in SSE endpoint event body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode()) message = {**message, "body": body} await send(message) await self.mcp_app(new_scope, receive, send_wrapper) finally: _current_bank_id.reset(token) async def _send_error(self, send, status: int, message: str): """Send an error response.""" body = json.dumps({"error": message}).encode() await send( { "type": "http.response.start", "status": status, "headers": [(b"content-type", b"application/json")], } ) await send( { "type": "http.response.body", "body": body, } ) def create_mcp_app(memory: MemoryEngine): """ Create an ASGI app that handles MCP requests. Bank ID can be provided via: 1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank" 2. URL path: /mcp/{bank_id}/ 3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback, default: "default") Args: memory: MemoryEngine instance Returns: ASGI application """ return MCPMiddleware(None, memory)