feat: add local mcp server (#32)
This commit is contained in:
parent
1cef364719
commit
7dd68538bb
9 changed files with 578 additions and 5233 deletions
|
|
@ -61,7 +61,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
bank_id = get_current_bank_id()
|
bank_id = get_current_bank_id()
|
||||||
await memory.put_batch_async(
|
await memory.retain_batch_async(
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
contents=[{"content": content, "context": context}]
|
contents=[{"content": content, "context": context}]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ ENV_PORT = "HINDSIGHT_API_PORT"
|
||||||
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
||||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||||
|
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_DATABASE_URL = "pg0"
|
DEFAULT_DATABASE_URL = "pg0"
|
||||||
|
|
@ -47,6 +48,7 @@ DEFAULT_PORT = 8888
|
||||||
DEFAULT_LOG_LEVEL = "info"
|
DEFAULT_LOG_LEVEL = "info"
|
||||||
DEFAULT_MCP_ENABLED = True
|
DEFAULT_MCP_ENABLED = True
|
||||||
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
|
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
|
||||||
|
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
|
||||||
|
|
||||||
# Required embedding dimension for database schema
|
# Required embedding dimension for database schema
|
||||||
EMBEDDING_DIMENSION = 384
|
EMBEDDING_DIMENSION = 384
|
||||||
|
|
|
||||||
192
hindsight-api/hindsight_api/mcp_local.py
Normal file
192
hindsight-api/hindsight_api/mcp_local.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""
|
||||||
|
Local MCP server for use with Claude Code (stdio transport).
|
||||||
|
|
||||||
|
This runs a fully local Hindsight instance with embedded PostgreSQL (pg0).
|
||||||
|
No external database or server required.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
hindsight-local-mcp
|
||||||
|
|
||||||
|
Or with uvx:
|
||||||
|
uvx hindsight-api@latest hindsight-local-mcp
|
||||||
|
|
||||||
|
Configure in Claude Code's MCP settings:
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hindsight": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
|
||||||
|
"env": {
|
||||||
|
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
HINDSIGHT_API_LLM_API_KEY: Required. API key for LLM provider.
|
||||||
|
HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai").
|
||||||
|
HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini").
|
||||||
|
HINDSIGHT_API_MCP_LOCAL_BANK_ID: Optional. Memory bank ID (default: "mcp").
|
||||||
|
HINDSIGHT_API_LOG_LEVEL: Optional. Log level (default: "info").
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from hindsight_api.config import (
|
||||||
|
ENV_MCP_LOCAL_BANK_ID,
|
||||||
|
DEFAULT_MCP_LOCAL_BANK_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure logging - default to info
|
||||||
|
_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,
|
||||||
|
}
|
||||||
|
logging.basicConfig(
|
||||||
|
level=_log_level_map.get(_log_level_str, logging.WARNING),
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
stream=sys.stderr, # MCP uses stdout for protocol, logs go to stderr
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
|
||||||
|
"""
|
||||||
|
Create a stdio MCP server with retain/recall tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bank_id: The memory bank ID to use for all operations.
|
||||||
|
memory: Optional MemoryEngine instance. If not provided, creates one with pg0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured FastMCP server instance.
|
||||||
|
"""
|
||||||
|
# Import here to avoid slow startup if just checking --help
|
||||||
|
from hindsight_api import MemoryEngine
|
||||||
|
from hindsight_api.engine.memory_engine import Budget
|
||||||
|
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||||
|
|
||||||
|
# Create memory engine with pg0 embedded database if not provided
|
||||||
|
if memory is None:
|
||||||
|
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
|
||||||
|
|
||||||
|
mcp = FastMCP("hindsight")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def retain(content: str, context: str = "general") -> dict:
|
||||||
|
"""
|
||||||
|
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'
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _retain():
|
||||||
|
try:
|
||||||
|
await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[{"content": content, "context": context}]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Fire and forget - don't block on memory storage
|
||||||
|
asyncio.create_task(_retain())
|
||||||
|
return {"status": "accepted", "message": "Memory storage initiated"}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
|
||||||
|
"""
|
||||||
|
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 to return in results (default: 4096)
|
||||||
|
budget: Search budget level - "low", "mid", or "high" (default: "low")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
search_result = await memory.recall_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
query=query,
|
||||||
|
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||||
|
budget=budget_enum,
|
||||||
|
max_tokens=max_tokens
|
||||||
|
)
|
||||||
|
|
||||||
|
return search_result.model_dump()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching: {e}", exc_info=True)
|
||||||
|
return {"error": str(e), "results": []}
|
||||||
|
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
async def _initialize_and_run(bank_id: str):
|
||||||
|
"""Initialize memory and run the MCP server."""
|
||||||
|
from hindsight_api import MemoryEngine
|
||||||
|
|
||||||
|
# Create and initialize memory engine with pg0 embedded database
|
||||||
|
print("Initializing memory engine...", file=sys.stderr)
|
||||||
|
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
|
||||||
|
await memory.initialize()
|
||||||
|
print("Memory engine initialized.", file=sys.stderr)
|
||||||
|
|
||||||
|
# Create and run the server
|
||||||
|
mcp = create_local_mcp_server(bank_id, memory=memory)
|
||||||
|
await mcp.run_stdio_async()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the stdio MCP server."""
|
||||||
|
import asyncio
|
||||||
|
from hindsight_api.config import get_config, ENV_LLM_API_KEY
|
||||||
|
|
||||||
|
# Check for required environment variables
|
||||||
|
config = get_config()
|
||||||
|
if not config.llm_api_key:
|
||||||
|
print(f"Error: {ENV_LLM_API_KEY} environment variable is required", file=sys.stderr)
|
||||||
|
print("Set it in your MCP configuration or shell environment", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get bank ID from environment, default to "mcp"
|
||||||
|
bank_id = os.environ.get(ENV_MCP_LOCAL_BANK_ID, DEFAULT_MCP_LOCAL_BANK_ID)
|
||||||
|
|
||||||
|
# Print startup message to stderr (stdout is reserved for MCP protocol)
|
||||||
|
print(f"Hindsight MCP server starting (bank_id={bank_id})...", file=sys.stderr)
|
||||||
|
|
||||||
|
# Run the async initialization and server
|
||||||
|
asyncio.run(_initialize_and_run(bank_id))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -6,7 +6,6 @@ from pg0 import Pg0
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_PORT = 5555
|
|
||||||
DEFAULT_USERNAME = "hindsight"
|
DEFAULT_USERNAME = "hindsight"
|
||||||
DEFAULT_PASSWORD = "hindsight"
|
DEFAULT_PASSWORD = "hindsight"
|
||||||
DEFAULT_DATABASE = "hindsight"
|
DEFAULT_DATABASE = "hindsight"
|
||||||
|
|
@ -17,14 +16,14 @@ class EmbeddedPostgres:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
port: int = DEFAULT_PORT,
|
port: Optional[int] = None,
|
||||||
username: str = DEFAULT_USERNAME,
|
username: str = DEFAULT_USERNAME,
|
||||||
password: str = DEFAULT_PASSWORD,
|
password: str = DEFAULT_PASSWORD,
|
||||||
database: str = DEFAULT_DATABASE,
|
database: str = DEFAULT_DATABASE,
|
||||||
name: str = "hindsight",
|
name: str = "hindsight",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.port = port
|
self.port = port # None means pg0 will auto-assign
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self.database = database
|
self.database = database
|
||||||
|
|
@ -33,18 +32,22 @@ class EmbeddedPostgres:
|
||||||
|
|
||||||
def _get_pg0(self) -> Pg0:
|
def _get_pg0(self) -> Pg0:
|
||||||
if self._pg0 is None:
|
if self._pg0 is None:
|
||||||
self._pg0 = Pg0(
|
kwargs = {
|
||||||
name=self.name,
|
"name": self.name,
|
||||||
port=self.port,
|
"username": self.username,
|
||||||
username=self.username,
|
"password": self.password,
|
||||||
password=self.password,
|
"database": self.database,
|
||||||
database=self.database,
|
}
|
||||||
)
|
# Only set port if explicitly specified
|
||||||
|
if self.port is not None:
|
||||||
|
kwargs["port"] = self.port
|
||||||
|
self._pg0 = Pg0(**kwargs)
|
||||||
return self._pg0
|
return self._pg0
|
||||||
|
|
||||||
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
|
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
|
||||||
"""Start the PostgreSQL server with retry logic."""
|
"""Start the PostgreSQL server with retry logic."""
|
||||||
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
|
port_info = f"port={self.port}" if self.port else "port=auto"
|
||||||
|
logger.info(f"Starting embedded PostgreSQL (name={self.name}, {port_info})...")
|
||||||
|
|
||||||
pg0 = self._get_pg0()
|
pg0 = self._get_pg0()
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
@ -53,9 +56,9 @@ class EmbeddedPostgres:
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
info = await loop.run_in_executor(None, pg0.start)
|
info = await loop.run_in_executor(None, pg0.start)
|
||||||
logger.info(f"PostgreSQL started on port {self.port}")
|
# Get URI from pg0 (includes auto-assigned port)
|
||||||
# Construct URI manually since pg0-embedded may return None
|
uri = info.uri
|
||||||
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
|
logger.info(f"PostgreSQL started: {uri}")
|
||||||
return uri
|
return uri
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
|
|
@ -91,9 +94,7 @@ class EmbeddedPostgres:
|
||||||
pg0 = self._get_pg0()
|
pg0 = self._get_pg0()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
info = await loop.run_in_executor(None, pg0.info)
|
info = await loop.run_in_executor(None, pg0.info)
|
||||||
# Construct URI manually since pg0-embedded may return None
|
return info.uri
|
||||||
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
|
|
||||||
return uri
|
|
||||||
|
|
||||||
async def is_running(self) -> bool:
|
async def is_running(self) -> bool:
|
||||||
"""Check if the PostgreSQL server is currently running."""
|
"""Check if the PostgreSQL server is currently running."""
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ test = [
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
hindsight-api = "hindsight_api.main:main"
|
hindsight-api = "hindsight_api.main:main"
|
||||||
|
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["hindsight_api"]
|
packages = ["hindsight_api"]
|
||||||
|
|
|
||||||
162
hindsight-api/tests/test_mcp_local.py
Normal file
162
hindsight-api/tests/test_mcp_local.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Test local MCP server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_memory():
|
||||||
|
"""Create a mock MemoryEngine."""
|
||||||
|
memory = MagicMock()
|
||||||
|
memory._initialized = True
|
||||||
|
memory.retain_batch_async = AsyncMock()
|
||||||
|
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_retain(mock_memory):
|
||||||
|
"""Test that retain tool fires async and returns immediately."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
|
||||||
|
bank_id = "test-bank"
|
||||||
|
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
|
||||||
|
|
||||||
|
# Get the tools
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
assert "retain" in tools
|
||||||
|
|
||||||
|
# Call retain
|
||||||
|
retain_tool = tools["retain"]
|
||||||
|
result = await retain_tool.fn(content="test content", context="test_context")
|
||||||
|
|
||||||
|
# Returns immediately with accepted status
|
||||||
|
assert result["status"] == "accepted"
|
||||||
|
|
||||||
|
# Wait for background task to complete
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Verify the memory was called correctly
|
||||||
|
mock_memory.retain_batch_async.assert_called_once()
|
||||||
|
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
|
||||||
|
assert call_kwargs["bank_id"] == "test-bank"
|
||||||
|
assert call_kwargs["contents"] == [{"content": "test content", "context": "test_context"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_recall(mock_memory):
|
||||||
|
"""Test that recall tool calls memory.recall_async with correct params."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
from hindsight_api.engine.memory_engine import Budget
|
||||||
|
|
||||||
|
# Mock recall_async to return a proper pydantic model
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.model_dump.return_value = {"results": []}
|
||||||
|
mock_memory.recall_async = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
bank_id = "test-bank"
|
||||||
|
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
|
||||||
|
|
||||||
|
# Get the tools
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
assert "recall" in tools
|
||||||
|
|
||||||
|
# Call recall with new params
|
||||||
|
recall_tool = tools["recall"]
|
||||||
|
result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid")
|
||||||
|
|
||||||
|
# Result is a dict
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
|
||||||
|
# Verify the memory was called correctly
|
||||||
|
mock_memory.recall_async.assert_called_once()
|
||||||
|
call_kwargs = mock_memory.recall_async.call_args.kwargs
|
||||||
|
assert call_kwargs["bank_id"] == "test-bank"
|
||||||
|
assert call_kwargs["query"] == "test query"
|
||||||
|
assert call_kwargs["max_tokens"] == 2048
|
||||||
|
assert call_kwargs["budget"] == Budget.MID
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_retain_with_default_context(mock_memory):
|
||||||
|
"""Test that retain uses default context when not provided."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
|
||||||
|
bank_id = "test-bank"
|
||||||
|
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
|
||||||
|
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
retain_tool = tools["retain"]
|
||||||
|
|
||||||
|
# Call retain without context
|
||||||
|
await retain_tool.fn(content="test content")
|
||||||
|
|
||||||
|
# Wait for background task
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
|
||||||
|
assert call_kwargs["contents"] == [{"content": "test content", "context": "general"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_retain_error_handling(mock_memory):
|
||||||
|
"""Test that retain errors are logged but don't affect response."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
|
||||||
|
mock_memory.retain_batch_async = AsyncMock(side_effect=Exception("Test error"))
|
||||||
|
|
||||||
|
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
|
||||||
|
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
retain_tool = tools["retain"]
|
||||||
|
|
||||||
|
# Retain returns immediately with accepted status (fire and forget)
|
||||||
|
result = await retain_tool.fn(content="test content")
|
||||||
|
assert result["status"] == "accepted"
|
||||||
|
|
||||||
|
# Wait for background task to complete (and log error)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_recall_error_handling(mock_memory):
|
||||||
|
"""Test that recall handles errors gracefully."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
|
||||||
|
mock_memory.recall_async = AsyncMock(side_effect=Exception("Test error"))
|
||||||
|
|
||||||
|
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
|
||||||
|
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
recall_tool = tools["recall"]
|
||||||
|
|
||||||
|
result = await recall_tool.fn(query="test query")
|
||||||
|
|
||||||
|
# Result is a dict with error
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "error" in result
|
||||||
|
assert result["results"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_mcp_server_recall_with_defaults(mock_memory):
|
||||||
|
"""Test that recall uses default max_tokens and budget."""
|
||||||
|
from hindsight_api.mcp_local import create_local_mcp_server
|
||||||
|
from hindsight_api.engine.memory_engine import Budget
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.model_dump.return_value = {"results": []}
|
||||||
|
mock_memory.recall_async = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
|
||||||
|
|
||||||
|
tools = mcp_server._tool_manager._tools
|
||||||
|
recall_tool = tools["recall"]
|
||||||
|
|
||||||
|
# Call with defaults
|
||||||
|
await recall_tool.fn(query="test query")
|
||||||
|
|
||||||
|
call_kwargs = mock_memory.recall_async.call_args.kwargs
|
||||||
|
assert call_kwargs["max_tokens"] == 4096
|
||||||
|
assert call_kwargs["budget"] == Budget.LOW
|
||||||
|
|
@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
def mock_memory():
|
def mock_memory():
|
||||||
"""Create a mock MemoryEngine."""
|
"""Create a mock MemoryEngine."""
|
||||||
memory = MagicMock()
|
memory = MagicMock()
|
||||||
memory.put_batch_async = AsyncMock()
|
memory.retain_batch_async = AsyncMock()
|
||||||
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
|
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
|
||||||
return memory
|
return memory
|
||||||
|
|
||||||
|
|
@ -52,8 +52,8 @@ async def test_mcp_tools_use_context_bank_id(mock_memory):
|
||||||
assert "successfully" in result.lower()
|
assert "successfully" in result.lower()
|
||||||
|
|
||||||
# Verify the memory was called with the context bank_id
|
# Verify the memory was called with the context bank_id
|
||||||
mock_memory.put_batch_async.assert_called_once()
|
mock_memory.retain_batch_async.assert_called_once()
|
||||||
call_kwargs = mock_memory.put_batch_async.call_args.kwargs
|
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
|
||||||
assert call_kwargs["bank_id"] == "context-bank-id"
|
assert call_kwargs["bank_id"] == "context-bank-id"
|
||||||
finally:
|
finally:
|
||||||
_current_bank_id.reset(token)
|
_current_bank_id.reset(token)
|
||||||
|
|
|
||||||
199
hindsight-docs/docs/sdks/integrations/local-mcp.md
Normal file
199
hindsight-docs/docs/sdks/integrations/local-mcp.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
---
|
||||||
|
sidebar_position: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local MCP Server
|
||||||
|
|
||||||
|
Hindsight provides a fully local MCP server that runs entirely on your machine with an embedded PostgreSQL database. No external server or database setup required.
|
||||||
|
|
||||||
|
This is ideal for:
|
||||||
|
- **Personal use with Claude Code** — Give Claude long-term memory across conversations
|
||||||
|
- **Development and testing** — Quick setup without infrastructure
|
||||||
|
- **Privacy-focused setups** — All data stays on your machine
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### With uvx (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx hindsight-api@latest hindsight-local-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### With pip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install hindsight-api
|
||||||
|
hindsight-local-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Claude Code Configuration
|
||||||
|
|
||||||
|
Add to your Claude Code MCP settings (`~/.claude/claude_desktop_config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hindsight": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
|
||||||
|
"env": {
|
||||||
|
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Bank ID
|
||||||
|
|
||||||
|
By default, memories are stored in a bank called `mcp`. To use a different bank:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hindsight": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
|
||||||
|
"env": {
|
||||||
|
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key",
|
||||||
|
"HINDSIGHT_API_MCP_LOCAL_BANK_ID": "my-personal-memory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `HINDSIGHT_API_LLM_API_KEY` | Yes | - | API key for the LLM provider |
|
||||||
|
| `HINDSIGHT_API_LLM_PROVIDER` | No | `openai` | LLM provider (`openai`, `groq`, `anthropic`) |
|
||||||
|
| `HINDSIGHT_API_LLM_MODEL` | No | `gpt-4o-mini` | Model to use for fact extraction |
|
||||||
|
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | No | `mcp` | Memory bank ID |
|
||||||
|
| `HINDSIGHT_API_LOG_LEVEL` | No | `info` | Log level (`debug`, `info`, `warning`, `error`) |
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### retain
|
||||||
|
|
||||||
|
Store information to long-term memory. This is a **fire-and-forget** operation — it returns immediately while processing happens in the background.
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `content` | string | Yes | The fact or memory to store |
|
||||||
|
| `context` | string | No | Category for the memory (default: `general`) |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "retain",
|
||||||
|
"arguments": {
|
||||||
|
"content": "User's favorite color is blue",
|
||||||
|
"context": "preferences"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "accepted",
|
||||||
|
"message": "Memory storage initiated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### recall
|
||||||
|
|
||||||
|
Search memories to provide personalized responses.
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `query` | string | Yes | Natural language search query |
|
||||||
|
| `max_tokens` | integer | No | Maximum tokens to return (default: 4096) |
|
||||||
|
| `budget` | string | No | Search depth: `low`, `mid`, or `high` (default: `low`) |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "recall",
|
||||||
|
"arguments": {
|
||||||
|
"query": "What are the user's color preferences?",
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"budget": "mid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"text": "User's favorite color is blue",
|
||||||
|
"fact_type": "world",
|
||||||
|
"context": "preferences",
|
||||||
|
"event_date": null,
|
||||||
|
"score": 0.95
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_tokens": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The local MCP server:
|
||||||
|
|
||||||
|
1. **Starts an embedded PostgreSQL** (pg0) on an automatically assigned port
|
||||||
|
2. **Initializes the Hindsight memory engine** with local embeddings
|
||||||
|
3. **Connects via stdio** to Claude Code using the MCP protocol
|
||||||
|
|
||||||
|
Data is persisted in the pg0 data directory (`~/.pg0/hindsight-mcp/`), so your memories survive restarts.
|
||||||
|
|
||||||
|
## Comparison: Local vs Server MCP
|
||||||
|
|
||||||
|
| Feature | Local MCP | Server MCP |
|
||||||
|
|---------|-----------|------------|
|
||||||
|
| Setup | Zero config | Requires running server |
|
||||||
|
| Database | Embedded (pg0) | External PostgreSQL |
|
||||||
|
| Multi-user | Single user | Multi-tenant |
|
||||||
|
| Scalability | Single machine | Horizontally scalable |
|
||||||
|
| Use case | Personal/development | Production/teams |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "HINDSIGHT_API_LLM_API_KEY required"
|
||||||
|
|
||||||
|
Make sure you've set the API key in your MCP configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"HINDSIGHT_API_LLM_API_KEY": "sk-..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slow startup
|
||||||
|
|
||||||
|
The first startup may take longer as it:
|
||||||
|
- Downloads the embedding model (~100MB)
|
||||||
|
- Initializes the PostgreSQL database
|
||||||
|
|
||||||
|
Subsequent starts are faster.
|
||||||
|
|
||||||
|
### Checking logs
|
||||||
|
|
||||||
|
Set `HINDSIGHT_API_LOG_LEVEL=debug` for verbose output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"HINDSIGHT_API_LOG_LEVEL": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs are written to stderr and visible in Claude Code's MCP server output.
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue