Add OAuth-protected MCP support for standalone Hyperforge#61
Add OAuth-protected MCP support for standalone Hyperforge#61arnaugrispg wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds optional OAuth/JWT-based protection for standalone Hyperforge MCP endpoints, including protected-resource metadata discovery and bearer token validation against a configured JWKS, with tests validating expected 401/200 behaviors and optional Authorization passthrough.
Changes:
- Introduces standalone OAuth/JWT validation (JWKS fetch/cache, JWT signature + claims + scope checks) and wires it into standalone auth middleware for MCP routes.
- Adds per-agent
mcp_authconfiguration and uses it to emit RFC9728 protected-resource metadata and control Authorization header forwarding. - Adds standalone test coverage for protected vs open MCP behavior, metadata exposure, JWT failures, and Authorization passthrough control.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| hyperforge/tests/standalone/test_mcp_auth.py | Adds standalone MCP OAuth/authn test suite (metadata, 401s, JWT validation, header passthrough). |
| hyperforge/src/hyperforge/standalone/oauth.py | Implements JWKS caching and JWT bearer validation (signature/claims/scopes). |
| hyperforge/src/hyperforge/standalone/config.py | Adds StandaloneMCPAuthConfig and mcp_auth per-agent configuration. |
| hyperforge/src/hyperforge/standalone/app.py | Replaces open auth backend with standalone backend that enforces MCP OAuth when configured + emits proper 401 metadata. |
| hyperforge/src/hyperforge/api/v1/mcp_interaction.py | Adds protected-resource metadata endpoint config override and optional Authorization forwarding control into interaction headers. |
| hyperforge/src/hyperforge/api/v1/interaction.py | Updates ensure_session_exists to return effective session id + error tuple; updates websocket call site. |
| hyperforge/src/hyperforge/api/session.py | Adds UUID pre-check to session_exists before calling NucliaDB by id. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| auth_config = get_enabled_mcp_auth(getattr(app, "_agents_cfg", {}), agent_id) | ||
| resource = ( | ||
| auth_config.protected_resource | ||
| if auth_config is not None and auth_config.protected_resource is not None | ||
| else str(mcp_url.replace(scheme="https")) | ||
| ) | ||
| fallback_authorization_server = getattr(app.settings, "hydra_public_url", None) | ||
| authorization_servers = ( | ||
| [auth_config.authorization_server] | ||
| if auth_config is not None and auth_config.authorization_server is not None | ||
| else ( | ||
| [fallback_authorization_server] | ||
| if fallback_authorization_server is not None | ||
| else [] | ||
| ) | ||
| ) | ||
| scopes_supported = ( | ||
| auth_config.scopes_supported | ||
| if auth_config is not None | ||
| else getattr(app.settings, "hydra_scopes_supported", []) | ||
| ) | ||
| return { | ||
| "resource": mcp_url_https, | ||
| "scopes_supported": app.settings.hydra_scopes_supported, | ||
| "authorization_servers": [app.settings.hydra_public_url], | ||
| "resource": resource, | ||
| "scopes_supported": scopes_supported, | ||
| "authorization_servers": authorization_servers, |
There was a problem hiding this comment.
any reason to be using getattr instead of accessing the fields normally? getattr bypasses type checking and might be a source for errors
There was a problem hiding this comment.
I used getattr to avoid conflicts between standalone and non-standalone settings, but I agree it’s better not to use it here, so I replaced it with a dedicated helper and removed the getattr usage.
| missing = [ | ||
| field | ||
| for field in ("authorization_server", "jwks_url") | ||
| if getattr(self, field) is None |
There was a problem hiding this comment.
another getattr here
| app: "HTTPApplication", agent_id: str, headers: Headers | ||
| ) -> dict[str, str]: | ||
| interaction_headers = dict(headers.items()) | ||
| auth_config = get_enabled_mcp_auth(getattr(app, "_agents_cfg", {}), agent_id) |
There was a problem hiding this comment.
Why a getattr to check it ? could be less hacky ?
| auth_config = get_enabled_mcp_auth(getattr(app, "_agents_cfg", {}), agent_id) | ||
|
|
||
| authorization = headers.get("authorization") | ||
| if auth_config is not None and not auth_config.forward_authorization_header: |
There was a problem hiding this comment.
Which use case covers this condition?
There was a problem hiding this comment.
This condition was added to support using the token only for MCP auth, but this MCP will never have auth on its own, so I’m removing this use case.
| if auth_config is not None and auth_config.protected_resource is not None | ||
| else str(mcp_url.replace(scheme="https")) | ||
| ) | ||
| fallback_authorization_server = getattr(app.settings, "hydra_public_url", None) |
| scopes_supported = ( | ||
| auth_config.scopes_supported | ||
| if auth_config is not None | ||
| else getattr(app.settings, "hydra_scopes_supported", []) |
Description
Adds optional OAuth configuration for standalone Hyperforge MCP endpoints.
When configured, Hyperforge now:
401with the proper resource metadata reference when MCP requests are missing or have invalid bearer tokens.exp) and rejects tokens when JWKS validation fails.Authorizationheader to the inner MCP/service backend, enabling use cases like MarkLogic OAuth passthrough.Includes focused standalone MCP auth tests covering metadata exposure, unauthorized requests, valid token passthrough, missing
exp, and JWKS failure handling.