* feat(mcp): add async_processing parameter to retain tool Add async_processing parameter (default: True) to the MCP retain tool to allow non-blocking memory storage. When True, memories are queued for background processing and the tool returns immediately. When False, the tool waits for completion before returning. This matches the async behavior available in the HTTP API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(mcp): add list_memories and reflect tools Add two missing MCP tools to achieve feature parity with HTTP API: - list_memories: browse memories with pagination and full-text search (equivalent to GET /memories/list) - reflect: LLM-based reasoning over memories with disposition awareness (equivalent to POST /reflect) Both tools follow the existing pattern with JSON string responses and proper error handling. * docs: improve CLAUDE.md with detailed architecture info - Add memory types explanation (world, experience, opinion, observation) - Document retain/ and search/ submodule structure - Add commands for single test run, ruff format, ty type checking - Note MCP server implementation in API layer - Add optional environment variables section - Clarify conventions (no Python files at root, npm workspaces) * chore: add .mcp.json and .osgrep to gitignore These are user-specific development tool configs that should not be committed. * changes * refactor(mcp): remove list_memories tool The list_memories endpoint is for debugging/exploration, not agent use. Agents should use recall for semantic search instead. Feedback from maintainer: "this tool is misleading for the agent, it should use recall, the list method is mostly for debugging and exploration, not for real usage" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(mcp): remove list_banks and create_bank tools These admin/orchestration tools are not needed for typical agent usage. Agents work with a single configured bank via X-Bank-Id header. MCP now exposes only core memory operations: - retain: store memories - recall: semantic search - reflect: LLM reasoning over memories Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Anton Evseev <a.evseev@xsolla.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""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": ""}}'
|
|
|
|
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)
|