Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions hindsight-api/hindsight_api/api/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from fastmcp import FastMCP

from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import _current_schema
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()
Expand All @@ -29,7 +32,8 @@
# 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
Expand Down Expand Up @@ -80,8 +84,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 (can disable MCP auth)

Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
Expand All @@ -96,6 +102,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
Expand All @@ -121,14 +128,28 @@ async def __call__(self, scope, receive, send):
# 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", "")

Expand Down Expand Up @@ -189,6 +210,8 @@ async def send_wrapper(message):
_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."""
Expand All @@ -213,8 +236,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"
Expand Down
14 changes: 14 additions & 0 deletions hindsight-api/hindsight_api/extensions/builtin/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -64,6 +65,8 @@ def __init__(self, config: dict[str, str]):
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."""
Expand All @@ -74,3 +77,14 @@ async def authenticate(self, context: RequestContext) -> TenantContext:
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)
19 changes: 19 additions & 0 deletions hindsight-api/hindsight_api/extensions/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,22 @@ async def list_tenants(self) -> list[Tenant]:
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)
Loading
Loading