diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index 59e5961f..49d7442f 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -1830,6 +1830,12 @@ def create_app( app.include_router(extension_router, prefix="/ext", tags=["Extension"]) logging.info("HTTP extension router mounted at /ext/") + # Mount root router if provided (for well-known endpoints, etc.) + root_router = http_extension.get_root_router(memory) + if root_router: + app.include_router(root_router) + logging.info("HTTP extension root router mounted") + return app diff --git a/hindsight-api/hindsight_api/api/mcp.py b/hindsight-api/hindsight_api/api/mcp.py index 8a029f22..2eef9e25 100644 --- a/hindsight-api/hindsight_api/api/mcp.py +++ b/hindsight-api/hindsight_api/api/mcp.py @@ -331,7 +331,7 @@ class MCPMiddleware: auth_tenant_id = auth_context.tenant_id auth_api_key_id = auth_context.api_key_id except AuthenticationError as e: - await self._send_error(send, 401, str(e)) + await self._send_error(send, 401, str(e), extra_headers=e.headers) return # Set schema from tenant context so downstream DB queries use the correct schema @@ -413,14 +413,17 @@ class MCPMiddleware: 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, extra_headers: dict[str, str] | None = None): """Send an error response.""" body = json.dumps({"error": message}).encode() + headers = [(b"content-type", b"application/json")] + for key, value in (extra_headers or {}).items(): + headers.append((key.encode(), value.encode())) await send( { "type": "http.response.start", "status": status, - "headers": [(b"content-type", b"application/json")], + "headers": headers, } ) await send( diff --git a/hindsight-api/hindsight_api/extensions/http.py b/hindsight-api/hindsight_api/extensions/http.py index a64816f7..629183d3 100644 --- a/hindsight-api/hindsight_api/extensions/http.py +++ b/hindsight-api/hindsight_api/extensions/http.py @@ -87,3 +87,15 @@ class HttpExtension(Extension, ABC): ``` """ pass + + def get_root_router(self, memory: "MemoryEngine") -> APIRouter | None: + """ + Return a FastAPI router with endpoints mounted at the app root. + + Unlike get_router() which is mounted at /ext/, this router is mounted + directly on the application root. Use for well-known endpoints or other + paths that must be at specific locations. + + Returns None by default (no root routes). Override to provide root-level routes. + """ + return None diff --git a/hindsight-api/hindsight_api/extensions/tenant.py b/hindsight-api/hindsight_api/extensions/tenant.py index cc55ebd3..936f02fa 100644 --- a/hindsight-api/hindsight_api/extensions/tenant.py +++ b/hindsight-api/hindsight_api/extensions/tenant.py @@ -11,8 +11,9 @@ from hindsight_api.models import RequestContext class AuthenticationError(Exception): """Raised when authentication fails.""" - def __init__(self, reason: str): + def __init__(self, reason: str, headers: dict[str, str] | None = None): self.reason = reason + self.headers = headers or {} super().__init__(f"Authentication failed: {reason}") diff --git a/hindsight-docs/docs/developer/extensions.md b/hindsight-docs/docs/developer/extensions.md index 3264fe2e..ba8ef011 100644 --- a/hindsight-docs/docs/developer/extensions.md +++ b/hindsight-docs/docs/developer/extensions.md @@ -40,6 +40,10 @@ For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT Adds custom HTTP endpoints under the `/ext/` path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine. +Provides two router methods: +- `get_router(memory)` — returns a FastAPI router mounted at `/ext/` +- `get_root_router(memory)` — returns a FastAPI router mounted at the application root (for well-known endpoints or other paths that must be at specific locations). Returns `None` by default. + **No built-in implementation** - implement your own to add custom endpoints. ```bash @@ -117,6 +121,7 @@ class JwtTenantExtension(TenantExtension): async def authenticate(self, context: RequestContext) -> TenantContext: token = context.api_key if not token: + # Optional headers dict is forwarded in HTTP/MCP error responses raise AuthenticationError("Bearer token required") try: @@ -129,6 +134,15 @@ class JwtTenantExtension(TenantExtension): raise AuthenticationError(str(e)) ``` +`AuthenticationError` accepts an optional `headers` dict that is forwarded in both HTTP and MCP error responses. This is useful for returning custom headers like `WWW-Authenticate`: + +```python +raise AuthenticationError( + "Authorization required", + headers={"WWW-Authenticate": 'Bearer realm="example"'}, +) +``` + ### Example: Custom HttpExtension ```python @@ -151,9 +165,20 @@ class MyHttpExtension(HttpExtension): return {"status": "ok"} return router + + def get_root_router(self, memory: MemoryEngine) -> APIRouter | None: + """Optional: mount routes at the application root (not under /ext/).""" + router = APIRouter() + + @router.get("/.well-known/my-metadata") + async def metadata(): + return {"version": "1.0"} + + return router ``` -Routes are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc. +Routes from `get_router` are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc. +Routes from `get_root_router` are mounted at the app root (e.g., `/.well-known/my-metadata`). ### Example: Custom OperationValidatorExtension