Skip to content

Commit e407f4b

Browse files
DK09876claude
andauthored
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>
1 parent 8138fa9 commit e407f4b

File tree

5 files changed

+52
-5
lines changed

5 files changed

+52
-5
lines changed

hindsight-api/hindsight_api/api/http.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,12 @@ async def http_metrics_middleware(request, call_next):
18301830
app.include_router(extension_router, prefix="/ext", tags=["Extension"])
18311831
logging.info("HTTP extension router mounted at /ext/")
18321832

1833+
# Mount root router if provided (for well-known endpoints, etc.)
1834+
root_router = http_extension.get_root_router(memory)
1835+
if root_router:
1836+
app.include_router(root_router)
1837+
logging.info("HTTP extension root router mounted")
1838+
18331839
return app
18341840

18351841

hindsight-api/hindsight_api/api/mcp.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ async def __call__(self, scope, receive, send):
331331
auth_tenant_id = auth_context.tenant_id
332332
auth_api_key_id = auth_context.api_key_id
333333
except AuthenticationError as e:
334-
await self._send_error(send, 401, str(e))
334+
await self._send_error(send, 401, str(e), extra_headers=e.headers)
335335
return
336336

337337
# Set schema from tenant context so downstream DB queries use the correct schema
@@ -413,14 +413,17 @@ async def send_wrapper(message):
413413
if schema_token is not None:
414414
_current_schema.reset(schema_token)
415415

416-
async def _send_error(self, send, status: int, message: str):
416+
async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None):
417417
"""Send an error response."""
418418
body = json.dumps({"error": message}).encode()
419+
headers = [(b"content-type", b"application/json")]
420+
for key, value in (extra_headers or {}).items():
421+
headers.append((key.encode(), value.encode()))
419422
await send(
420423
{
421424
"type": "http.response.start",
422425
"status": status,
423-
"headers": [(b"content-type", b"application/json")],
426+
"headers": headers,
424427
}
425428
)
426429
await send(

hindsight-api/hindsight_api/extensions/http.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,15 @@ async def status():
8787
```
8888
"""
8989
pass
90+
91+
def get_root_router(self, memory: "MemoryEngine") -> APIRouter | None:
92+
"""
93+
Return a FastAPI router with endpoints mounted at the app root.
94+
95+
Unlike get_router() which is mounted at /ext/, this router is mounted
96+
directly on the application root. Use for well-known endpoints or other
97+
paths that must be at specific locations.
98+
99+
Returns None by default (no root routes). Override to provide root-level routes.
100+
"""
101+
return None

hindsight-api/hindsight_api/extensions/tenant.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
class AuthenticationError(Exception):
1212
"""Raised when authentication fails."""
1313

14-
def __init__(self, reason: str):
14+
def __init__(self, reason: str, headers: dict[str, str] | None = None):
1515
self.reason = reason
16+
self.headers = headers or {}
1617
super().__init__(f"Authentication failed: {reason}")
1718

1819

hindsight-docs/docs/developer/extensions.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT
4040

4141
Adds custom HTTP endpoints under the `/ext/` path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine.
4242

43+
Provides two router methods:
44+
- `get_router(memory)` — returns a FastAPI router mounted at `/ext/`
45+
- `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.
46+
4347
**No built-in implementation** - implement your own to add custom endpoints.
4448

4549
```bash
@@ -117,6 +121,7 @@ class JwtTenantExtension(TenantExtension):
117121
async def authenticate(self, context: RequestContext) -> TenantContext:
118122
token = context.api_key
119123
if not token:
124+
# Optional headers dict is forwarded in HTTP/MCP error responses
120125
raise AuthenticationError("Bearer token required")
121126

122127
try:
@@ -129,6 +134,15 @@ class JwtTenantExtension(TenantExtension):
129134
raise AuthenticationError(str(e))
130135
```
131136

137+
`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`:
138+
139+
```python
140+
raise AuthenticationError(
141+
"Authorization required",
142+
headers={"WWW-Authenticate": 'Bearer realm="example"'},
143+
)
144+
```
145+
132146
### Example: Custom HttpExtension
133147

134148
```python
@@ -151,9 +165,20 @@ class MyHttpExtension(HttpExtension):
151165
return {"status": "ok"}
152166

153167
return router
168+
169+
def get_root_router(self, memory: MemoryEngine) -> APIRouter | None:
170+
"""Optional: mount routes at the application root (not under /ext/)."""
171+
router = APIRouter()
172+
173+
@router.get("/.well-known/my-metadata")
174+
async def metadata():
175+
return {"version": "1.0"}
176+
177+
return router
154178
```
155179

156-
Routes are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
180+
Routes from `get_router` are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
181+
Routes from `get_root_router` are mounted at the app root (e.g., `/.well-known/my-metadata`).
157182

158183
### Example: Custom OperationValidatorExtension
159184

0 commit comments

Comments
 (0)