* feat(mcp): Add multi-bank access and new MCP tools Enables orchestrator agents to access multiple memory banks from a single MCP connection, with new tools for bank management. ## New MCP Tools - `reflect` - Thoughtful analysis using bank's personality and memories - `list_banks` - Discover all available memory banks - `create_bank` - Create new banks programmatically ## Multi-Bank Access - Added optional `bank_id` parameter to `retain`, `recall`, `reflect` - Allows cross-bank operations from a single MCP session - Defaults to session bank if not specified ## Claude Code Compatibility - Enabled `stateless_http=True` for proper Claude Code integration - Responses now include `bank_id` for transparency ## Documentation - Added docker-compose.example.yml with env var substitution - Added HINDSIGHT-DOCKER.md setup guide with volume persistence docs - Updated .gitignore to exclude local docker-compose.yml ## Use Case Orchestrator agents can now: - Maintain a private meta-orchestration bank - Access shared project knowledge banks - Query across banks for cross-context insights 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Address PR review feedback: remove docker files, improve reflect description - Remove HINDSIGHT-DOCKER.md and docker-compose.example.yml per reviewer request - Improve reflect tool description with clearer guidance for AI agents: - Added "WHEN TO USE THIS TOOL" section - Added "EXAMPLES OF GOOD QUERIES" with concrete use cases - Added "HOW IT DIFFERS FROM RECALL" to clarify when to use each tool 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
391 lines
15 KiB
Python
391 lines
15 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", 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'
|
|
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"
|
|
await memory.retain_batch_async(
|
|
bank_id=target_bank, contents=[{"content": content, "context": context}], 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_results: int = 10, 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_results: Maximum number of results to return (default: 10)
|
|
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
|
|
|
|
search_result = await memory.recall_async(
|
|
bank_id=target_bank,
|
|
query=query,
|
|
fact_type=list(VALID_RECALL_FACT_TYPES),
|
|
budget=Budget.LOW,
|
|
request_context=RequestContext(),
|
|
)
|
|
|
|
results = [
|
|
{
|
|
"id": fact.id,
|
|
"text": fact.text,
|
|
"type": fact.fact_type,
|
|
"context": fact.context,
|
|
"occurred_start": fact.occurred_start,
|
|
}
|
|
for fact in search_result.results[:max_results]
|
|
]
|
|
|
|
return json.dumps({"results": results, "bank_id": target_bank}, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error searching: {e}", exc_info=True)
|
|
return json.dumps({"error": str(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 the reflection text and optionally facts used
|
|
result = {
|
|
"text": reflect_result.text,
|
|
"bank_id": target_bank,
|
|
"based_on": {
|
|
fact_type: [
|
|
{"id": f.id, "text": f.text, "context": f.context}
|
|
for f in facts
|
|
]
|
|
for fact_type, facts in (reflect_result.based_on or {}).items()
|
|
if facts
|
|
},
|
|
}
|
|
|
|
# Include new opinions if any were formed
|
|
if reflect_result.new_opinions:
|
|
result["new_opinions"] = [
|
|
{"text": op.text, "confidence": op.confidence}
|
|
for op in reflect_result.new_opinions
|
|
]
|
|
|
|
return json.dumps(result, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error reflecting: {e}", exc_info=True)
|
|
return json.dumps({"error": str(e), "text": ""})
|
|
|
|
@mcp.tool()
|
|
async def list_banks() -> str:
|
|
"""
|
|
List all available memory banks.
|
|
|
|
Use this to discover banks for orchestration or to find
|
|
the correct bank_id for cross-bank operations.
|
|
|
|
Returns:
|
|
JSON list of banks with id, name, and creation date
|
|
"""
|
|
try:
|
|
banks = await memory.list_banks(request_context=RequestContext())
|
|
return json.dumps({
|
|
"banks": [
|
|
{
|
|
"id": b["bank_id"],
|
|
"name": b.get("name"),
|
|
"created_at": str(b.get("created_at")) if b.get("created_at") else None
|
|
}
|
|
for b in banks
|
|
]
|
|
}, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error listing banks: {e}", exc_info=True)
|
|
return json.dumps({"error": str(e), "banks": []})
|
|
|
|
@mcp.tool()
|
|
async def create_bank(bank_id: str, name: str | None = None, background: str | None = None) -> str:
|
|
"""
|
|
Create or update a memory bank.
|
|
|
|
Use this to create new banks for different agents, sessions, or purposes.
|
|
Banks are isolated memory stores - each bank has its own memories and personality.
|
|
|
|
Args:
|
|
bank_id: Unique identifier for the bank (e.g., 'orchestrator-memory', 'agent-1')
|
|
name: Human-readable name for the bank
|
|
background: Context about what this bank stores or its purpose
|
|
"""
|
|
try:
|
|
# Get or create the bank profile (auto-creates with defaults)
|
|
await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
|
|
|
# Update name and/or background if provided
|
|
if name is not None or background is not None:
|
|
await memory.update_bank_info(
|
|
bank_id,
|
|
name=name,
|
|
background=background,
|
|
request_context=RequestContext()
|
|
)
|
|
|
|
# Get final profile
|
|
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
|
return json.dumps({
|
|
"success": True,
|
|
"bank_id": bank_id,
|
|
"name": profile.get("name"),
|
|
"background": profile.get("background"),
|
|
"disposition": profile.get("disposition").model_dump() if hasattr(profile.get("disposition"), "model_dump") else dict(profile.get("disposition", {}))
|
|
}, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error creating bank: {e}", exc_info=True)
|
|
return json.dumps({"error": str(e), "success": False})
|
|
|
|
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()
|
|
# 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")
|
|
|
|
# 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)
|
|
if parts[0] and parts[0] != "mcp":
|
|
# 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)
|