Compare commits

...
Author SHA1 Message Date
DK09876andClaude Opus 4.6 37cb16c8c7 Remove OAuth-specific wording from extension docs
Make the AuthenticationError headers example generic instead of
OAuth-specific, since these are general-purpose extension hooks.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-02 21:24:40 -07:00
DK09876andClaude Opus 4.6 219a647360 docs: document get_root_router and AuthenticationError.headers
Add documentation for the new extension points introduced in the
OAuth extension hooks commit.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-02 11:04:36 -07:00
DK09876andClaude Opus 4.6 89c776d946 feat: add OAuth extension hooks for MCP authentication
Add extension points in core that allow cloud extensions to support
OAuth 2.1 (RFC 9728 / RFC 7591) for MCP server authentication:

- HttpExtension.get_root_router() for well-known endpoint mounting
- AuthenticationError.headers for WWW-Authenticate propagation
- MCP middleware forwards auth error headers to clients

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-02 10:34:48 -07:00
5 changed files with 52 additions and 5 deletions
+6
View File
@@ -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
+6 -3
View File
@@ -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(
@@ -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
@@ -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}")
+26 -1
View File
@@ -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