fleet-memory/hindsight-api/tests/test_mcp_routing.py
DK09876 fedfb494ee
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>
2026-02-06 12:28:05 -07:00

380 lines
13 KiB
Python

"""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():
"""Create a mock MemoryEngine."""
memory = MagicMock()
memory.retain_batch_async = AsyncMock()
memory.submit_async_retain = AsyncMock(return_value={"operation_id": "test-op-123"})
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
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."""
from hindsight_api.api.mcp import get_current_bank_id, _current_bank_id
# Initially None
assert get_current_bank_id() is None
# Set and verify
token = _current_bank_id.set("test-bank-123")
try:
assert get_current_bank_id() == "test-bank-123"
finally:
_current_bank_id.reset(token)
# Back to None after reset
assert get_current_bank_id() is None
@pytest.mark.asyncio
async def test_mcp_tools_use_context_bank_id(mock_memory):
"""Test that MCP tools use bank_id from context."""
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id
mcp_server = create_mcp_server(mock_memory)
# Get the tools
tools = mcp_server._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
# Test retain with bank_id from context (use async_processing=False for synchronous test)
token = _current_bank_id.set("context-bank-id")
try:
retain_tool = tools["retain"]
result = await retain_tool.fn(content="test content", context="test_context", async_processing=False)
assert "successfully" in result.lower()
# Verify the memory was called with the context bank_id
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["bank_id"] == "context-bank-id"
finally:
_current_bank_id.reset(token)
def test_path_parsing_logic():
"""Test the path parsing logic for bank_id extraction."""
def parse_path(path):
"""Simulate the path parsing logic from MCPMiddleware."""
if not path.startswith("/") or len(path) <= 1:
return None, None # Error case
parts = path[1:].split("/", 1)
if not parts[0]:
return None, None # Error case
bank_id = parts[0]
new_path = "/" + parts[1] if len(parts) > 1 else "/"
return bank_id, new_path
# Test bank-specific paths
bank_id, remaining = parse_path("/my-bank/")
assert bank_id == "my-bank"
assert remaining == "/"
bank_id, remaining = parse_path("/my-bank")
assert bank_id == "my-bank"
assert remaining == "/"
# Test error case - no bank_id
bank_id, remaining = parse_path("/")
assert bank_id is None
# Test with complex bank_id
bank_id, remaining = parse_path("/user_12345/")
assert bank_id == "user_12345"
assert remaining == "/"
# Test with additional path after bank_id
bank_id, remaining = parse_path("/my-bank/some/path")
assert bank_id == "my-bank"
assert remaining == "/some/path"
@pytest.mark.asyncio
async def test_api_key_context_variable():
"""Test that API key context variable works correctly."""
from hindsight_api.api.mcp import get_current_api_key, _current_api_key
# Initially None
assert get_current_api_key() is None
# Set and verify
token = _current_api_key.set("test-api-key-123")
try:
assert get_current_api_key() == "test-api-key-123"
finally:
_current_api_key.reset(token)
# Back to None after reset
assert get_current_api_key() is None
@pytest.mark.asyncio
async def test_mcp_tools_propagate_api_key(mock_memory):
"""Test that MCP tools propagate API key to RequestContext."""
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id, _current_api_key
mcp_server = create_mcp_server(mock_memory)
tools = mcp_server._tool_manager._tools
# Set both bank_id and api_key context
bank_token = _current_bank_id.set("test-bank")
api_key_token = _current_api_key.set("test-bearer-token")
try:
retain_tool = tools["retain"]
result = await retain_tool.fn(content="test content", context="test_context", async_processing=False)
assert "successfully" in result.lower()
# Verify the memory was called with request_context containing api_key
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["request_context"].api_key == "test-bearer-token"
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(),
)