Compare commits

...
Author SHA1 Message Date
DK09876andClaude Opus 4.5 d7fe1b2102 Format mcp.py line length
Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 11:14:19 -07:00
DK09876andClaude Opus 4.5 81cec70d5f Address PR review feedback
- Remove CloudTenantExtension mention from MCPMiddleware docstring
- Fix docs: clarify that ApiKeyTenantExtension must be explicitly enabled
- Revert changes to versioned docs (0.3 and 0.4) - synced automatically on release

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 4c38e21882 Move search_docs to hindsight-cloud, add MCPExtension pattern
- Add MCPExtension base class for registering additional MCP tools
- Load MCPExtension in create_mcp_server when configured
- Remove search_docs tool (moved to hindsight-cloud CloudMCPExtension)
- Remove Vectorize config from hindsight-core
- Add tests for MCPExtension pattern
- Update docs to remove search_docs references

The MCPExtension pattern allows cloud (or any extension package) to
register additional MCP tools via:
  HINDSIGHT_API_MCP_EXTENSION=package.module:ExtensionClass

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 9eb7ed0055 Add tests for search_docs MCP tool
Tests cover:
- DocsSource enum values and parsing
- _clean_text HTML stripping helper
- _search_vectorize_pipeline with mocked httpx
- Tool registration and function execution
- Source filtering (core/cloud/all)
- Result sorting by similarity
- Error handling for pipeline failures
- HTML cleaning in results
- Invalid source defaulting to 'all'

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 2a41d84d4e Add documentation for search_docs MCP tool
- Add Vectorize environment variables to configuration.md
- Add search_docs tool to MCP server available tools
- Add reflect tool documentation (was missing)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 df1e76430d Add search_docs MCP tool for documentation search
Implements a new MCP tool that searches Hindsight documentation using
Vectorize RAG pipelines. The tool supports:
- Searching core (OSS) docs, cloud docs, or both
- Configurable number of results (1-10)
- Returns ranked results with URLs, similarity scores, and text snippets

New environment variables:
- HINDSIGHT_API_VECTORIZE_ORG_ID
- HINDSIGHT_API_VECTORIZE_API_TOKEN
- HINDSIGHT_API_VECTORIZE_CORE_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_CLOUD_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_API_BASE_URL

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 749670680b Address PR review: backwards compatibility for MCP auth
- Keep MCP_AUTH_TOKEN env var for legacy MCP servers
- Add authenticate_mcp() method to TenantExtension base class
  - Default implementation calls authenticate()
  - Extensions can override to opt-out of MCP auth
- Add mcp_auth_disabled config option to ApiKeyTenantExtension
  - Set HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true to skip MCP auth
- Remove CloudTenantExtension from public docstring
- Add tests for legacy auth token and mcp_auth_disabled flag
- Update MCP docs with new auth configuration

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
DK09876andClaude Opus 4.5 26ce460f9d feat: add TenantExtension auth to MCP endpoint
Replace static MCP_AUTH_TOKEN check with TenantExtension authentication,
making MCP use the same auth path as REST API.

- MCPMiddleware now calls tenant_extension.authenticate()
- Sets _current_schema from TenantContext for multi-tenant isolation
- Returns 401 on AuthenticationError (same as REST API)
- DefaultTenantExtension: no auth (local dev)
- ApiKeyTenantExtension: validates against env var
- CloudTenantExtension: HMAC + DB lookup (production)

Adds tests for middleware auth rejection, acceptance, and schema routing.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-06 10:48:19 -07:00
8 changed files with 582 additions and 33 deletions
+37 -6
View File
@@ -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)
+169
View File
@@ -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
+237
View File
@@ -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(),
)
+61 -27
View File
@@ -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/`