Skip to content
2 changes: 2 additions & 0 deletions hyperforge/src/hyperforge/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -72,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)
Expand Down
59 changes: 50 additions & 9 deletions hyperforge/src/hyperforge/api/session.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -94,6 +95,54 @@ 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:
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(
ndb: NucliaDBAsync,
agent_id: str,
Expand All @@ -109,15 +158,7 @@ async def session_exists(
Returns:
True if session exists, False otherwise
"""
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(
Expand Down
27 changes: 15 additions & 12 deletions hyperforge/src/hyperforge/api/v1/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,36 +50,39 @@ 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
# 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:
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,
title=f"Session {session}",
summary="Auto-created session",
data="",
)
return None
return created.uuid, None
Comment thread
arnaugrispg marked this conversation as resolved.
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:
Expand Down Expand Up @@ -333,7 +336,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:
Expand Down
56 changes: 49 additions & 7 deletions hyperforge/src/hyperforge/api/v1/mcp_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@

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
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:
Expand Down Expand Up @@ -93,9 +95,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)
Expand Down Expand Up @@ -167,6 +170,29 @@ 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())
authorization = headers.get("authorization")
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 _get_mcp_auth_config(
app: "HTTPApplication", agent_id: str
):
return get_enabled_mcp_auth(app._agents_cfg, agent_id)


@router.get(
"/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp"
)
Expand All @@ -183,13 +209,29 @@ 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_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"))
)
default_authorization_server, default_scopes_supported = _default_oauth_metadata(
app
)
Comment thread
Copilot marked this conversation as resolved.
authorization_servers = (
[auth_config.authorization_server]
if auth_config is not None and auth_config.authorization_server is not None
else default_authorization_server
)
scopes_supported = (
auth_config.scopes_supported
if auth_config is not None
else default_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,
}


Expand Down
93 changes: 88 additions & 5 deletions hyperforge/src/hyperforge/standalone/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -123,6 +147,59 @@ 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(
scheme="https",
path=f"/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp",
query="",
fragment="",
)
)
Comment thread
arnaugrispg marked this conversation as resolved.
return None


# ---------------------------------------------------------------------------
# Application
Expand All @@ -132,6 +209,8 @@ async def authenticate(
class StandaloneApplication(FastAPI):
"""Single-process arag: API + SessionManager sharing one LocalBroker."""

_agents_cfg: dict[str, Any]

def __init__(
self,
agents_cfg: dict[str, Any],
Expand Down Expand Up @@ -173,7 +252,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,
Expand Down
Loading
Loading