From 95e2fa43b1875a418a5f6ef57b9727def09ef86e Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Thu, 9 Jul 2026 14:31:32 +0200 Subject: [PATCH 01/12] Add OAuth-protected MCP support for standalone Hyperforge --- .../src/hyperforge/api/v1/mcp_interaction.py | 45 ++- hyperforge/src/hyperforge/standalone/app.py | 90 +++++- .../src/hyperforge/standalone/config.py | 18 ++ hyperforge/src/hyperforge/standalone/oauth.py | 189 ++++++++++++ hyperforge/tests/standalone/test_mcp_auth.py | 285 ++++++++++++++++++ 5 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 hyperforge/src/hyperforge/standalone/oauth.py create mode 100644 hyperforge/tests/standalone/test_mcp_auth.py diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py index 44ead98..bba8916 100644 --- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py +++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py @@ -38,6 +38,7 @@ from hyperforge.interaction import AnswerOperation from hyperforge.prompts import PromptConfig from hyperforge.pubsub import UserToAgentInteraction +from hyperforge.standalone.oauth import get_enabled_mcp_auth from hyperforge.workflows import WorkflowData if TYPE_CHECKING: @@ -93,9 +94,10 @@ async def call_tool( raise ResourceError(f"Missing required parameter: {parameter}") question = f"Calling tool: {workflow.description or workflow.name} with arguments: {arguments}" + interaction_headers = _prepare_interaction_headers(app, agent_id, headers) interaction = InteractionRequest( - question=question, headers=dict(headers.items()), arguments=arguments + question=question, headers=interaction_headers, arguments=arguments ) mcp_session = mcp_server.request_context.session websocket = WebsocketReceiver(websocket=None) @@ -167,6 +169,22 @@ async def read_resource( raise ResourceError(f"Unknown uri: {uri}") +def _prepare_interaction_headers( + 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) + + authorization = headers.get("authorization") + if auth_config is not None and not auth_config.forward_authorization_header: + interaction_headers.pop("authorization", None) + interaction_headers.pop("Authorization", None) + elif authorization is not None: + interaction_headers["authorization"] = authorization + + return interaction_headers + + @router.get( "/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp" ) @@ -183,13 +201,26 @@ async def mcp_interaction_protected_resource_metadata( mcp_url = request.url_for( "interaction_mcp_handler", agent_id=agent_id, session=session ) - mcp_url_https = str(mcp_url).replace( - "http://", "https://" - ) # Ensure the URL uses https + 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("http://", "https://") + ) + authorization_servers = ( + [auth_config.authorization_server] + if auth_config is not None and auth_config.authorization_server is not None + else [app.settings.hydra_public_url] + ) + scopes_supported = ( + auth_config.scopes_supported + if auth_config is not None + else 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, } diff --git a/hyperforge/src/hyperforge/standalone/app.py b/hyperforge/src/hyperforge/standalone/app.py index be303fe..0bd358c 100644 --- a/hyperforge/src/hyperforge/standalone/app.py +++ b/hyperforge/src/hyperforge/standalone/app.py @@ -21,7 +21,12 @@ from nucliadb_telemetry.settings import LogLevel, LogSettings from prometheus_client import CONTENT_TYPE_LATEST # type: ignore from redis.asyncio import Redis -from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser +from starlette.authentication import ( + AuthCredentials, + AuthenticationBackend, + AuthenticationError, + BaseUser, +) from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import HTTPConnection from starlette.responses import PlainTextResponse @@ -37,6 +42,11 @@ from hyperforge.server.cache import InMemoryCache, ValkeyCache from hyperforge.server.session import SessionManager from hyperforge.server.settings import Settings as ServerSettings +from hyperforge.standalone.oauth import ( + JWKSCache, + get_enabled_mcp_auth, + validate_mcp_bearer, +) from hyperforge.standalone.settings import StandaloneSettings from hyperforge.standalone.ui_router import router as ui_router @@ -96,13 +106,27 @@ async def health_alive(): ) -class OpenAuthBackend(AuthenticationBackend): - """Authentication backend that accepts every request as a local user with - all roles. Only used in the standalone single-process deployment.""" +class StandaloneAuthBackend(AuthenticationBackend): + """Open standalone auth, with optional OAuth protection for MCP endpoints.""" + + def __init__(self, agents_cfg: dict[str, Any]) -> None: + self._agents_cfg = agents_cfg + self._jwks_cache = JWKSCache() async def authenticate( self, conn: HTTPConnection ) -> tuple[AuthCredentials, BaseUser] | None: + agent_id = self._mcp_agent_id(conn) + if agent_id is not None: + auth_config = get_enabled_mcp_auth(self._agents_cfg, agent_id) + if auth_config is not None: + conn.scope["hyperforge.mcp_auth_config"] = auth_config + await validate_mcp_bearer( + conn.headers.get("authorization"), + auth_config, + self._jwks_cache, + ) + # Inject the headers that interaction endpoints declare as required # FastAPI Header() dependencies, so they don't get rejected. if "x-stf-account" not in conn.headers: @@ -123,6 +147,58 @@ async def authenticate( ] return _ALL_ROLES, User(username="standalone") + def _mcp_agent_id(self, conn: HTTPConnection) -> str | None: + parts = conn.url.path.strip("/").split("/") + if ( + len(parts) == 7 + and parts[:3] == ["api", "v1", "agent"] + and parts[4] == "session" + and parts[6] == "mcp" + ): + return parts[3] + return None + + +def standalone_auth_error( + conn: HTTPConnection, exc: AuthenticationError +) -> PlainTextResponse: + authenticate = "Bearer" + metadata_url = _mcp_resource_metadata_url(conn) + if metadata_url is not None: + authenticate = f'Bearer resource_metadata="{metadata_url}"' + return PlainTextResponse( + str(exc) or "Unauthorized", + status_code=401, + headers={"WWW-Authenticate": authenticate}, + ) + + +def _mcp_resource_metadata_url(conn: HTTPConnection) -> str | None: + auth_config = conn.scope.get("hyperforge.mcp_auth_config") + if ( + auth_config is not None + and auth_config.protected_resource_metadata_url is not None + ): + return auth_config.protected_resource_metadata_url + + parts = conn.url.path.strip("/").split("/") + if ( + len(parts) == 7 + and parts[:3] == ["api", "v1", "agent"] + and parts[4] == "session" + and parts[6] == "mcp" + ): + agent_id = parts[3] + session = parts[5] + return str( + conn.url.replace( + path=f"/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp", + query="", + fragment="", + ) + ) + return None + # --------------------------------------------------------------------------- # Application @@ -173,7 +249,11 @@ async def lifespan(app: "StandaloneApplication"): name="frontend", ) - self.add_middleware(AuthenticationMiddleware, backend=OpenAuthBackend()) + self.add_middleware( + AuthenticationMiddleware, + backend=StandaloneAuthBackend(agents_cfg), + on_error=standalone_auth_error, + ) self.add_middleware( CORSMiddleware, allow_credentials=True, diff --git a/hyperforge/src/hyperforge/standalone/config.py b/hyperforge/src/hyperforge/standalone/config.py index 1091381..f1e97a1 100644 --- a/hyperforge/src/hyperforge/standalone/config.py +++ b/hyperforge/src/hyperforge/standalone/config.py @@ -48,6 +48,21 @@ from hyperforge.workflows import WorkflowData +class StandaloneMCPAuthConfig(BaseModel): + """Optional OAuth/JWT protection for the standalone MCP endpoint.""" + + enabled: bool = False + authorization_server: Optional[str] = None + protected_resource_metadata_url: Optional[str] = None + protected_resource: Optional[str] = None + scopes_supported: list[str] = Field(default_factory=list) + required_scopes: list[str] = Field(default_factory=list) + jwks_url: Optional[str] = None + issuer: Optional[str] = None + audience: Optional[str] = None + forward_authorization_header: bool = True + + class WorkflowConfig(BaseModel): """Pipeline steps for a single named workflow.""" @@ -113,6 +128,9 @@ class StandAloneAgentConfig(BaseModel): # Prompts exposed via the MCP server. prompts: list[PromptConfig] = Field(default_factory=list) + # Optional OAuth/JWT protection for this agent's standalone MCP endpoint. + mcp_auth: Optional[StandaloneMCPAuthConfig] = None + @field_validator("drivers", mode="before") def validate_drivers(cls, value: list[Dict[str, Any]], field): if len(value) == 0: diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py new file mode 100644 index 0000000..04712ee --- /dev/null +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -0,0 +1,189 @@ +import base64 +import json +import time +from dataclasses import dataclass, field +from typing import Any + +import httpx +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.hashes import SHA256, SHA384, SHA512 +from starlette.authentication import AuthenticationError + +from hyperforge.standalone.config import StandaloneMCPAuthConfig + +_HASHES = { + "RS256": SHA256, + "RS384": SHA384, + "RS512": SHA512, +} + + +@dataclass +class JWKSCache: + ttl_seconds: int = 300 + _values: dict[str, tuple[float, dict[str, Any]]] = field(default_factory=dict) + + async def get(self, url: str) -> dict[str, Any]: + now = time.time() + cached = self._values.get(url) + if cached is not None: + expires_at, jwks = cached + if expires_at > now: + return jwks + + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url) + response.raise_for_status() + jwks = response.json() + self._values[url] = (now + self.ttl_seconds, jwks) + return jwks + + +def extract_bearer_token(authorization: str | None) -> str: + if authorization is None: + raise AuthenticationError("Missing bearer token") + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise AuthenticationError("Invalid bearer token") + return token + + +def get_enabled_mcp_auth( + agents_cfg: dict[str, Any], agent_id: str +) -> StandaloneMCPAuthConfig | None: + agent_config = agents_cfg.get(agent_id) + auth_config = getattr(agent_config, "mcp_auth", None) + if auth_config is None or not auth_config.enabled: + return None + return auth_config + + +async def validate_mcp_bearer( + authorization: str | None, + auth_config: StandaloneMCPAuthConfig, + jwks_cache: JWKSCache, +) -> dict[str, Any]: + token = extract_bearer_token(authorization) + if auth_config.jwks_url is None: + raise AuthenticationError("Missing JWKS URL") + + payload = await _validate_jwt(token, auth_config, jwks_cache) + _validate_scopes(payload, auth_config.required_scopes) + return payload + + +async def _validate_jwt( + token: str, + auth_config: StandaloneMCPAuthConfig, + jwks_cache: JWKSCache, +) -> dict[str, Any]: + try: + header_raw, payload_raw, signature_raw = token.split(".") + header = _decode_json(header_raw) + payload = _decode_json(payload_raw) + signature = _b64decode(signature_raw) + except Exception as exc: + raise AuthenticationError("Malformed bearer token") from exc + + alg = header.get("alg") + if alg not in _HASHES: + raise AuthenticationError("Unsupported bearer token algorithm") + + try: + jwks = await jwks_cache.get(auth_config.jwks_url or "") + except AuthenticationError: + raise + except Exception as exc: + raise AuthenticationError("Unable to fetch JWKS") from exc + key = _select_jwk(jwks, header.get("kid")) + public_key = _rsa_public_key_from_jwk(key) + signed_payload = f"{header_raw}.{payload_raw}".encode() + + try: + public_key.verify( + signature, + signed_payload, + padding.PKCS1v15(), + _HASHES[alg](), + ) + except Exception as exc: + raise AuthenticationError("Invalid bearer token signature") from exc + + _validate_claims(payload, auth_config) + return payload + + +def _decode_json(value: str) -> dict[str, Any]: + decoded = _b64decode(value) + parsed = json.loads(decoded) + if not isinstance(parsed, dict): + raise ValueError("JWT part is not an object") + return parsed + + +def _b64decode(value: str) -> bytes: + padded = value + "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(padded.encode()) + + +def _select_jwk(jwks: dict[str, Any], kid: str | None) -> dict[str, Any]: + keys = jwks.get("keys") + if not isinstance(keys, list): + raise AuthenticationError("Invalid JWKS") + for key in keys: + if isinstance(key, dict) and (kid is None or key.get("kid") == kid): + return key + raise AuthenticationError("Bearer token key not found") + + +def _rsa_public_key_from_jwk(jwk: dict[str, Any]) -> rsa.RSAPublicKey: + if jwk.get("kty") != "RSA": + raise AuthenticationError("Unsupported bearer token key type") + try: + n = int.from_bytes(_b64decode(jwk["n"]), "big") + e = int.from_bytes(_b64decode(jwk["e"]), "big") + except Exception as exc: + raise AuthenticationError("Invalid bearer token key") from exc + return rsa.RSAPublicNumbers(e=e, n=n).public_key() + + +def _validate_claims( + payload: dict[str, Any], auth_config: StandaloneMCPAuthConfig +) -> None: + now = time.time() + exp = payload.get("exp") + if exp is None: + raise AuthenticationError("Missing bearer token expiration") + if float(exp) < now: + raise AuthenticationError("Expired bearer token") + nbf = payload.get("nbf") + if nbf is not None and float(nbf) > now: + raise AuthenticationError("Bearer token is not valid yet") + + if auth_config.issuer is not None and payload.get("iss") != auth_config.issuer: + raise AuthenticationError("Invalid bearer token issuer") + + if auth_config.audience is not None: + aud = payload.get("aud") + audiences = aud if isinstance(aud, list) else [aud] + if auth_config.audience not in audiences: + raise AuthenticationError("Invalid bearer token audience") + + +def _validate_scopes(payload: dict[str, Any], required_scopes: list[str]) -> None: + if not required_scopes: + return + + raw_scope = payload.get("scope", "") + scopes = set(raw_scope.split()) if isinstance(raw_scope, str) else set() + raw_scp = payload.get("scp", "") + if isinstance(raw_scp, str): + scopes.update(raw_scp.split()) + elif isinstance(raw_scp, list): + scopes.update(str(scope) for scope in raw_scp) + raw_roles = payload.get("roles", []) + if isinstance(raw_roles, list): + scopes.update(str(role) for role in raw_roles) + + if not set(required_scopes).issubset(scopes): + raise AuthenticationError("Bearer token does not include required scopes") diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py new file mode 100644 index 0000000..67ec1e0 --- /dev/null +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -0,0 +1,285 @@ +import base64 +import json +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.hashes import SHA256 +from httpx import AsyncClient +from httpx._transports.asgi import ASGITransport +from hyperforge.api.v1.mcp_interaction import _prepare_interaction_headers +from hyperforge.standalone import oauth as standalone_oauth +from hyperforge.standalone.app import StandaloneApplication +from hyperforge.standalone.config import StandaloneConfig +from hyperforge.standalone.settings import StandaloneSettings +from starlette.datastructures import Headers + +pytestmark = pytest.mark.asyncio + + +AGENT_ID = "protected-agent" +OPEN_AGENT_ID = "open-agent" +ISSUER = "https://login.example.test/tenant" +AUDIENCE = "api://marklogic" +REQUIRED_SCOPE = "marklogic:read" +PROTECTED_RESOURCE_METADATA_URL = ( + "https://hyperforge.example.test/.well-known/oauth-protected-resource/mcp" +) + + +@pytest.fixture +def agents_config(load_agents): + return StandaloneConfig.validate_python( + { + AGENT_ID: { + "title": "Protected Agent", + "mcp_auth": { + "enabled": True, + "authorization_server": "https://auth.example.test", + "protected_resource_metadata_url": PROTECTED_RESOURCE_METADATA_URL, + "protected_resource": AUDIENCE, + "scopes_supported": ["openid", "offline_access", REQUIRED_SCOPE], + "required_scopes": [REQUIRED_SCOPE], + "jwks_url": "https://auth.example.test/jwks", + "issuer": ISSUER, + "audience": AUDIENCE, + "forward_authorization_header": True, + }, + "workflows": { + "default": { + "name": "default", + "generation": [{"module": "summarize"}], + } + }, + }, + OPEN_AGENT_ID: { + "title": "Open Agent", + "workflows": { + "default": { + "name": "default", + "generation": [{"module": "summarize"}], + } + }, + }, + } + ) + + +@pytest.fixture +def standalone_settings(): + return StandaloneSettings( + agents_config=Path("/dev/null"), + external_nua_api_key="dummy", + debug=False, + ) + + +@pytest.fixture +async def client(agents_config, standalone_settings): + app = StandaloneApplication(agents_config, standalone_settings) + async with ( + app.router.lifespan_context(app), + AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client, + ): + yield client + + +@pytest.fixture +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture +def jwks(signing_key): + public_numbers = signing_key.public_key().public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "kid": "test-key", + "alg": "RS256", + "n": _b64encode_int(public_numbers.n), + "e": _b64encode_int(public_numbers.e), + } + ] + } + + +@pytest.fixture(autouse=True) +def mock_jwks(monkeypatch, jwks): + async def get(self, url: str): + return jwks + + monkeypatch.setattr(standalone_oauth.JWKSCache, "get", get) + + +def make_token(signing_key, **claims_overrides): + now = int(time.time()) + header = {"alg": "RS256", "kid": "test-key", "typ": "JWT"} + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "exp": now + 300, + "nbf": now - 10, + "scp": REQUIRED_SCOPE, + **claims_overrides, + } + header_raw = _b64encode_json(header) + claims_raw = _b64encode_json(claims) + signed_payload = f"{header_raw}.{claims_raw}".encode() + signature = signing_key.sign(signed_payload, padding.PKCS1v15(), SHA256()) + return f"{header_raw}.{claims_raw}.{_b64encode(signature)}" + + +def _b64encode_json(value: dict) -> str: + return _b64encode(json.dumps(value, separators=(",", ":")).encode()) + + +def _b64encode_int(value: int) -> str: + return _b64encode(value.to_bytes((value.bit_length() + 7) // 8, "big")) + + +def _b64encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip("=") + + +async def test_mcp_auth_is_optional(client: AsyncClient): + response = await client.delete(f"/api/v1/agent/{OPEN_AGENT_ID}/session/s1/mcp") + + assert response.status_code == 200 + + +async def test_protected_mcp_requires_bearer(client: AsyncClient): + response = await client.delete(f"/api/v1/agent/{AGENT_ID}/session/s1/mcp") + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == ( + f'Bearer resource_metadata="{PROTECTED_RESOURCE_METADATA_URL}"' + ) + + +async def test_protected_mcp_accepts_signed_bearer( + client: AsyncClient, signing_key +): + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key)}"}, + ) + + assert response.status_code == 200 + + +async def test_protected_mcp_rejects_token_with_wrong_audience( + client: AsyncClient, signing_key +): + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key, aud='api://other')}"}, + ) + + assert response.status_code == 401 + assert "audience" in response.text + + +async def test_protected_mcp_rejects_tampered_token(client: AsyncClient, signing_key): + token = make_token(signing_key) + header_raw, claims_raw, signature_raw = token.split(".") + tampered_claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "exp": int(time.time()) + 300, + "scp": REQUIRED_SCOPE, + "sub": "tampered", + } + tampered_token = ( + f"{header_raw}.{_b64encode_json(tampered_claims)}.{signature_raw}" + ) + + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {tampered_token}"}, + ) + + assert response.status_code == 401 + assert "signature" in response.text + assert response.headers["www-authenticate"] == ( + f'Bearer resource_metadata="{PROTECTED_RESOURCE_METADATA_URL}"' + ) + + +async def test_protected_mcp_rejects_token_without_required_scope( + client: AsyncClient, signing_key +): + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key, scp='other:read')}"}, + ) + + assert response.status_code == 401 + assert "scope" in response.text + + +async def test_protected_mcp_rejects_token_without_exp( + client: AsyncClient, signing_key +): + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key, exp=None)}"}, + ) + + assert response.status_code == 401 + assert "expiration" in response.text + + +async def test_protected_mcp_returns_401_when_jwks_fetch_fails( + client: AsyncClient, monkeypatch, signing_key +): + async def fail_get(self, url: str): + raise RuntimeError("jwks unavailable") + + monkeypatch.setattr(standalone_oauth.JWKSCache, "get", fail_get) + + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key)}"}, + ) + + assert response.status_code == 401 + assert "JWKS" in response.text + + +async def test_protected_resource_metadata_uses_agent_oauth_config( + client: AsyncClient, +): + response = await client.get( + f"/.well-known/oauth-protected-resource/api/v1/agent/{AGENT_ID}/session/s1/mcp" + ) + + assert response.status_code == 200 + assert response.json() == { + "resource": AUDIENCE, + "scopes_supported": ["openid", "offline_access", REQUIRED_SCOPE], + "authorization_servers": ["https://auth.example.test"], + } + + +async def test_prepare_interaction_headers_forwards_authorization(agents_config): + app = SimpleNamespace(_agents_cfg=agents_config) + headers = Headers({"Authorization": "Bearer user-token", "X-Other": "value"}) + + prepared = _prepare_interaction_headers(app, AGENT_ID, headers) + + assert prepared["authorization"] == "Bearer user-token" + assert prepared["x-other"] == "value" + + +async def test_prepare_interaction_headers_can_drop_authorization(agents_config): + agents_config[AGENT_ID].mcp_auth.forward_authorization_header = False + app = SimpleNamespace(_agents_cfg=agents_config) + headers = Headers({"Authorization": "Bearer user-token"}) + + prepared = _prepare_interaction_headers(app, AGENT_ID, headers) + + assert "authorization" not in prepared From 242b9ea2a7bf7b49abff15466826f71f25dc3ee3 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Thu, 9 Jul 2026 14:45:05 +0200 Subject: [PATCH 02/12] Fix test --- hyperforge/src/hyperforge/api/session.py | 6 ++++++ .../src/hyperforge/api/v1/interaction.py | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/hyperforge/src/hyperforge/api/session.py b/hyperforge/src/hyperforge/api/session.py index 34ebde4..bed7c10 100644 --- a/hyperforge/src/hyperforge/api/session.py +++ b/hyperforge/src/hyperforge/api/session.py @@ -1,6 +1,7 @@ """Session management functions for ARAG agents with NucliaDB memory.""" from typing import Optional +from uuid import UUID from nucliadb_models import ( CreateResourcePayload, @@ -109,6 +110,11 @@ async def session_exists( Returns: True if session exists, False otherwise """ + try: + UUID(session_id) + except ValueError: + return False + try: await ndb.get_resource_by_id( rid=session_id, diff --git a/hyperforge/src/hyperforge/api/v1/interaction.py b/hyperforge/src/hyperforge/api/v1/interaction.py index 8658da7..f65a479 100644 --- a/hyperforge/src/hyperforge/api/v1/interaction.py +++ b/hyperforge/src/hyperforge/api/v1/interaction.py @@ -50,25 +50,26 @@ async def ensure_session_exists( agent_id: str, session: str, create_if_not_exists: bool, -) -> Optional[str]: +) -> tuple[str, Optional[str]]: """ Check if session exists and create it if needed. Returns: - None if session exists or was created successfully. - Error message string if session doesn't exist and shouldn't be created. + The effective session id and None if the session exists or was created. + The original session id and an error message if the session doesn't exist + and shouldn't be created. """ # Check if session exists if await session_exists(ndb, agent_id, session): - return None + return session, None # Session doesn't exist if not create_if_not_exists: - return f"Session '{session}' does not exist" + return session, f"Session '{session}' does not exist" # Create the session try: - await create_session_resource( + created = await create_session_resource( ndb=ndb, agent_id=agent_id, slug=session, @@ -76,10 +77,10 @@ async def ensure_session_exists( summary="Auto-created session", data="", ) - return None + return created.uuid, None except Exception as e: logger.exception(f"Error creating session {session} for agent {agent_id}: {e}") - return f"Failed to create session: {str(e)}" + return session, f"Failed to create session: {str(e)}" class Shutdown: @@ -333,7 +334,7 @@ async def websocket_endpoint( agent_manager, x_stf_account, agent_id, workflow_id ): ndb: NucliaDBAsync = websocket.app.arag_reader - error_message = await ensure_session_exists( + session, error_message = await ensure_session_exists( ndb, agent_id, session, create_session_if_not_exists ) if error_message: From 911e91b526db5bc9bebcdbabe8e6112cf60ff2f6 Mon Sep 17 00:00:00 2001 From: arnaugrispg Date: Thu, 9 Jul 2026 15:33:35 +0200 Subject: [PATCH 03/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- hyperforge/src/hyperforge/standalone/oauth.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py index 04712ee..cd15e8b 100644 --- a/hyperforge/src/hyperforge/standalone/oauth.py +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -154,11 +154,21 @@ def _validate_claims( exp = payload.get("exp") if exp is None: raise AuthenticationError("Missing bearer token expiration") - if float(exp) < now: + try: + exp_value = float(exp) + except (TypeError, ValueError) as exc: + raise AuthenticationError("Invalid bearer token expiration") from exc + if exp_value < now: raise AuthenticationError("Expired bearer token") + nbf = payload.get("nbf") - if nbf is not None and float(nbf) > now: - raise AuthenticationError("Bearer token is not valid yet") + if nbf is not None: + try: + nbf_value = float(nbf) + except (TypeError, ValueError) as exc: + raise AuthenticationError("Invalid bearer token not-before") from exc + if nbf_value > now: + raise AuthenticationError("Bearer token is not valid yet") if auth_config.issuer is not None and payload.get("iss") != auth_config.issuer: raise AuthenticationError("Invalid bearer token issuer") From 7ac5b6822ea385cc29a0f7b26cfacd68a55e2a03 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Thu, 9 Jul 2026 15:51:54 +0200 Subject: [PATCH 04/12] Fix copilot --- hyperforge/src/hyperforge/api/session.py | 57 ++++++++++++++----- .../src/hyperforge/api/v1/interaction.py | 10 ++-- .../src/hyperforge/api/v1/mcp_interaction.py | 2 +- hyperforge/src/hyperforge/standalone/oauth.py | 11 +++- hyperforge/tests/standalone/test_mcp_auth.py | 25 +++++++- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/hyperforge/src/hyperforge/api/session.py b/hyperforge/src/hyperforge/api/session.py index bed7c10..9408274 100644 --- a/hyperforge/src/hyperforge/api/session.py +++ b/hyperforge/src/hyperforge/api/session.py @@ -95,6 +95,48 @@ async def get_session_resource( ) +async def get_session_resource_by_slug( + ndb: NucliaDBAsync, + agent_id: str, + slug: str, + show: Optional[list[str]] = None, +) -> Resource: + """Get a session resource by slug.""" + query_params = {} + if show: + query_params["show"] = show + + return await ndb.get_resource_by_slug( + slug=slug, + kbid=agent_id, + query_params=query_params, + ) + + +async def resolve_session_id( + ndb: NucliaDBAsync, + agent_id: str, + session: str, +) -> str | None: + """Resolve a client-provided session identifier to the NucliaDB resource id.""" + try: + UUID(session) + except ValueError: + try: + resource = await get_session_resource_by_slug( + ndb, agent_id, session, show=["basic"] + ) + except NotFoundError: + return None + return resource.id + + try: + await get_session_resource(ndb, agent_id, session, show=["basic"]) + return session + except NotFoundError: + return None + + async def session_exists( ndb: NucliaDBAsync, agent_id: str, @@ -110,20 +152,7 @@ async def session_exists( Returns: True if session exists, False otherwise """ - try: - UUID(session_id) - except ValueError: - return False - - try: - await ndb.get_resource_by_id( - rid=session_id, - kbid=agent_id, - query_params={"show": ["basic"]}, - ) - return True - except NotFoundError: - return False + return await resolve_session_id(ndb, agent_id, session_id) is not None async def update_session_resource( diff --git a/hyperforge/src/hyperforge/api/v1/interaction.py b/hyperforge/src/hyperforge/api/v1/interaction.py index f65a479..a094e4b 100644 --- a/hyperforge/src/hyperforge/api/v1/interaction.py +++ b/hyperforge/src/hyperforge/api/v1/interaction.py @@ -18,7 +18,7 @@ from hyperforge import logger from hyperforge.api.authentication import requires_one from hyperforge.api.models import AgentRole, InteractionRequest -from hyperforge.api.session import create_session_resource, session_exists +from hyperforge.api.session import create_session_resource, resolve_session_id from hyperforge.api.settings import Settings from hyperforge.api.utils import agent_has_nucliadb_memory from hyperforge.api.v1.router import router @@ -59,9 +59,11 @@ async def ensure_session_exists( The original session id and an error message if the session doesn't exist and shouldn't be created. """ - # Check if session exists - if await session_exists(ndb, agent_id, session): - return session, None + # Check if session exists. The client may pass either the resource UUID or + # the session slug, but the memory layer needs the NucliaDB resource UUID. + existing_session_id = await resolve_session_id(ndb, agent_id, session) + if existing_session_id is not None: + return existing_session_id, None # Session doesn't exist if not create_if_not_exists: diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py index bba8916..8d56333 100644 --- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py +++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py @@ -205,7 +205,7 @@ async def mcp_interaction_protected_resource_metadata( resource = ( auth_config.protected_resource if auth_config is not None and auth_config.protected_resource is not None - else str(mcp_url).replace("http://", "https://") + else str(mcp_url.replace(scheme="https")) ) authorization_servers = ( [auth_config.authorization_server] diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py index cd15e8b..3e6c58f 100644 --- a/hyperforge/src/hyperforge/standalone/oauth.py +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -130,8 +130,17 @@ def _select_jwk(jwks: dict[str, Any], kid: str | None) -> dict[str, Any]: keys = jwks.get("keys") if not isinstance(keys, list): raise AuthenticationError("Invalid JWKS") + + if kid is None: + if len(keys) != 1: + raise AuthenticationError("Missing bearer token key id") + key = keys[0] + if isinstance(key, dict): + return key + raise AuthenticationError("Invalid JWKS") + for key in keys: - if isinstance(key, dict) and (kid is None or key.get("kid") == kid): + if isinstance(key, dict) and key.get("kid") == kid: return key raise AuthenticationError("Bearer token key not found") diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py index 67ec1e0..e4a5be6 100644 --- a/hyperforge/tests/standalone/test_mcp_auth.py +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -115,9 +115,11 @@ async def get(self, url: str): monkeypatch.setattr(standalone_oauth.JWKSCache, "get", get) -def make_token(signing_key, **claims_overrides): +def make_token(signing_key, kid: str | None = "test-key", **claims_overrides): now = int(time.time()) - header = {"alg": "RS256", "kid": "test-key", "typ": "JWT"} + header = {"alg": "RS256", "typ": "JWT"} + if kid is not None: + header["kid"] = kid claims = { "iss": ISSUER, "aud": AUDIENCE, @@ -250,6 +252,25 @@ async def fail_get(self, url: str): assert "JWKS" in response.text +async def test_protected_mcp_rejects_token_without_kid_when_jwks_has_multiple_keys( + client: AsyncClient, monkeypatch, signing_key, jwks +): + jwks["keys"].append({**jwks["keys"][0], "kid": "rotated-key"}) + + async def get(self, url: str): + return jwks + + monkeypatch.setattr(standalone_oauth.JWKSCache, "get", get) + + response = await client.delete( + f"/api/v1/agent/{AGENT_ID}/session/s1/mcp", + headers={"Authorization": f"Bearer {make_token(signing_key, kid=None)}"}, + ) + + assert response.status_code == 401 + assert "key id" in response.text + + async def test_protected_resource_metadata_uses_agent_oauth_config( client: AsyncClient, ): From fb3ba28c6187118284f44538fd5747fca6fe1bb0 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Thu, 9 Jul 2026 16:13:18 +0200 Subject: [PATCH 05/12] Fix copilot --- .../src/hyperforge/api/v1/mcp_interaction.py | 9 ++- .../src/hyperforge/standalone/config.py | 25 +++++++- hyperforge/src/hyperforge/standalone/oauth.py | 1 + hyperforge/tests/standalone/test_mcp_auth.py | 61 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py index 8d56333..81312ed 100644 --- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py +++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py @@ -207,15 +207,20 @@ async def mcp_interaction_protected_resource_metadata( 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 [app.settings.hydra_public_url] + 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 app.settings.hydra_scopes_supported + else getattr(app.settings, "hydra_scopes_supported", []) ) return { "resource": resource, diff --git a/hyperforge/src/hyperforge/standalone/config.py b/hyperforge/src/hyperforge/standalone/config.py index f1e97a1..e60840d 100644 --- a/hyperforge/src/hyperforge/standalone/config.py +++ b/hyperforge/src/hyperforge/standalone/config.py @@ -38,7 +38,14 @@ from typing import Any, Dict, Optional -from pydantic import BaseModel, Field, TypeAdapter, field_serializer, field_validator +from pydantic import ( + BaseModel, + Field, + TypeAdapter, + field_serializer, + field_validator, + model_validator, +) from hyperforge.agent import AgentConfig from hyperforge.configure import get_agent_config_klass, get_driver_config_klass @@ -62,6 +69,22 @@ class StandaloneMCPAuthConfig(BaseModel): audience: Optional[str] = None forward_authorization_header: bool = True + @model_validator(mode="after") + def validate_enabled_config(self): + if not self.enabled: + return self + + missing = [ + field + for field in ("authorization_server", "jwks_url") + if getattr(self, field) is None + ] + if missing: + raise ValueError( + f"Missing required MCP auth setting(s) when enabled: {', '.join(missing)}" + ) + return self + class WorkflowConfig(BaseModel): """Pipeline steps for a single named workflow.""" diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py index 3e6c58f..9608cc2 100644 --- a/hyperforge/src/hyperforge/standalone/oauth.py +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -43,6 +43,7 @@ def extract_bearer_token(authorization: str | None) -> str: if authorization is None: raise AuthenticationError("Missing bearer token") scheme, _, token = authorization.partition(" ") + token = token.strip() if scheme.lower() != "bearer" or not token: raise AuthenticationError("Invalid bearer token") return token diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py index e4a5be6..d85ed18 100644 --- a/hyperforge/tests/standalone/test_mcp_auth.py +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -147,6 +147,52 @@ def _b64encode(value: bytes) -> str: return base64.urlsafe_b64encode(value).decode().rstrip("=") +async def test_extract_bearer_token_strips_extra_token_whitespace(): + token = standalone_oauth.extract_bearer_token("Bearer token-value ") + + assert token == "token-value" + + +async def test_enabled_mcp_auth_requires_authorization_server(load_agents): + with pytest.raises(ValueError, match="authorization_server"): + StandaloneConfig.validate_python( + { + AGENT_ID: { + "mcp_auth": { + "enabled": True, + "jwks_url": "https://auth.example.test/jwks", + }, + "workflows": { + "default": { + "name": "default", + "generation": [{"module": "summarize"}], + } + }, + } + } + ) + + +async def test_enabled_mcp_auth_requires_jwks_url(load_agents): + with pytest.raises(ValueError, match="jwks_url"): + StandaloneConfig.validate_python( + { + AGENT_ID: { + "mcp_auth": { + "enabled": True, + "authorization_server": "https://auth.example.test", + }, + "workflows": { + "default": { + "name": "default", + "generation": [{"module": "summarize"}], + } + }, + } + } + ) + + async def test_mcp_auth_is_optional(client: AsyncClient): response = await client.delete(f"/api/v1/agent/{OPEN_AGENT_ID}/session/s1/mcp") @@ -286,6 +332,21 @@ async def test_protected_resource_metadata_uses_agent_oauth_config( } +async def test_protected_resource_metadata_without_auth_has_no_hydra_fallback( + client: AsyncClient, +): + response = await client.get( + f"/.well-known/oauth-protected-resource/api/v1/agent/{OPEN_AGENT_ID}/session/s1/mcp" + ) + + assert response.status_code == 200 + assert response.json() == { + "resource": "https://test/api/v1/agent/open-agent/session/s1/mcp", + "scopes_supported": [], + "authorization_servers": [], + } + + async def test_prepare_interaction_headers_forwards_authorization(agents_config): app = SimpleNamespace(_agents_cfg=agents_config) headers = Headers({"Authorization": "Bearer user-token", "X-Other": "value"}) From 37b5d5faa54db29fb0e1fe8e11bcf870f843cf98 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Thu, 9 Jul 2026 16:39:27 +0200 Subject: [PATCH 06/12] Fix copilot --- hyperforge/src/hyperforge/standalone/app.py | 1 + hyperforge/tests/standalone/test_mcp_auth.py | 29 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/hyperforge/src/hyperforge/standalone/app.py b/hyperforge/src/hyperforge/standalone/app.py index 0bd358c..071ed26 100644 --- a/hyperforge/src/hyperforge/standalone/app.py +++ b/hyperforge/src/hyperforge/standalone/app.py @@ -192,6 +192,7 @@ def _mcp_resource_metadata_url(conn: HTTPConnection) -> str | None: session = parts[5] return str( conn.url.replace( + scheme="https", path=f"/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp", query="", fragment="", diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py index d85ed18..0f43adb 100644 --- a/hyperforge/tests/standalone/test_mcp_auth.py +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -7,8 +7,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import padding, rsa from cryptography.hazmat.primitives.hashes import SHA256 -from httpx import AsyncClient -from httpx._transports.asgi import ASGITransport +from httpx import ASGITransport, AsyncClient from hyperforge.api.v1.mcp_interaction import _prepare_interaction_headers from hyperforge.standalone import oauth as standalone_oauth from hyperforge.standalone.app import StandaloneApplication @@ -21,6 +20,7 @@ AGENT_ID = "protected-agent" OPEN_AGENT_ID = "open-agent" +DEFAULT_METADATA_AGENT_ID = "default-metadata-agent" ISSUER = "https://login.example.test/tenant" AUDIENCE = "api://marklogic" REQUIRED_SCOPE = "marklogic:read" @@ -63,6 +63,20 @@ def agents_config(load_agents): } }, }, + DEFAULT_METADATA_AGENT_ID: { + "title": "Protected Agent With Default Metadata URL", + "mcp_auth": { + "enabled": True, + "authorization_server": "https://auth.example.test", + "jwks_url": "https://auth.example.test/jwks", + }, + "workflows": { + "default": { + "name": "default", + "generation": [{"module": "summarize"}], + } + }, + }, } ) @@ -208,6 +222,17 @@ async def test_protected_mcp_requires_bearer(client: AsyncClient): ) +async def test_protected_mcp_default_metadata_url_uses_https(client: AsyncClient): + response = await client.delete( + f"/api/v1/agent/{DEFAULT_METADATA_AGENT_ID}/session/s1/mcp" + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == ( + 'Bearer resource_metadata="https://test/.well-known/oauth-protected-resource/api/v1/agent/default-metadata-agent/session/s1/mcp"' + ) + + async def test_protected_mcp_accepts_signed_bearer( client: AsyncClient, signing_key ): From 56c35d940291fc9f42daa7c6033f38038f42d9f6 Mon Sep 17 00:00:00 2001 From: arnaugrispg Date: Thu, 9 Jul 2026 16:55:16 +0200 Subject: [PATCH 07/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- hyperforge/src/hyperforge/api/session.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hyperforge/src/hyperforge/api/session.py b/hyperforge/src/hyperforge/api/session.py index 9408274..c8cc09d 100644 --- a/hyperforge/src/hyperforge/api/session.py +++ b/hyperforge/src/hyperforge/api/session.py @@ -134,7 +134,13 @@ async def resolve_session_id( await get_session_resource(ndb, agent_id, session, show=["basic"]) return session except NotFoundError: - return None + try: + resource = await get_session_resource_by_slug( + ndb, agent_id, session, show=["basic"] + ) + except NotFoundError: + return None + return resource.id async def session_exists( From 532fb409df7eeaee77e0ae93110a8391971986db Mon Sep 17 00:00:00 2001 From: arnaugrispg Date: Thu, 9 Jul 2026 16:55:24 +0200 Subject: [PATCH 08/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- hyperforge/tests/standalone/test_mcp_auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py index 0f43adb..aa507f9 100644 --- a/hyperforge/tests/standalone/test_mcp_auth.py +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -100,11 +100,10 @@ async def client(agents_config, standalone_settings): yield client -@pytest.fixture +@pytest.fixture(scope="module") def signing_key(): return rsa.generate_private_key(public_exponent=65537, key_size=2048) - @pytest.fixture def jwks(signing_key): public_numbers = signing_key.public_key().public_numbers() From 61bcf1f7c8abf308e3f91a1e8425491d1b31db2d Mon Sep 17 00:00:00 2001 From: arnaugrispg Date: Fri, 10 Jul 2026 08:18:47 +0200 Subject: [PATCH 09/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- hyperforge/src/hyperforge/standalone/oauth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py index 9608cc2..c3b4945 100644 --- a/hyperforge/src/hyperforge/standalone/oauth.py +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -96,6 +96,8 @@ async def _validate_jwt( raise except Exception as exc: raise AuthenticationError("Unable to fetch JWKS") from exc + if not isinstance(jwks, dict): + raise AuthenticationError("Invalid JWKS") key = _select_jwk(jwks, header.get("kid")) public_key = _rsa_public_key_from_jwk(key) signed_payload = f"{header_raw}.{payload_raw}".encode() From 1f052ea9daac082b1f6e5e8865122778d49c6cf3 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Fri, 10 Jul 2026 15:09:50 +0200 Subject: [PATCH 10/12] Fix comments --- .../src/hyperforge/api/v1/mcp_interaction.py | 46 +++++++++++++------ .../src/hyperforge/standalone/config.py | 11 ++--- hyperforge/src/hyperforge/standalone/oauth.py | 7 ++- hyperforge/tests/standalone/test_mcp_auth.py | 11 ----- 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py index 81312ed..b2c8a81 100644 --- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py +++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import MutableMapping from functools import partial -from typing import TYPE_CHECKING, Any, Iterable, Sequence +from typing import TYPE_CHECKING, Any, Iterable, Protocol, Sequence, cast import anyio from fastapi import Header @@ -33,6 +33,7 @@ from hyperforge.api.authentication import requires_one from hyperforge.api.models import InteractionRequest +from hyperforge.api.settings import Settings as ApiSettings from hyperforge.api.v1.interaction import WebsocketReceiver, stream_response from hyperforge.db.agents import AgentManager from hyperforge.interaction import AnswerOperation @@ -53,6 +54,10 @@ from hyperforge.api.v1.router import router +class _HasAgentsCfg(Protocol): + _agents_cfg: dict[str, Any] + + async def list_tools(workflows: list[WorkflowData]) -> list[Tool]: return [ Tool( @@ -173,18 +178,31 @@ def _prepare_interaction_headers( 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) - authorization = headers.get("authorization") - if auth_config is not None and not auth_config.forward_authorization_header: - interaction_headers.pop("authorization", None) - interaction_headers.pop("Authorization", None) - elif authorization is not None: + if authorization is not None: interaction_headers["authorization"] = authorization return interaction_headers +def _default_oauth_metadata(app: "HTTPApplication") -> tuple[list[str], list[str]]: + if isinstance(app.settings, ApiSettings): + return [app.settings.hydra_public_url], app.settings.hydra_scopes_supported + return [], [] + + +def _agents_cfg(app: "HTTPApplication") -> dict[str, Any]: + if "_agents_cfg" in vars(app): + return cast(_HasAgentsCfg, app)._agents_cfg + return {} + + +def _get_mcp_auth_config( + app: "HTTPApplication", agent_id: str +): + return get_enabled_mcp_auth(_agents_cfg(app), agent_id) + + @router.get( "/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp" ) @@ -201,26 +219,24 @@ async def mcp_interaction_protected_resource_metadata( mcp_url = request.url_for( "interaction_mcp_handler", agent_id=agent_id, session=session ) - auth_config = get_enabled_mcp_auth(getattr(app, "_agents_cfg", {}), agent_id) + auth_config = _get_mcp_auth_config(app, 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) + default_authorization_server, default_scopes_supported = _default_oauth_metadata( + app + ) 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 [] - ) + else default_authorization_server ) scopes_supported = ( auth_config.scopes_supported if auth_config is not None - else getattr(app.settings, "hydra_scopes_supported", []) + else default_scopes_supported ) return { "resource": resource, diff --git a/hyperforge/src/hyperforge/standalone/config.py b/hyperforge/src/hyperforge/standalone/config.py index e60840d..7f3e276 100644 --- a/hyperforge/src/hyperforge/standalone/config.py +++ b/hyperforge/src/hyperforge/standalone/config.py @@ -67,18 +67,17 @@ class StandaloneMCPAuthConfig(BaseModel): jwks_url: Optional[str] = None issuer: Optional[str] = None audience: Optional[str] = None - forward_authorization_header: bool = True @model_validator(mode="after") def validate_enabled_config(self): if not self.enabled: return self - missing = [ - field - for field in ("authorization_server", "jwks_url") - if getattr(self, field) is None - ] + missing = [] + if self.authorization_server is None: + missing.append("authorization_server") + if self.jwks_url is None: + missing.append("jwks_url") if missing: raise ValueError( f"Missing required MCP auth setting(s) when enabled: {', '.join(missing)}" diff --git a/hyperforge/src/hyperforge/standalone/oauth.py b/hyperforge/src/hyperforge/standalone/oauth.py index c3b4945..2ce02ec 100644 --- a/hyperforge/src/hyperforge/standalone/oauth.py +++ b/hyperforge/src/hyperforge/standalone/oauth.py @@ -9,7 +9,7 @@ from cryptography.hazmat.primitives.hashes import SHA256, SHA384, SHA512 from starlette.authentication import AuthenticationError -from hyperforge.standalone.config import StandaloneMCPAuthConfig +from hyperforge.standalone.config import StandAloneAgentConfig, StandaloneMCPAuthConfig _HASHES = { "RS256": SHA256, @@ -53,7 +53,10 @@ def get_enabled_mcp_auth( agents_cfg: dict[str, Any], agent_id: str ) -> StandaloneMCPAuthConfig | None: agent_config = agents_cfg.get(agent_id) - auth_config = getattr(agent_config, "mcp_auth", None) + if not isinstance(agent_config, StandAloneAgentConfig): + return None + + auth_config = agent_config.mcp_auth if auth_config is None or not auth_config.enabled: return None return auth_config diff --git a/hyperforge/tests/standalone/test_mcp_auth.py b/hyperforge/tests/standalone/test_mcp_auth.py index aa507f9..307db73 100644 --- a/hyperforge/tests/standalone/test_mcp_auth.py +++ b/hyperforge/tests/standalone/test_mcp_auth.py @@ -45,7 +45,6 @@ def agents_config(load_agents): "jwks_url": "https://auth.example.test/jwks", "issuer": ISSUER, "audience": AUDIENCE, - "forward_authorization_header": True, }, "workflows": { "default": { @@ -379,13 +378,3 @@ async def test_prepare_interaction_headers_forwards_authorization(agents_config) assert prepared["authorization"] == "Bearer user-token" assert prepared["x-other"] == "value" - - -async def test_prepare_interaction_headers_can_drop_authorization(agents_config): - agents_config[AGENT_ID].mcp_auth.forward_authorization_header = False - app = SimpleNamespace(_agents_cfg=agents_config) - headers = Headers({"Authorization": "Bearer user-token"}) - - prepared = _prepare_interaction_headers(app, AGENT_ID, headers) - - assert "authorization" not in prepared From 4ecd94c966c8e1205dfe74da5a11c599abb580e9 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Fri, 10 Jul 2026 15:46:24 +0200 Subject: [PATCH 11/12] Simplify agents config --- hyperforge/src/hyperforge/api/app.py | 1 + .../src/hyperforge/api/v1/mcp_interaction.py | 14 ++------------ hyperforge/src/hyperforge/standalone/app.py | 2 ++ 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/hyperforge/src/hyperforge/api/app.py b/hyperforge/src/hyperforge/api/app.py index 2aab29c..56bbfb5 100644 --- a/hyperforge/src/hyperforge/api/app.py +++ b/hyperforge/src/hyperforge/api/app.py @@ -54,6 +54,7 @@ class HTTPApplication(FastAPI): arag_writer: NucliaDBAsync arag_reader: NucliaDBAsync broker: Broker + _agents_cfg: dict[str, Any] extra_middlewares: Optional[list[Any]] = None def __init__( diff --git a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py index b2c8a81..292c278 100644 --- a/hyperforge/src/hyperforge/api/v1/mcp_interaction.py +++ b/hyperforge/src/hyperforge/api/v1/mcp_interaction.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import MutableMapping from functools import partial -from typing import TYPE_CHECKING, Any, Iterable, Protocol, Sequence, cast +from typing import TYPE_CHECKING, Any, Iterable, Sequence import anyio from fastapi import Header @@ -54,10 +54,6 @@ from hyperforge.api.v1.router import router -class _HasAgentsCfg(Protocol): - _agents_cfg: dict[str, Any] - - async def list_tools(workflows: list[WorkflowData]) -> list[Tool]: return [ Tool( @@ -191,16 +187,10 @@ def _default_oauth_metadata(app: "HTTPApplication") -> tuple[list[str], list[str return [], [] -def _agents_cfg(app: "HTTPApplication") -> dict[str, Any]: - if "_agents_cfg" in vars(app): - return cast(_HasAgentsCfg, app)._agents_cfg - return {} - - def _get_mcp_auth_config( app: "HTTPApplication", agent_id: str ): - return get_enabled_mcp_auth(_agents_cfg(app), agent_id) + return get_enabled_mcp_auth(app._agents_cfg, agent_id) @router.get( diff --git a/hyperforge/src/hyperforge/standalone/app.py b/hyperforge/src/hyperforge/standalone/app.py index 071ed26..ff73253 100644 --- a/hyperforge/src/hyperforge/standalone/app.py +++ b/hyperforge/src/hyperforge/standalone/app.py @@ -209,6 +209,8 @@ def _mcp_resource_metadata_url(conn: HTTPConnection) -> str | None: class StandaloneApplication(FastAPI): """Single-process arag: API + SessionManager sharing one LocalBroker.""" + _agents_cfg: dict[str, Any] + def __init__( self, agents_cfg: dict[str, Any], From 15d4fb7571568c1f5330488c40b7abd4fd61c120 Mon Sep 17 00:00:00 2001 From: Arnau Gris Date: Fri, 10 Jul 2026 15:53:39 +0200 Subject: [PATCH 12/12] Fix test --- hyperforge/src/hyperforge/api/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hyperforge/src/hyperforge/api/app.py b/hyperforge/src/hyperforge/api/app.py index 56bbfb5..a614c52 100644 --- a/hyperforge/src/hyperforge/api/app.py +++ b/hyperforge/src/hyperforge/api/app.py @@ -73,6 +73,7 @@ async def lifespan(app: "HTTPApplication"): super().__init__(*args, lifespan=lifespan, **kwargs) self.settings = settings self.data_manager_settings = data_manager_settings + self._agents_cfg = {} self.include_router(internal.router) self.include_router(v1.router) self.include_router(router)