feat: add extension hooks for root routing and error headers (#470)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8138fa9002
commit
e407f4bc55
5 changed files with 52 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue