From fedfb494ee90ca97b4ceab8dcc4e466f75629b44 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Fri, 6 Feb 2026 12:28:05 -0700 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * Format mcp.py line length Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- hindsight-api/hindsight_api/api/mcp.py | 43 +++- .../hindsight_api/extensions/__init__.py | 3 + .../extensions/builtin/tenant.py | 14 ++ hindsight-api/hindsight_api/extensions/mcp.py | 42 ++++ .../hindsight_api/extensions/tenant.py | 19 ++ hindsight-api/tests/test_mcp_extension.py | 169 +++++++++++++ hindsight-api/tests/test_mcp_routing.py | 237 ++++++++++++++++++ hindsight-docs/docs/developer/mcp-server.md | 88 +++++-- 8 files changed, 582 insertions(+), 33 deletions(-) create mode 100644 hindsight-api/hindsight_api/extensions/mcp.py create mode 100644 hindsight-api/tests/test_mcp_extension.py diff --git a/hindsight-api/hindsight_api/api/mcp.py b/hindsight-api/hindsight_api/api/mcp.py index c2d0a71a..d39e0f7a 100644 --- a/hindsight-api/hindsight_api/api/mcp.py +++ b/hindsight-api/hindsight_api/api/mcp.py @@ -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 " 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" diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py index c5752831..7a6662c6 100644 --- a/hindsight-api/hindsight_api/extensions/__init__.py +++ b/hindsight-api/hindsight_api/extensions/__init__.py @@ -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", diff --git a/hindsight-api/hindsight_api/extensions/builtin/tenant.py b/hindsight-api/hindsight_api/extensions/builtin/tenant.py index 625946b3..c2ab3f89 100644 --- a/hindsight-api/hindsight_api/extensions/builtin/tenant.py +++ b/hindsight-api/hindsight_api/extensions/builtin/tenant.py @@ -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) diff --git a/hindsight-api/hindsight_api/extensions/mcp.py b/hindsight-api/hindsight_api/extensions/mcp.py new file mode 100644 index 00000000..280e1241 --- /dev/null +++ b/hindsight-api/hindsight_api/extensions/mcp.py @@ -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 diff --git a/hindsight-api/hindsight_api/extensions/tenant.py b/hindsight-api/hindsight_api/extensions/tenant.py index 75dc0dfa..689237e5 100644 --- a/hindsight-api/hindsight_api/extensions/tenant.py +++ b/hindsight-api/hindsight_api/extensions/tenant.py @@ -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) diff --git a/hindsight-api/tests/test_mcp_extension.py b/hindsight-api/tests/test_mcp_extension.py new file mode 100644 index 00000000..f69013cc --- /dev/null +++ b/hindsight-api/tests/test_mcp_extension.py @@ -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 diff --git a/hindsight-api/tests/test_mcp_routing.py b/hindsight-api/tests/test_mcp_routing.py index fb5676cd..62601ef7 100644 --- a/hindsight-api/tests/test_mcp_routing.py +++ b/hindsight-api/tests/test_mcp_routing.py @@ -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(), + ) diff --git a/hindsight-docs/docs/developer/mcp-server.md b/hindsight-docs/docs/developer/mcp-server.md index e512e2e3..6e920e6b 100644 --- a/hindsight-docs/docs/developer/mcp-server.md +++ b/hindsight-docs/docs/developer/mcp-server.md @@ -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/`