Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fe1b2102 | ||
|
|
81cec70d5f | ||
|
|
4c38e21882 | ||
|
|
9eb7ed0055 | ||
|
|
2a41d84d4e | ||
|
|
df1e76430d | ||
|
|
749670680b | ||
|
|
26ce460f9d |
@@ -8,7 +8,11 @@ from contextvars import ContextVar
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.memory_engine import _current_schema
|
||||
from hindsight_api.extensions import MCPExtension, load_extension
|
||||
from hindsight_api.extensions.tenant import AuthenticationError
|
||||
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
|
||||
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()
|
||||
@@ -29,7 +33,8 @@ logger = logging.getLogger(__name__)
|
||||
# Default bank_id from environment variable
|
||||
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
|
||||
|
||||
# MCP authentication token (optional - if set, Bearer token auth is required)
|
||||
# Legacy MCP authentication token (for backwards compatibility)
|
||||
# If set, this token is checked first before TenantExtension auth
|
||||
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
|
||||
|
||||
# Context variable to hold the current bank_id
|
||||
@@ -73,6 +78,12 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
|
||||
register_mcp_tools(mcp, memory, config)
|
||||
|
||||
# Load and register additional tools from MCP extension if configured
|
||||
mcp_extension = load_extension("MCP", MCPExtension)
|
||||
if mcp_extension:
|
||||
logger.info(f"Loading MCP extension: {mcp_extension.__class__.__name__}")
|
||||
mcp_extension.register_tools(mcp, memory)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@@ -80,8 +91,10 @@ class MCPMiddleware:
|
||||
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
|
||||
|
||||
Authentication:
|
||||
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
|
||||
Authorization header with Bearer token or direct token matching the configured value.
|
||||
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
|
||||
2. Otherwise, uses TenantExtension.authenticate_mcp() from the MemoryEngine
|
||||
- DefaultTenantExtension: no auth required (local dev)
|
||||
- ApiKeyTenantExtension: validates against env var
|
||||
|
||||
Bank ID can be provided via:
|
||||
1. X-Bank-Id header (recommended for Claude Code)
|
||||
@@ -96,6 +109,7 @@ class MCPMiddleware:
|
||||
def __init__(self, app, memory: MemoryEngine):
|
||||
self.app = app
|
||||
self.memory = memory
|
||||
self.tenant_extension = memory._tenant_extension
|
||||
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
|
||||
@@ -121,14 +135,30 @@ class MCPMiddleware:
|
||||
# Support both "Bearer <token>" and direct token
|
||||
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
|
||||
|
||||
# Authenticate if MCP_AUTH_TOKEN is configured
|
||||
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
|
||||
tenant_context = None
|
||||
if MCP_AUTH_TOKEN:
|
||||
# Legacy authentication mode - validate against static token
|
||||
if not auth_token:
|
||||
await self._send_error(send, 401, "Authorization header required")
|
||||
return
|
||||
if auth_token != MCP_AUTH_TOKEN:
|
||||
await self._send_error(send, 401, "Invalid authentication token")
|
||||
return
|
||||
# Legacy mode doesn't use tenant schemas
|
||||
tenant_context = None
|
||||
else:
|
||||
# Use TenantExtension.authenticate_mcp() for auth
|
||||
try:
|
||||
tenant_context = await self.tenant_extension.authenticate_mcp(RequestContext(api_key=auth_token))
|
||||
except AuthenticationError as e:
|
||||
await self._send_error(send, 401, str(e))
|
||||
return
|
||||
|
||||
# Set schema from tenant context so downstream DB queries use the correct schema
|
||||
schema_token = (
|
||||
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
|
||||
)
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
@@ -189,6 +219,8 @@ class MCPMiddleware:
|
||||
_current_bank_id.reset(bank_id_token)
|
||||
if api_key_token is not None:
|
||||
_current_api_key.reset(api_key_token)
|
||||
if schema_token is not None:
|
||||
_current_schema.reset(schema_token)
|
||||
|
||||
async def _send_error(self, send, status: int, message: str):
|
||||
"""Send an error response."""
|
||||
@@ -213,8 +245,7 @@ def create_mcp_app(memory: MemoryEngine):
|
||||
Create an ASGI app that handles MCP requests.
|
||||
|
||||
Authentication:
|
||||
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
|
||||
If not set, MCP endpoint is open (for local development).
|
||||
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
|
||||
|
||||
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"
|
||||
|
||||
@@ -20,6 +20,7 @@ from hindsight_api.extensions.builtin import ApiKeyTenantExtension
|
||||
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
|
||||
from hindsight_api.extensions.http import HttpExtension
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
from hindsight_api.extensions.mcp import MCPExtension
|
||||
from hindsight_api.extensions.operation_validator import (
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
@@ -57,6 +58,8 @@ __all__ = [
|
||||
"DefaultExtensionContext",
|
||||
# HTTP Extension
|
||||
"HttpExtension",
|
||||
# MCP Extension
|
||||
"MCPExtension",
|
||||
# Operation Validator - Core
|
||||
"OperationValidationError",
|
||||
"OperationValidatorExtension",
|
||||
|
||||
@@ -54,6 +54,7 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
||||
HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true (optional, disable auth for MCP endpoints)
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant, implement a custom
|
||||
TenantExtension that looks up the schema based on the API key or token claims.
|
||||
@@ -64,6 +65,8 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
self.expected_api_key = config.get("api_key")
|
||||
if not self.expected_api_key:
|
||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
||||
# Allow disabling MCP auth for backwards compatibility
|
||||
self.mcp_auth_disabled = config.get("mcp_auth_disabled", "").lower() in ("true", "1", "yes")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Validate API key and return configured schema context."""
|
||||
@@ -74,3 +77,14 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return configured schema for single-tenant setup."""
|
||||
return [Tenant(schema=get_config().database_schema)]
|
||||
|
||||
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate MCP requests.
|
||||
|
||||
If mcp_auth_disabled is set, skip authentication for backwards compatibility.
|
||||
Otherwise, delegate to authenticate().
|
||||
"""
|
||||
if self.mcp_auth_disabled:
|
||||
return TenantContext(schema_name=get_config().database_schema)
|
||||
return await self.authenticate(context)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""MCP Extension for registering additional MCP tools.
|
||||
|
||||
This extension allows external packages (like hindsight-cloud) to register
|
||||
additional MCP tools on the Hindsight MCP server.
|
||||
|
||||
Example:
|
||||
HINDSIGHT_API_MCP_EXTENSION=hindsight_cloud.extensions:CloudMCPExtension
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.extensions.base import Extension
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPExtension(Extension):
|
||||
"""Base class for MCP extensions that register additional tools.
|
||||
|
||||
Subclass this to add MCP tools in extension packages.
|
||||
|
||||
Example:
|
||||
class CloudMCPExtension(MCPExtension):
|
||||
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
||||
@mcp.tool()
|
||||
async def my_custom_tool(query: str) -> str:
|
||||
return "result"
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
||||
"""Register additional MCP tools.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance to register tools on
|
||||
memory: MemoryEngine instance for accessing memory operations
|
||||
"""
|
||||
pass
|
||||
@@ -87,3 +87,22 @@ class TenantExtension(Extension, ABC):
|
||||
For single-tenant setups, return [Tenant(schema="public")].
|
||||
"""
|
||||
...
|
||||
|
||||
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate MCP requests.
|
||||
|
||||
By default, this calls authenticate(). Override this method to provide
|
||||
different authentication behavior for MCP endpoints (e.g., to disable
|
||||
auth for backwards compatibility with existing MCP servers).
|
||||
|
||||
Args:
|
||||
context: The action context containing API key and other auth data.
|
||||
|
||||
Returns:
|
||||
TenantContext with the schema_name for database operations.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
"""
|
||||
return await self.authenticate(context)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for MCPExtension loading and tool registration."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.extensions.mcp import MCPExtension
|
||||
|
||||
|
||||
class MockMCPExtension(MCPExtension):
|
||||
"""Test extension that registers a custom tool."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
super().__init__(config)
|
||||
self.register_tools_called = False
|
||||
self.registered_mcp = None
|
||||
self.registered_memory = None
|
||||
|
||||
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
||||
"""Register a test tool to verify extension was called."""
|
||||
self.register_tools_called = True
|
||||
self.registered_mcp = mcp
|
||||
self.registered_memory = memory
|
||||
|
||||
@mcp.tool()
|
||||
async def test_extension_tool(query: str) -> str:
|
||||
"""A test tool registered by the extension."""
|
||||
return f"Extension tool received: {query}"
|
||||
|
||||
|
||||
class TestMCPExtensionBase:
|
||||
"""Tests for MCPExtension base class."""
|
||||
|
||||
def test_mcp_extension_is_abstract(self):
|
||||
"""MCPExtension.register_tools is abstract and must be implemented."""
|
||||
with pytest.raises(TypeError, match="abstract method"):
|
||||
MCPExtension()
|
||||
|
||||
def test_subclass_can_be_instantiated(self):
|
||||
"""Subclass implementing register_tools can be instantiated."""
|
||||
ext = MockMCPExtension()
|
||||
assert ext is not None
|
||||
assert ext.register_tools_called is False
|
||||
|
||||
def test_register_tools_receives_mcp_and_memory(self):
|
||||
"""register_tools receives FastMCP and MemoryEngine instances."""
|
||||
ext = MockMCPExtension()
|
||||
mcp = FastMCP("test")
|
||||
memory = MagicMock(spec=MemoryEngine)
|
||||
|
||||
ext.register_tools(mcp, memory)
|
||||
|
||||
assert ext.register_tools_called is True
|
||||
assert ext.registered_mcp is mcp
|
||||
assert ext.registered_memory is memory
|
||||
|
||||
|
||||
class TestMCPExtensionLoading:
|
||||
"""Tests for MCPExtension loading in create_mcp_server."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory(self):
|
||||
"""Create a mock MemoryEngine."""
|
||||
memory = MagicMock()
|
||||
memory._tenant_extension = MagicMock()
|
||||
memory._tenant_extension.authenticate_mcp = MagicMock()
|
||||
return memory
|
||||
|
||||
def test_create_mcp_server_without_extension(self, mock_memory):
|
||||
"""create_mcp_server works without MCPExtension configured."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
with patch("hindsight_api.api.mcp.load_extension", return_value=None):
|
||||
mcp = create_mcp_server(mock_memory)
|
||||
|
||||
# Core tools should be registered
|
||||
tools = mcp._tool_manager._tools
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "reflect" in tools
|
||||
# Extension tool should NOT be present
|
||||
assert "test_extension_tool" not in tools
|
||||
|
||||
def test_create_mcp_server_with_extension(self, mock_memory):
|
||||
"""create_mcp_server loads and calls MCPExtension when configured."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
mock_ext = MockMCPExtension()
|
||||
|
||||
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
|
||||
mcp = create_mcp_server(mock_memory)
|
||||
|
||||
# Extension should have been called
|
||||
assert mock_ext.register_tools_called is True
|
||||
|
||||
# Core tools should still be registered
|
||||
tools = mcp._tool_manager._tools
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
|
||||
# Extension tool should also be registered
|
||||
assert "test_extension_tool" in tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extension_tool_is_callable(self, mock_memory):
|
||||
"""Tool registered by extension can be called."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
mock_ext = MockMCPExtension()
|
||||
|
||||
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
|
||||
mcp = create_mcp_server(mock_memory)
|
||||
|
||||
# Get and call the extension tool
|
||||
tools = mcp._tool_manager._tools
|
||||
test_tool = tools["test_extension_tool"]
|
||||
result = await test_tool.fn(query="hello world")
|
||||
|
||||
assert result == "Extension tool received: hello world"
|
||||
|
||||
def test_load_extension_called_with_correct_args(self, mock_memory):
|
||||
"""load_extension is called with 'MCP' prefix and MCPExtension class."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
with patch("hindsight_api.api.mcp.load_extension") as mock_load:
|
||||
mock_load.return_value = None
|
||||
create_mcp_server(mock_memory)
|
||||
|
||||
mock_load.assert_called_once_with("MCP", MCPExtension)
|
||||
|
||||
|
||||
class TestMCPExtensionIntegration:
|
||||
"""Integration tests verifying extension tools work end-to-end."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory(self):
|
||||
"""Create a mock MemoryEngine with required methods."""
|
||||
memory = MagicMock()
|
||||
memory.retain_batch_async = MagicMock()
|
||||
memory.submit_async_retain = MagicMock(return_value={"operation_id": "test-op"})
|
||||
memory.recall_async = MagicMock(return_value=MagicMock(results=[]))
|
||||
memory.reflect_async = MagicMock(return_value=MagicMock(text="reflection"))
|
||||
memory.list_banks = MagicMock(return_value=[])
|
||||
memory.get_bank_profile = MagicMock(return_value={"id": "test"})
|
||||
memory._tenant_extension = MagicMock()
|
||||
return memory
|
||||
|
||||
def test_extension_tools_coexist_with_core_tools(self, mock_memory):
|
||||
"""Extension tools are added alongside core tools, not replacing them."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
mock_ext = MockMCPExtension()
|
||||
|
||||
with patch("hindsight_api.api.mcp.load_extension", return_value=mock_ext):
|
||||
mcp = create_mcp_server(mock_memory)
|
||||
|
||||
tools = mcp._tool_manager._tools
|
||||
# All core tools present
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "reflect" in tools
|
||||
assert "list_banks" in tools
|
||||
assert "create_bank" in tools
|
||||
# Extension tool also present
|
||||
assert "test_extension_tool" in tools
|
||||
# Total: 5 core + 1 extension = 6 tools
|
||||
assert len(tools) == 6
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Test MCP server routing with dynamic bank_id."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension, DefaultTenantExtension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
@@ -14,6 +18,41 @@ def mock_memory():
|
||||
return memory
|
||||
|
||||
|
||||
def _make_scope(path="/mcp", headers=None):
|
||||
"""Build a minimal ASGI HTTP scope."""
|
||||
raw_headers = []
|
||||
for k, v in (headers or {}).items():
|
||||
raw_headers.append((k.lower().encode(), v.encode()))
|
||||
# MCP requires Accept header
|
||||
raw_headers.append((b"accept", b"application/json, text/event-stream"))
|
||||
raw_headers.append((b"content-type", b"application/json"))
|
||||
return {
|
||||
"type": "http",
|
||||
"path": path,
|
||||
"root_path": "",
|
||||
"headers": raw_headers,
|
||||
}
|
||||
|
||||
|
||||
async def _collect_response(middleware, scope, body=b""):
|
||||
"""Send a request through the middleware and collect the response status and body."""
|
||||
status = None
|
||||
response_body = b""
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": body}
|
||||
|
||||
async def send(message):
|
||||
nonlocal status, response_body
|
||||
if message["type"] == "http.response.start":
|
||||
status = message["status"]
|
||||
elif message["type"] == "http.response.body":
|
||||
response_body += message.get("body", b"")
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
return status, response_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_context_variable():
|
||||
"""Test that context variable works correctly."""
|
||||
@@ -141,3 +180,201 @@ async def test_mcp_tools_propagate_api_key(mock_memory):
|
||||
finally:
|
||||
_current_bank_id.reset(bank_token)
|
||||
_current_api_key.reset(api_key_token)
|
||||
|
||||
|
||||
# --- Middleware authentication tests ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_api_key_auth():
|
||||
"""Create a mock MemoryEngine with ApiKeyTenantExtension."""
|
||||
memory = MagicMock()
|
||||
memory._tenant_extension = ApiKeyTenantExtension({"api_key": "test-secret-123"})
|
||||
return memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_default_auth():
|
||||
"""Create a mock MemoryEngine with DefaultTenantExtension (no auth)."""
|
||||
memory = MagicMock()
|
||||
memory._tenant_extension = DefaultTenantExtension({})
|
||||
return memory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_middleware_rejects_no_auth(memory_with_api_key_auth):
|
||||
"""MCP middleware returns 401 when no Authorization header is provided."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
middleware = MCPMiddleware(None, memory_with_api_key_auth)
|
||||
scope = _make_scope(path="/mcp")
|
||||
status, body = await _collect_response(middleware, scope)
|
||||
|
||||
assert status == 401
|
||||
assert b"Authentication failed" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_middleware_rejects_wrong_key(memory_with_api_key_auth):
|
||||
"""MCP middleware returns 401 when an invalid API key is provided."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
middleware = MCPMiddleware(None, memory_with_api_key_auth)
|
||||
scope = _make_scope(path="/mcp", headers={"Authorization": "Bearer wrong-key"})
|
||||
status, body = await _collect_response(middleware, scope)
|
||||
|
||||
assert status == 401
|
||||
assert b"Authentication failed" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_middleware_accepts_valid_key(memory_with_api_key_auth):
|
||||
"""MCP middleware passes through when a valid API key is provided."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
middleware = MCPMiddleware(None, memory_with_api_key_auth)
|
||||
scope = _make_scope(
|
||||
path="/mcp",
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
# FastMCP raises RuntimeError because its lifespan isn't initialized in unit tests.
|
||||
# If we get that error, auth passed — the request made it past the middleware.
|
||||
with pytest.raises(RuntimeError, match="Task group is not initialized"):
|
||||
await _collect_response(
|
||||
middleware,
|
||||
scope,
|
||||
body=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_middleware_default_tenant_no_auth_required(memory_with_default_auth):
|
||||
"""MCP middleware passes through with no auth when DefaultTenantExtension is used."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
middleware = MCPMiddleware(None, memory_with_default_auth)
|
||||
scope = _make_scope(path="/mcp")
|
||||
# Same as above — RuntimeError means auth passed and request reached FastMCP internals.
|
||||
with pytest.raises(RuntimeError, match="Task group is not initialized"):
|
||||
await _collect_response(
|
||||
middleware,
|
||||
scope,
|
||||
body=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode(),
|
||||
)
|
||||
|
||||
|
||||
class MultiTenantTestExtension:
|
||||
"""Test extension that maps API keys to tenant schemas."""
|
||||
|
||||
def __init__(self, key_to_schema: dict[str, str]):
|
||||
self.key_to_schema = key_to_schema
|
||||
|
||||
async def authenticate(self, context):
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext
|
||||
|
||||
if not context.api_key:
|
||||
raise AuthenticationError("API key required")
|
||||
schema = self.key_to_schema.get(context.api_key)
|
||||
if not schema:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name=schema)
|
||||
|
||||
async def authenticate_mcp(self, context):
|
||||
"""MCP auth delegates to authenticate by default."""
|
||||
return await self.authenticate(context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_middleware_sets_schema_from_tenant_context():
|
||||
"""MCP middleware sets _current_schema from tenant context for multi-tenant isolation."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
from hindsight_api.engine.memory_engine import _current_schema
|
||||
|
||||
# Create extension that maps keys to different schemas
|
||||
tenant_ext = MultiTenantTestExtension({
|
||||
"key-for-tenant-alpha": "tenant_alpha",
|
||||
"key-for-tenant-beta": "tenant_beta",
|
||||
})
|
||||
|
||||
memory = MagicMock()
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
middleware = MCPMiddleware(None, memory)
|
||||
|
||||
# Track what schema was set during request processing
|
||||
captured_schema = None
|
||||
|
||||
# Patch the mcp_app to capture the schema instead of actually processing
|
||||
async def mock_mcp_app(scope, receive, send):
|
||||
nonlocal captured_schema
|
||||
captured_schema = _current_schema.get()
|
||||
# Send a minimal response
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"{}"})
|
||||
|
||||
middleware.mcp_app = mock_mcp_app
|
||||
|
||||
# Test tenant alpha
|
||||
scope = _make_scope(path="/mcp", headers={"Authorization": "Bearer key-for-tenant-alpha"})
|
||||
await _collect_response(middleware, scope)
|
||||
assert captured_schema == "tenant_alpha", f"Expected tenant_alpha, got {captured_schema}"
|
||||
|
||||
# Test tenant beta
|
||||
scope = _make_scope(path="/mcp", headers={"Authorization": "Bearer key-for-tenant-beta"})
|
||||
await _collect_response(middleware, scope)
|
||||
assert captured_schema == "tenant_beta", f"Expected tenant_beta, got {captured_schema}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_legacy_auth_token(monkeypatch):
|
||||
"""MCP middleware supports legacy MCP_AUTH_TOKEN for backwards compatibility."""
|
||||
import hindsight_api.api.mcp as mcp_module
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
# Set legacy auth token
|
||||
monkeypatch.setattr(mcp_module, "MCP_AUTH_TOKEN", "legacy-secret-token")
|
||||
|
||||
memory = MagicMock()
|
||||
# Even with ApiKeyTenantExtension, legacy token should work
|
||||
memory._tenant_extension = ApiKeyTenantExtension({"api_key": "different-key"})
|
||||
|
||||
middleware = MCPMiddleware(None, memory)
|
||||
|
||||
# Wrong token should fail
|
||||
scope = _make_scope(path="/mcp", headers={"Authorization": "Bearer wrong-token"})
|
||||
status, body = await _collect_response(middleware, scope)
|
||||
assert status == 401
|
||||
assert b"Invalid authentication token" in body
|
||||
|
||||
# Correct legacy token should pass
|
||||
scope = _make_scope(path="/mcp", headers={"Authorization": "Bearer legacy-secret-token"})
|
||||
with pytest.raises(RuntimeError, match="Task group is not initialized"):
|
||||
await _collect_response(
|
||||
middleware,
|
||||
scope,
|
||||
body=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_auth_disabled_flag():
|
||||
"""ApiKeyTenantExtension with mcp_auth_disabled=true skips MCP auth."""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
|
||||
memory = MagicMock()
|
||||
# Create extension with MCP auth disabled
|
||||
memory._tenant_extension = ApiKeyTenantExtension({
|
||||
"api_key": "test-secret-123",
|
||||
"mcp_auth_disabled": "true",
|
||||
})
|
||||
|
||||
middleware = MCPMiddleware(None, memory)
|
||||
|
||||
# No auth should pass when mcp_auth_disabled=true
|
||||
scope = _make_scope(path="/mcp")
|
||||
with pytest.raises(RuntimeError, match="Task group is not initialized"):
|
||||
await _collect_response(
|
||||
middleware,
|
||||
scope,
|
||||
body=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode(),
|
||||
)
|
||||
|
||||
@@ -27,44 +27,62 @@ export HINDSIGHT_API_MCP_ENABLED=false
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, the MCP endpoint is **open** for local development. For production deployments, enable authentication with a Bearer token:
|
||||
By default, the MCP endpoint is **open** (no authentication required).
|
||||
|
||||
To enable authentication, configure the API key tenant extension:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_MCP_AUTH_TOKEN=your-secret-token
|
||||
export HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
export HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
```
|
||||
|
||||
When authentication is enabled, all MCP requests must include a valid `Authorization` header:
|
||||
When authentication is enabled, include your API key in the `Authorization` header:
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http hindsight http://localhost:8888/mcp \
|
||||
--header "Authorization: Bearer your-secret-key" \
|
||||
--header "X-Bank-Id: my-bank"
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/.claude_desktop_config.json`:
|
||||
|
||||
**Claude Desktop config** (`.claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hindsight": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-http-client", "http://localhost:8888/mcp/alice/"],
|
||||
"env": {
|
||||
"HTTP_HEADERS": "{\"Authorization\": \"Bearer your-secret-token\"}"
|
||||
"url": "http://localhost:8888/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer your-secret-key",
|
||||
"X-Bank-Id": "my-bank"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Code config:**
|
||||
```bash
|
||||
claude mcp add --transport http hindsight http://localhost:8888/mcp/alice/ \
|
||||
--header "Authorization: Bearer your-secret-token"
|
||||
```
|
||||
### Direct HTTP Request
|
||||
|
||||
**Direct HTTP request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8888/mcp/alice/ \
|
||||
-H "Authorization: Bearer your-secret-token" \
|
||||
curl -X POST http://localhost:8888/mcp \
|
||||
-H "Authorization: Bearer your-secret-key" \
|
||||
-H "X-Bank-Id: my-bank" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
|
||||
```
|
||||
|
||||
If the token is missing or invalid, requests will receive a `401 Unauthorized` response.
|
||||
If the key is missing or invalid, requests will receive a `401 Unauthorized` response.
|
||||
|
||||
## Bank Selection
|
||||
|
||||
Specify the memory bank via:
|
||||
|
||||
1. **X-Bank-Id header** (recommended): `--header "X-Bank-Id: my-bank"`
|
||||
2. **URL path**: `http://localhost:8888/mcp/my-bank/`
|
||||
3. **Default**: Uses `HINDSIGHT_MCP_BANK_ID` env var (default: "default")
|
||||
|
||||
## Per-Bank Endpoints
|
||||
|
||||
@@ -149,22 +167,38 @@ Search memories to provide personalized responses.
|
||||
|
||||
---
|
||||
|
||||
## Integration with AI Assistants
|
||||
### reflect
|
||||
|
||||
The MCP server can be used with any MCP-compatible AI assistant.
|
||||
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
||||
|
||||
### Claude Desktop Configuration
|
||||
|
||||
To connect Claude Desktop to a specific memory bank:
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | Yes | The question or topic to reflect on |
|
||||
| `context` | string | No | Optional context about why this reflection is needed |
|
||||
| `budget` | string | No | Search budget: `low`, `mid`, or `high` (default: `low`) |
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hindsight-alice": {
|
||||
"url": "http://localhost:8888/mcp/alice/"
|
||||
}
|
||||
"name": "reflect",
|
||||
"arguments": {
|
||||
"query": "Based on my past decisions, what architectural style do I prefer?",
|
||||
"budget": "mid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each user can have their own MCP server configuration pointing to their personal memory bank.
|
||||
**When to use:**
|
||||
- When reasoned analysis is needed, not just fact retrieval
|
||||
- Questions like "What should I do?" rather than "What did I say?"
|
||||
- Synthesizing patterns across multiple memories
|
||||
|
||||
---
|
||||
|
||||
## Integration with AI Assistants
|
||||
|
||||
The MCP server can be used with any MCP-compatible AI assistant. See the [Authentication](#authentication) section above for Claude Code and Claude Desktop configuration examples.
|
||||
|
||||
Each user can have their own configuration pointing to their personal memory bank using either:
|
||||
- The `X-Bank-Id` header (recommended)
|
||||
- A bank-specific URL path like `/mcp/alice/`
|
||||
|
||||
Reference in New Issue
Block a user