feat: add TenantExtension auth to MCP endpoint (#286)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Format mcp.py line length Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0430588e32
commit
fedfb494ee
8 changed files with 582 additions and 33 deletions
|
|
@ -8,7 +8,11 @@ from contextvars import ContextVar
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from hindsight_api import MemoryEngine
|
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.mcp_tools import MCPToolsConfig, register_mcp_tools
|
||||||
|
from hindsight_api.models import RequestContext
|
||||||
|
|
||||||
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
|
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
|
||||||
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
|
_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 from environment variable
|
||||||
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
|
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")
|
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
|
||||||
|
|
||||||
# Context variable to hold the current bank_id
|
# 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)
|
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
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,8 +91,10 @@ class MCPMiddleware:
|
||||||
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
|
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
|
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
|
||||||
Authorization header with Bearer token or direct token matching the configured value.
|
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:
|
Bank ID can be provided via:
|
||||||
1. X-Bank-Id header (recommended for Claude Code)
|
1. X-Bank-Id header (recommended for Claude Code)
|
||||||
|
|
@ -96,6 +109,7 @@ class MCPMiddleware:
|
||||||
def __init__(self, app, memory: MemoryEngine):
|
def __init__(self, app, memory: MemoryEngine):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.memory = memory
|
self.memory = memory
|
||||||
|
self.tenant_extension = memory._tenant_extension
|
||||||
self.mcp_server = create_mcp_server(memory)
|
self.mcp_server = create_mcp_server(memory)
|
||||||
self.mcp_app = self.mcp_server.http_app(path="/")
|
self.mcp_app = self.mcp_server.http_app(path="/")
|
||||||
# Expose the lifespan for the parent app to chain
|
# Expose the lifespan for the parent app to chain
|
||||||
|
|
@ -121,14 +135,30 @@ class MCPMiddleware:
|
||||||
# Support both "Bearer <token>" and direct token
|
# Support both "Bearer <token>" and direct token
|
||||||
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
|
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:
|
if MCP_AUTH_TOKEN:
|
||||||
|
# Legacy authentication mode - validate against static token
|
||||||
if not auth_token:
|
if not auth_token:
|
||||||
await self._send_error(send, 401, "Authorization header required")
|
await self._send_error(send, 401, "Authorization header required")
|
||||||
return
|
return
|
||||||
if auth_token != MCP_AUTH_TOKEN:
|
if auth_token != MCP_AUTH_TOKEN:
|
||||||
await self._send_error(send, 401, "Invalid authentication token")
|
await self._send_error(send, 401, "Invalid authentication token")
|
||||||
return
|
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", "")
|
path = scope.get("path", "")
|
||||||
|
|
||||||
|
|
@ -189,6 +219,8 @@ class MCPMiddleware:
|
||||||
_current_bank_id.reset(bank_id_token)
|
_current_bank_id.reset(bank_id_token)
|
||||||
if api_key_token is not None:
|
if api_key_token is not None:
|
||||||
_current_api_key.reset(api_key_token)
|
_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):
|
async def _send_error(self, send, status: int, message: str):
|
||||||
"""Send an error response."""
|
"""Send an error response."""
|
||||||
|
|
@ -213,8 +245,7 @@ def create_mcp_app(memory: MemoryEngine):
|
||||||
Create an ASGI app that handles MCP requests.
|
Create an ASGI app that handles MCP requests.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
|
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
|
||||||
If not set, MCP endpoint is open (for local development).
|
|
||||||
|
|
||||||
Bank ID can be provided via:
|
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"
|
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.context import DefaultExtensionContext, ExtensionContext
|
||||||
from hindsight_api.extensions.http import HttpExtension
|
from hindsight_api.extensions.http import HttpExtension
|
||||||
from hindsight_api.extensions.loader import load_extension
|
from hindsight_api.extensions.loader import load_extension
|
||||||
|
from hindsight_api.extensions.mcp import MCPExtension
|
||||||
from hindsight_api.extensions.operation_validator import (
|
from hindsight_api.extensions.operation_validator import (
|
||||||
# Consolidation operation
|
# Consolidation operation
|
||||||
ConsolidateContext,
|
ConsolidateContext,
|
||||||
|
|
@ -57,6 +58,8 @@ __all__ = [
|
||||||
"DefaultExtensionContext",
|
"DefaultExtensionContext",
|
||||||
# HTTP Extension
|
# HTTP Extension
|
||||||
"HttpExtension",
|
"HttpExtension",
|
||||||
|
# MCP Extension
|
||||||
|
"MCPExtension",
|
||||||
# Operation Validator - Core
|
# Operation Validator - Core
|
||||||
"OperationValidationError",
|
"OperationValidationError",
|
||||||
"OperationValidatorExtension",
|
"OperationValidatorExtension",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
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
|
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.
|
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")
|
self.expected_api_key = config.get("api_key")
|
||||||
if not self.expected_api_key:
|
if not self.expected_api_key:
|
||||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
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:
|
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||||
"""Validate API key and return configured schema context."""
|
"""Validate API key and return configured schema context."""
|
||||||
|
|
@ -74,3 +77,14 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||||
async def list_tenants(self) -> list[Tenant]:
|
async def list_tenants(self) -> list[Tenant]:
|
||||||
"""Return configured schema for single-tenant setup."""
|
"""Return configured schema for single-tenant setup."""
|
||||||
return [Tenant(schema=get_config().database_schema)]
|
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)
|
||||||
|
|
|
||||||
42
hindsight-api/hindsight_api/extensions/mcp.py
Normal file
42
hindsight-api/hindsight_api/extensions/mcp.py
Normal file
|
|
@ -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")].
|
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
hindsight-api/tests/test_mcp_extension.py
Normal file
169
hindsight-api/tests/test_mcp_extension.py
Normal 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
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"""Test MCP server routing with dynamic bank_id."""
|
"""Test MCP server routing with dynamic bank_id."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension, DefaultTenantExtension
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_memory():
|
def mock_memory():
|
||||||
|
|
@ -14,6 +18,41 @@ def mock_memory():
|
||||||
return 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
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_context_variable():
|
async def test_mcp_context_variable():
|
||||||
"""Test that context variable works correctly."""
|
"""Test that context variable works correctly."""
|
||||||
|
|
@ -141,3 +180,201 @@ async def test_mcp_tools_propagate_api_key(mock_memory):
|
||||||
finally:
|
finally:
|
||||||
_current_bank_id.reset(bank_token)
|
_current_bank_id.reset(bank_token)
|
||||||
_current_api_key.reset(api_key_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
|
## 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
|
```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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"hindsight": {
|
"hindsight": {
|
||||||
"command": "npx",
|
"url": "http://localhost:8888/mcp",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-http-client", "http://localhost:8888/mcp/alice/"],
|
"headers": {
|
||||||
"env": {
|
"Authorization": "Bearer your-secret-key",
|
||||||
"HTTP_HEADERS": "{\"Authorization\": \"Bearer your-secret-token\"}"
|
"X-Bank-Id": "my-bank"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Claude Code config:**
|
### Direct HTTP Request
|
||||||
```bash
|
|
||||||
claude mcp add --transport http hindsight http://localhost:8888/mcp/alice/ \
|
|
||||||
--header "Authorization: Bearer your-secret-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Direct HTTP request:**
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8888/mcp/alice/ \
|
curl -X POST http://localhost:8888/mcp \
|
||||||
-H "Authorization: Bearer your-secret-token" \
|
-H "Authorization: Bearer your-secret-key" \
|
||||||
|
-H "X-Bank-Id: my-bank" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
|
-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
|
## 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
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
To connect Claude Desktop to a specific memory bank:
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"name": "reflect",
|
||||||
"hindsight-alice": {
|
"arguments": {
|
||||||
"url": "http://localhost:8888/mcp/alice/"
|
"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/`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue