diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py
index 325e29406..46cd87458 100644
--- a/mcpgateway/admin.py
+++ b/mcpgateway/admin.py
@@ -1910,7 +1910,7 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
>>> server_service.register_server = original_register_server
"""
form = await request.form()
- # root_path = request.scope.get("root_path", "")
+ # root_path = settings.app_root_path
# is_inactive_checked = form.get("is_inactive_checked", "false")
# Parse tags from comma-separated string
@@ -2268,14 +2268,14 @@ async def admin_toggle_server(
>>>
>>> # Happy path: Activate server
>>> form_data_activate = FormData([("activate", "true"), ("is_inactive_checked", "false")])
- >>> mock_request_activate = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_activate = MagicMock(spec=Request)
>>> mock_request_activate.form = AsyncMock(return_value=form_data_activate)
>>> original_toggle_server_status = server_service.toggle_server_status
>>> server_service.toggle_server_status = AsyncMock()
>>>
>>> async def test_admin_toggle_server_activate():
... result = await admin_toggle_server(server_id, mock_request_activate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#catalog" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#catalog" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_server_activate())
True
@@ -2285,16 +2285,23 @@ async def admin_toggle_server(
>>> mock_request_deactivate = MagicMock(spec=Request, scope={"root_path": "/api"})
>>> mock_request_deactivate.form = AsyncMock(return_value=form_data_deactivate)
>>>
+ >>> # Mock settings.app_root_path
+ >>> import mcpgateway.admin as admin_module
+ >>> original_app_root_path = admin_module.settings.app_root_path
+ >>> admin_module.settings.app_root_path = "/api"
+ >>>
>>> async def test_admin_toggle_server_deactivate():
... result = await admin_toggle_server(server_id, mock_request_deactivate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin#catalog" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin/#catalog" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_server_deactivate())
True
+ >>> # Restore
+ >>> admin_module.settings.app_root_path = original_app_root_path
>>>
>>> # Edge case: Toggle with inactive checkbox checked
>>> form_data_inactive = FormData([("activate", "true"), ("is_inactive_checked", "true")])
- >>> mock_request_inactive = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_inactive = MagicMock(spec=Request)
>>> mock_request_inactive.form = AsyncMock(return_value=form_data_inactive)
>>>
>>> async def test_admin_toggle_server_inactive_checked():
@@ -2306,7 +2313,7 @@ async def admin_toggle_server(
>>>
>>> # Error path: Simulate an exception during toggle
>>> form_data_error = FormData([("activate", "true")])
- >>> mock_request_error = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_error = MagicMock(spec=Request)
>>> mock_request_error.form = AsyncMock(return_value=form_data_error)
>>> server_service.toggle_server_status = AsyncMock(side_effect=Exception("Toggle failed"))
>>>
@@ -2342,7 +2349,7 @@ async def admin_toggle_server(
LOGGER.error(f"Error toggling server status: {e}")
error_message = "Error toggling server status. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -2353,7 +2360,7 @@ async def admin_toggle_server(
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
- return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#catalog", status_code=303)
@admin_router.post("/servers/{server_id}/delete")
@@ -2394,7 +2401,7 @@ async def admin_delete_server(server_id: str, request: Request, db: Session = De
>>>
>>> async def test_admin_delete_server_success():
... result = await admin_delete_server(server_id, mock_request_delete, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#catalog" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#catalog" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_delete_server_success())
True
@@ -2442,6 +2449,9 @@ async def admin_delete_server(server_id: str, request: Request, db: Session = De
LOGGER.error(f"Error deleting server: {e}")
error_message = "Failed to delete server. Please try again."
+ form = await request.form()
+ is_inactive_checked = str(form.get("is_inactive_checked", "false"))
+ root_path = settings.app_root_path
root_path = request.scope.get("root_path", "")
# Build redirect URL with error message if present
@@ -2453,7 +2463,7 @@ async def admin_delete_server(server_id: str, request: Request, db: Session = De
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
- return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#catalog", status_code=303)
@admin_router.get("/resources", response_model=PaginatedResponse)
@@ -2771,33 +2781,40 @@ async def admin_toggle_gateway(
>>>
>>> # Happy path: Activate gateway
>>> form_data_activate = FormData([("activate", "true"), ("is_inactive_checked", "false")])
- >>> mock_request_activate = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_activate = MagicMock(spec=Request)
>>> mock_request_activate.form = AsyncMock(return_value=form_data_activate)
>>> original_toggle_gateway_status = gateway_service.toggle_gateway_status
>>> gateway_service.toggle_gateway_status = AsyncMock()
>>>
>>> async def test_admin_toggle_gateway_activate():
... result = await admin_toggle_gateway(gateway_id, mock_request_activate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#gateways" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#gateways" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_gateway_activate())
True
>>>
>>> # Happy path: Deactivate gateway
>>> form_data_deactivate = FormData([("activate", "false"), ("is_inactive_checked", "false")])
- >>> mock_request_deactivate = MagicMock(spec=Request, scope={"root_path": "/api"})
+ >>> mock_request_deactivate = MagicMock(spec=Request)
>>> mock_request_deactivate.form = AsyncMock(return_value=form_data_deactivate)
>>>
+ >>> # Mock settings.app_root_path
+ >>> import mcpgateway.admin as admin_module
+ >>> original_app_root_path = admin_module.settings.app_root_path
+ >>> admin_module.settings.app_root_path = "/api"
+ >>>
>>> async def test_admin_toggle_gateway_deactivate():
... result = await admin_toggle_gateway(gateway_id, mock_request_deactivate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin#gateways" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin/#gateways" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_gateway_deactivate())
True
+ >>> # Restore
+ >>> admin_module.settings.app_root_path = original_app_root_path
>>>
>>> # Error path: Simulate an exception during toggle
>>> form_data_error = FormData([("activate", "true")])
- >>> mock_request_error = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_error = MagicMock(spec=Request)
>>> mock_request_error.form = AsyncMock(return_value=form_data_error)
>>> gateway_service.toggle_gateway_status = AsyncMock(side_effect=Exception("Toggle failed"))
>>>
@@ -2833,7 +2850,7 @@ async def admin_toggle_gateway(
LOGGER.error(f"Error toggling gateway status: {e}")
error_message = "Failed to toggle gateway status. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -2844,7 +2861,7 @@ async def admin_toggle_gateway(
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303)
- return RedirectResponse(f"{root_path}/admin#gateways", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#gateways", status_code=303)
@admin_router.get("/", name="admin_home", response_class=HTMLResponse)
@@ -3487,8 +3504,8 @@ async def admin_login_page(request: Request) -> Response:
"""
# Check if email auth is enabled
if not getattr(settings, "email_auth_enabled", False):
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(url=f"{root_path}/admin/", status_code=303)
root_path = settings.app_root_path
@@ -3543,8 +3560,8 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
True
"""
if not getattr(settings, "email_auth_enabled", False):
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(url=f"{root_path}/admin/", status_code=303)
try:
form = await request.form()
@@ -3554,7 +3571,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
password = password_val if isinstance(password_val, str) else None
if not email or not password:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=missing_fields", status_code=303)
# Authenticate using the email auth service
@@ -3568,7 +3585,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
if not user:
LOGGER.warning(f"Authentication failed for {email} - user is None")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials", status_code=303)
# Password change enforcement respects master switch and toggles
@@ -3615,7 +3632,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
token, _ = await create_access_token(user)
# Create redirect response to password change page
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
response = RedirectResponse(url=f"{root_path}/admin/change-password-required", status_code=303)
# Set JWT token as secure cookie for the password change process
@@ -3627,8 +3644,8 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
token, _ = await create_access_token(user) # expires_seconds not needed here
# Create redirect response
- root_path = request.scope.get("root_path", "")
- response = RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ response = RedirectResponse(url=f"{root_path}/admin/", status_code=303)
# Set JWT token as secure cookie
set_auth_cookie(response, token, remember_me=False)
@@ -3642,12 +3659,12 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
if settings.secure_cookies and settings.environment == "development":
LOGGER.warning("Login failed - set SECURE_COOKIES to false in config for HTTP development")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials", status_code=303)
except Exception as e:
LOGGER.error(f"Login handler error: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=server_error", status_code=303)
@@ -3750,11 +3767,11 @@ async def change_password_required_page(request: Request) -> HTMLResponse:
True
"""
if not getattr(settings, "email_auth_enabled", False):
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(url=f"{root_path}/admin/", status_code=303)
# Get root path for template
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse(
"change-password-required.html",
@@ -3815,8 +3832,8 @@ async def change_password_required_handler(request: Request, db: Session = Depen
True
"""
if not getattr(settings, "email_auth_enabled", False):
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(url=f"{root_path}/admin/", status_code=303)
try:
form = await request.form()
@@ -3829,18 +3846,18 @@ async def change_password_required_handler(request: Request, db: Session = Depen
confirm_password = confirm_password_val if isinstance(confirm_password_val, str) else None
if not all([current_password, new_password, confirm_password]):
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=missing_fields", status_code=303)
if new_password != confirm_password:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=mismatch", status_code=303)
# Get user from JWT token in cookie
try:
jwt_token = request.cookies.get("jwt_token")
if not jwt_token:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303)
# Authenticate using the token
@@ -3850,11 +3867,11 @@ async def change_password_required_handler(request: Request, db: Session = Depen
current_user = await get_current_user(credentials, request=request)
if not current_user:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303)
except Exception as e:
LOGGER.error(f"Authentication error: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303)
# Authenticate using the email auth service
@@ -3896,8 +3913,8 @@ async def change_password_required_handler(request: Request, db: Session = Depen
token, _ = await create_access_token(current_user)
# Create redirect response to admin panel
- root_path = request.scope.get("root_path", "")
- response = RedirectResponse(url=f"{root_path}/admin", status_code=303)
+ root_path = settings.app_root_path
+ response = RedirectResponse(url=f"{root_path}/admin/", status_code=303)
# Update JWT token cookie
set_auth_cookie(response, token, remember_me=False)
@@ -3905,24 +3922,24 @@ async def change_password_required_handler(request: Request, db: Session = Depen
LOGGER.info(f"User {current_user.email} successfully changed their expired password")
return response
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=change_failed", status_code=303)
except AuthenticationError:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=invalid_password", status_code=303)
except PasswordValidationError as e:
LOGGER.warning(f"Password validation failed for {current_user.email}: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=weak_password", status_code=303)
except Exception as e:
LOGGER.error(f"Password change failed for {current_user.email}: {e}", exc_info=True)
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
except Exception as e:
LOGGER.error(f"Password change handler error: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
@@ -4492,7 +4509,7 @@ async def admin_list_teams(
if not current_user:
return HTMLResponse(content='
', status_code=200)
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
if unified:
# Generate unified team view
@@ -4576,7 +4593,7 @@ async def admin_create_team(
try:
# Get root path for URL construction
- root_path = request.scope.get("root_path", "") if request else ""
+ root_path = settings.app_root_path
form = await request.form()
name = form.get("name")
@@ -4718,7 +4735,7 @@ async def admin_view_team_members(
try:
# Get root_path from request
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Get current user context for logging and authorization
user_email = get_user_email(user)
@@ -4993,7 +5010,7 @@ async def admin_get_team_edit(
try:
# Get root path for URL construction
- root_path = _request.scope.get("root_path", "") if _request else ""
+ root_path = settings.app_root_path if _request else ""
team_service = TeamManagementService(db)
team = await team_service.get_team_by_id(team_id)
@@ -5072,7 +5089,7 @@ async def admin_update_team(
Response: Result of team update operation
"""
# Ensure root_path is available for URL construction in all branches
- root_path = request.scope.get("root_path", "") if request else ""
+ root_path = settings.app_root_path
if not settings.email_auth_enabled:
return HTMLResponse(content='Email authentication is disabled
', status_code=403)
@@ -5794,7 +5811,6 @@ async def admin_cancel_join_request(
@require_permission("teams.manage_members")
async def admin_list_join_requests(
team_id: str,
- request: Request,
db: Session = Depends(get_db),
user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
@@ -5815,7 +5831,6 @@ async def admin_list_join_requests(
try:
team_service = TeamManagementService(db)
user_email = get_user_email(user)
- request.scope.get("root_path", "")
# Get team and verify ownership
team = await team_service.get_team_by_id(team_id)
@@ -6131,6 +6146,10 @@ async def admin_list_users(
status_code=200,
)
+ # Get root_path from request
+ root_path = settings.app_root_path
+
+ # First-Party
LOGGER.debug(f"User {get_user_email(user)} requested user list (page={page}, per_page={per_page})")
auth_service = EmailAuthService(db)
@@ -6542,6 +6561,9 @@ async def admin_create_user(
HTMLResponse: Success message or error response
"""
try:
+ # Get root path for URL construction
+ root_path = settings.app_root_path
+
form = await request.form()
# Validate password strength
@@ -6602,7 +6624,7 @@ async def admin_get_user_edit(
try:
# Get root path for URL construction
- root_path = _request.scope.get("root_path", "") if _request else ""
+ root_path = settings.app_root_path if _request else ""
# First-Party
@@ -6836,7 +6858,7 @@ async def admin_activate_user(
try:
# Get root path for URL construction
- root_path = _request.scope.get("root_path", "") if _request else ""
+ root_path = settings.app_root_path if _request else ""
# First-Party
@@ -6881,7 +6903,7 @@ async def admin_deactivate_user(
try:
# Get root path for URL construction
- root_path = _request.scope.get("root_path", "") if _request else ""
+ root_path = settings.app_root_path if _request else ""
# First-Party
@@ -7011,7 +7033,7 @@ async def admin_force_password_change(
try:
# Get root path for URL construction
- root_path = _request.scope.get("root_path", "") if _request else ""
+ root_path = settings.app_root_path if _request else ""
auth_service = EmailAuthService(db)
@@ -7238,7 +7260,7 @@ async def admin_tools_partial_html(
"hx_target": "#tools-table-body",
"hx_indicator": "#tools-loading",
"query_params": query_params_dict,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
},
)
@@ -7250,7 +7272,7 @@ async def admin_tools_partial_html(
"request": request,
"data": data,
"pagination": pagination.model_dump(),
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"gateway_id": gateway_id,
},
)
@@ -7263,7 +7285,7 @@ async def admin_tools_partial_html(
"data": data,
"pagination": pagination.model_dump(),
"links": links.model_dump() if links else None,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"include_inactive": include_inactive,
},
)
@@ -7630,7 +7652,7 @@ async def admin_prompts_partial_html(
"hx_target": "#prompts-table-body",
"hx_indicator": "#prompts-loading",
"query_params": query_params,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
},
)
@@ -7641,7 +7663,7 @@ async def admin_prompts_partial_html(
"request": request,
"data": data,
"pagination": pagination.model_dump(),
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"gateway_id": gateway_id,
},
)
@@ -7653,7 +7675,169 @@ async def admin_prompts_partial_html(
"data": data,
"pagination": pagination.model_dump(),
"links": links.model_dump() if links else None,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
+ "include_inactive": include_inactive,
+ },
+ )
+
+
+@admin_router.get("/gateways/partial", response_class=HTMLResponse)
+async def admin_gateways_partial_html(
+ request: Request,
+ page: int = Query(1, ge=1, description="Page number (1-indexed)"),
+ per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
+ include_inactive: bool = False,
+ render: Optional[str] = Query(None),
+ team_id: Optional[str] = Depends(_validated_team_id_param),
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Return paginated gateways HTML partials for the admin UI.
+
+ This HTMX endpoint returns only the partial HTML used by the admin UI for
+ gateways. It supports three render modes:
+
+ - default: full table partial (rows + controls)
+ - ``render="controls"``: return only pagination controls
+ - ``render="selector"``: return selector items for infinite scroll
+
+ Args:
+ request (Request): FastAPI request object used by the template engine.
+ page (int): Page number (1-indexed).
+ per_page (int): Number of items per page (bounded by settings).
+ include_inactive (bool): If True, include inactive gateways in results.
+ render (Optional[str]): Render mode; one of None, "controls", "selector".
+ team_id (Optional[str]): Filter by team ID.
+ db (Session): Database session (dependency-injected).
+ user: Authenticated user object from dependency injection.
+
+ Returns:
+ Union[HTMLResponse, TemplateResponse]: A rendered template response
+ containing either the table partial, pagination controls, or selector
+ items depending on ``render``. The response contains JSON-serializable
+ encoded gateway data when templates expect it.
+ """
+ user_email = get_user_email(user)
+ LOGGER.info(f"🔷 GATEWAYS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}")
+ # Normalize per_page within configured bounds
+ per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
+
+ # Team scoping
+ team_service = TeamManagementService(db)
+ user_teams = await team_service.get_user_teams(user_email)
+ team_ids = [t.id for t in user_teams]
+
+ # Build base query
+ query = select(DbGateway)
+
+ if not include_inactive:
+ query = query.where(DbGateway.enabled.is_(True))
+
+ # Build access conditions
+ # When team_id is specified, show ONLY items from that team (simpler, team-scoped view)
+ # When team_id is NOT specified, show all accessible items (owned + team + public)
+ if team_id:
+ # Team-specific view: only show gateways from the specified team if user is a member
+ if team_id in team_ids:
+ # Apply visibility check: team/public resources + user's own resources (including private)
+ team_access = [
+ and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
+ and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
+ ]
+ query = query.where(or_(*team_access))
+ LOGGER.debug(f"Filtering gateways by team_id: {team_id}")
+ else:
+ # User is not a member of this team, return no results
+ LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
+ query = query.where(false())
+ else:
+ # All Teams view: apply standard access conditions
+ access_conditions = []
+ access_conditions.append(DbGateway.owner_email == user_email)
+ if team_ids:
+ access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
+ access_conditions.append(DbGateway.visibility == "public")
+
+ query = query.where(or_(*access_conditions))
+
+ # Apply pagination ordering for cursor support
+ query = query.order_by(desc(DbGateway.created_at), desc(DbGateway.id))
+
+ # Build query params for pagination links
+ query_params = {}
+ if include_inactive:
+ query_params["include_inactive"] = "true"
+ if team_id:
+ query_params["team_id"] = team_id
+
+ # Use unified pagination function
+ paginated_result = await paginate_query(
+ db=db,
+ query=query,
+ page=page,
+ per_page=per_page,
+ cursor=None, # HTMX partials use page-based navigation
+ base_url=f"{settings.app_root_path}/admin/gateways/partial",
+ query_params=query_params,
+ use_cursor_threshold=False, # Disable auto-cursor switching for UI
+ )
+
+ # Extract paginated gateways (DbGateway objects)
+ gateways_db = paginated_result["data"]
+ pagination = paginated_result["pagination"]
+ links = paginated_result["links"]
+
+ # Batch fetch team names for the gateways to avoid N+1 queries
+ team_ids_set = {p.team_id for p in gateways_db if p.team_id}
+ team_map = {}
+ if team_ids_set:
+ teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
+ team_map = {team.id: team.name for team in teams}
+
+ # Apply team names to DB objects before conversion
+ for p in gateways_db:
+ p.team = team_map.get(p.team_id) if p.team_id else None
+
+ # Batch convert to Pydantic models using gateway service
+ # This eliminates the N+1 query problem from calling get_gateway_details() in a loop
+ gateways_pydantic = [gateway_service.convert_gateway_to_read(p) for p in gateways_db]
+
+ data = jsonable_encoder(gateways_pydantic)
+ base_url = f"{settings.app_root_path}/admin/gateways/partial"
+
+ # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
+ db.commit()
+
+ LOGGER.info(f"🔷 GATEWAYS PARTIAL RESPONSE - Returning {len(data)} gateways, render mode: {render or 'default'}, team_id used in query: {team_id}")
+
+ if render == "controls":
+ return request.app.state.templates.TemplateResponse(
+ "pagination_controls.html",
+ {
+ "request": request,
+ "pagination": pagination.model_dump(),
+ "base_url": base_url,
+ "hx_target": "#gateways-table-body",
+ "hx_indicator": "#gateways-loading",
+ "query_params": query_params,
+ "root_path": request.scope.get("root_path", ""),
+ },
+ )
+
+ if render == "selector":
+ return request.app.state.templates.TemplateResponse(
+ "gateways_selector_items.html",
+ {"request": request, "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", "")},
+ )
+
+ return request.app.state.templates.TemplateResponse(
+ "gateways_partial.html",
+ {
+ "request": request,
+ "data": data,
+ "pagination": pagination.model_dump(),
+ "links": links.model_dump() if links else None,
+ "root_path": settings.app_root_path,
"include_inactive": include_inactive,
},
)
@@ -8298,7 +8482,7 @@ async def admin_resources_partial_html(
"hx_target": "#resources-table-body",
"hx_indicator": "#resources-loading",
"query_params": query_params,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
},
)
@@ -8309,7 +8493,7 @@ async def admin_resources_partial_html(
"request": request,
"data": data,
"pagination": pagination.model_dump(),
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"gateway_id": gateway_id,
},
)
@@ -8321,7 +8505,7 @@ async def admin_resources_partial_html(
"data": data,
"pagination": pagination.model_dump(),
"links": links.model_dump() if links else None,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"include_inactive": include_inactive,
},
)
@@ -9682,7 +9866,7 @@ async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depend
>>>
>>> async def test_admin_delete_tool_success():
... result = await admin_delete_tool(tool_id, mock_request_delete, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#tools" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#tools" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_delete_tool_success())
True
@@ -9730,6 +9914,9 @@ async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depend
LOGGER.error(f"Error deleting tool: {e}")
error_message = "Failed to delete tool. Please try again."
+ form = await request.form()
+ is_inactive_checked = str(form.get("is_inactive_checked", "false"))
+ root_path = settings.app_root_path
root_path = request.scope.get("root_path", "")
# Build redirect URL with error message if present
@@ -9741,7 +9928,7 @@ async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depend
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303)
- return RedirectResponse(f"{root_path}/admin#tools", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#tools", status_code=303)
@admin_router.post("/tools/{tool_id}/toggle")
@@ -9782,33 +9969,40 @@ async def admin_toggle_tool(
>>>
>>> # Happy path: Activate tool
>>> form_data_activate = FormData([("activate", "true"), ("is_inactive_checked", "false")])
- >>> mock_request_activate = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_activate = MagicMock(spec=Request)
>>> mock_request_activate.form = AsyncMock(return_value=form_data_activate)
>>> original_toggle_tool_status = tool_service.toggle_tool_status
>>> tool_service.toggle_tool_status = AsyncMock()
>>>
>>> async def test_admin_toggle_tool_activate():
... result = await admin_toggle_tool(tool_id, mock_request_activate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#tools" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#tools" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_tool_activate())
True
>>>
>>> # Happy path: Deactivate tool
>>> form_data_deactivate = FormData([("activate", "false"), ("is_inactive_checked", "false")])
- >>> mock_request_deactivate = MagicMock(spec=Request, scope={"root_path": "/api"})
+ >>> mock_request_deactivate = MagicMock(spec=Request)
>>> mock_request_deactivate.form = AsyncMock(return_value=form_data_deactivate)
+ >>>
+ >>> # Mock settings.app_root_path
+ >>> import mcpgateway.admin as admin_module
+ >>> original_app_root_path = admin_module.settings.app_root_path
+ >>> admin_module.settings.app_root_path = "/api"
>>>
>>> async def test_admin_toggle_tool_deactivate():
... result = await admin_toggle_tool(tool_id, mock_request_deactivate, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin#tools" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin/#tools" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_toggle_tool_deactivate())
True
+ >>> # Restore
+ >>> admin_module.settings.app_root_path = original_app_root_path
>>>
>>> # Edge case: Toggle with inactive checkbox checked
>>> form_data_inactive = FormData([("activate", "true"), ("is_inactive_checked", "true")])
- >>> mock_request_inactive = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_inactive = MagicMock(spec=Request)
>>> mock_request_inactive.form = AsyncMock(return_value=form_data_inactive)
>>>
>>> async def test_admin_toggle_tool_inactive_checked():
@@ -9820,7 +10014,7 @@ async def admin_toggle_tool(
>>>
>>> # Error path: Simulate an exception during toggle
>>> form_data_error = FormData([("activate", "true")])
- >>> mock_request_error = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_error = MagicMock(spec=Request)
>>> mock_request_error.form = AsyncMock(return_value=form_data_error)
>>> tool_service.toggle_tool_status = AsyncMock(side_effect=Exception("Toggle failed"))
>>>
@@ -9856,7 +10050,7 @@ async def admin_toggle_tool(
LOGGER.error(f"Error toggling tool status: {e}")
error_message = "Failed to toggle tool status. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -9867,7 +10061,7 @@ async def admin_toggle_tool(
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303)
- return RedirectResponse(f"{root_path}/admin#tools", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#tools", status_code=303)
@admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead)
@@ -10597,33 +10791,40 @@ async def admin_delete_gateway(gateway_id: str, request: Request, db: Session =
>>>
>>> # Happy path: Delete gateway
>>> form_data_delete = FormData([("is_inactive_checked", "false")])
- >>> mock_request_delete = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_delete = MagicMock(spec=Request)
>>> mock_request_delete.form = AsyncMock(return_value=form_data_delete)
>>> original_delete_gateway = gateway_service.delete_gateway
>>> gateway_service.delete_gateway = AsyncMock()
>>>
>>> async def test_admin_delete_gateway_success():
... result = await admin_delete_gateway(gateway_id, mock_request_delete, mock_db, mock_user)
- ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin#gateways" in result.headers["location"]
+ ... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/admin/#gateways" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_delete_gateway_success())
True
>>>
>>> # Edge case: Delete with inactive checkbox checked
>>> form_data_inactive = FormData([("is_inactive_checked", "true")])
- >>> mock_request_inactive = MagicMock(spec=Request, scope={"root_path": "/api"})
+ >>> mock_request_inactive = MagicMock(spec=Request)
>>> mock_request_inactive.form = AsyncMock(return_value=form_data_inactive)
>>>
+ >>> # Mock settings.app_root_path
+ >>> import mcpgateway.admin as admin_module
+ >>> original_app_root_path = admin_module.settings.app_root_path
+ >>> admin_module.settings.app_root_path = "/api"
+ >>>
>>> async def test_admin_delete_gateway_inactive_checked():
... result = await admin_delete_gateway(gateway_id, mock_request_inactive, mock_db, mock_user)
... return isinstance(result, RedirectResponse) and result.status_code == 303 and "/api/admin/?include_inactive=true#gateways" in result.headers["location"]
>>>
>>> asyncio.run(test_admin_delete_gateway_inactive_checked())
True
+ >>> # Restore
+ >>> admin_module.settings.app_root_path = original_app_root_path
>>>
>>> # Error path: Simulate an exception during deletion
>>> form_data_error = FormData([])
- >>> mock_request_error = MagicMock(spec=Request, scope={"root_path": ""})
+ >>> mock_request_error = MagicMock(spec=Request)
>>> mock_request_error.form = AsyncMock(return_value=form_data_error)
>>> gateway_service.delete_gateway = AsyncMock(side_effect=Exception("Deletion failed"))
>>>
@@ -10651,7 +10852,7 @@ async def admin_delete_gateway(gateway_id: str, request: Request, db: Session =
form = await request.form()
is_inactive_checked = str(form.get("is_inactive_checked", "false"))
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -10662,7 +10863,7 @@ async def admin_delete_gateway(gateway_id: str, request: Request, db: Session =
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303)
- return RedirectResponse(f"{root_path}/admin#gateways", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#gateways", status_code=303)
@admin_router.get("/resources/test/{resource_uri:path}")
@@ -11212,6 +11413,9 @@ async def admin_delete_resource(resource_id: str, request: Request, db: Session
except Exception as e:
LOGGER.error(f"Error deleting resource: {e}")
error_message = "Failed to delete resource. Please try again."
+ form = await request.form()
+ is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
+ root_path = settings.app_root_path
root_path = request.scope.get("root_path", "")
# Build redirect URL with error message if present
@@ -11223,7 +11427,7 @@ async def admin_delete_resource(resource_id: str, request: Request, db: Session
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303)
- return RedirectResponse(f"{root_path}/admin#resources", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#resources", status_code=303)
@admin_router.post("/resources/{resource_id}/toggle")
@@ -11337,7 +11541,7 @@ async def admin_toggle_resource(
LOGGER.error(f"Error toggling resource status: {e}")
error_message = "Failed to toggle resource status. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -11348,7 +11552,7 @@ async def admin_toggle_resource(
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303)
- return RedirectResponse(f"{root_path}/admin#resources", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#resources", status_code=303)
@admin_router.get("/prompts/{prompt_id}")
@@ -11773,6 +11977,9 @@ async def admin_delete_prompt(prompt_id: str, request: Request, db: Session = De
except Exception as e:
LOGGER.error(f"Error deleting prompt: {e}")
error_message = "Failed to delete prompt. Please try again."
+ form = await request.form()
+ is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
+ root_path = settings.app_root_path
root_path = request.scope.get("root_path", "")
# Build redirect URL with error message if present
@@ -11784,7 +11991,7 @@ async def admin_delete_prompt(prompt_id: str, request: Request, db: Session = De
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303)
- return RedirectResponse(f"{root_path}/admin#prompts", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#prompts", status_code=303)
@admin_router.post("/prompts/{prompt_id}/toggle")
@@ -11898,7 +12105,7 @@ async def admin_toggle_prompt(
LOGGER.error(f"Error toggling prompt status: {e}")
error_message = "Failed to toggle prompt status. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
@@ -11909,7 +12116,7 @@ async def admin_toggle_prompt(
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303)
- return RedirectResponse(f"{root_path}/admin#prompts", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#prompts", status_code=303)
@admin_router.post("/roots")
@@ -11963,8 +12170,8 @@ async def admin_add_root(request: Request, user=Depends(get_current_user_with_pe
if isinstance(name_value, str):
name = name_value
await root_service.add_root(uri, name)
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(f"{root_path}/admin#roots", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(f"{root_path}/admin/#roots", status_code=303)
@admin_router.post("/roots/{uri:path}/delete")
@@ -12024,11 +12231,11 @@ async def admin_delete_root(uri: str, request: Request, user=Depends(get_current
LOGGER.debug(f"User {get_user_email(user)} is deleting root URI {uri}")
await root_service.remove_root(uri)
form = await request.form()
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
if is_inactive_checked.lower() == "true":
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#roots", status_code=303)
- return RedirectResponse(f"{root_path}/admin#roots", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#roots", status_code=303)
# Metrics
@@ -12190,7 +12397,7 @@ async def admin_metrics_partial_html(
"entity_type": entity_type,
"data": data,
"pagination": pagination.model_dump(),
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
},
)
@@ -14510,8 +14717,8 @@ async def admin_toggle_a2a_agent(
HTTPException: If A2A features are disabled
"""
if not a2a_service or not settings.mcpgateway_a2a_enabled:
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(f"{root_path}/admin/#a2a-agents", status_code=303)
error_message = None
try:
@@ -14522,29 +14729,29 @@ async def admin_toggle_a2a_agent(
user_email = get_user_email(user)
await a2a_service.toggle_agent_status(db, agent_id, activate, user_email=user_email)
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(f"{root_path}/admin/#a2a-agents", status_code=303)
except PermissionError as e:
LOGGER.warning(f"Permission denied for user {user_email} toggling A2A agent status{agent_id}: {e}")
error_message = str(e)
except A2AAgentNotFoundError as e:
LOGGER.error(f"A2A agent toggle failed - not found: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
error_message = "A2A agent not found."
except Exception as e:
LOGGER.error(f"Error toggling A2A agent: {e}")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
error_message = "Failed to toggle status of A2A agent. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
error_param = f"?error={urllib.parse.quote(error_message)}"
return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303)
- return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#a2a-agents", status_code=303)
@admin_router.post("/a2a/{agent_id}/delete")
@@ -14569,8 +14776,8 @@ async def admin_delete_a2a_agent(
HTTPException: If A2A features are disabled
"""
if not a2a_service or not settings.mcpgateway_a2a_enabled:
- root_path = request.scope.get("root_path", "")
- return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
+ root_path = settings.app_root_path
+ return RedirectResponse(f"{root_path}/admin/#a2a-agents", status_code=303)
form = await request.form()
purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
@@ -14588,14 +14795,14 @@ async def admin_delete_a2a_agent(
LOGGER.error(f"Error deleting A2A agent: {e}")
error_message = "Failed to delete A2A agent. Please try again."
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Build redirect URL with error message if present
if error_message:
error_param = f"?error={urllib.parse.quote(error_message)}"
return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303)
- return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)
+ return RedirectResponse(f"{root_path}/admin/#a2a-agents", status_code=303)
@admin_router.post("/a2a/{agent_id}/test")
@@ -15185,7 +15392,7 @@ async def get_plugins_partial(request: Request, db: Session = Depends(get_db), u
stats = await plugin_service.get_plugin_statistics()
# Prepare context for template
- context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_manager is not None, "root_path": request.scope.get("root_path", "")}
+ context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_manager is not None, "root_path": settings.app_root_path}
# Render the partial template
return request.app.state.templates.TemplateResponse("plugins_partial.html", context)
@@ -15668,7 +15875,7 @@ async def catalog_partial(
if not settings.mcpgateway_catalog_enabled:
raise HTTPException(status_code=404, detail="Catalog feature is disabled")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
# Calculate pagination
page_size = settings.mcpgateway_catalog_page_size
@@ -15787,7 +15994,7 @@ async def get_system_stats(
{
"request": request,
"stats": stats,
- "root_path": request.scope.get("root_path", ""),
+ "root_path": settings.app_root_path,
"db_metrics_recording_enabled": settings.db_metrics_recording_enabled,
},
)
@@ -15952,7 +16159,7 @@ async def get_observability_partial(request: Request, _user=Depends(get_current_
Returns:
HTMLResponse: Rendered observability dashboard template
"""
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse("observability_partial.html", {"request": request, "root_path": root_path})
@@ -15968,7 +16175,7 @@ async def get_observability_metrics_partial(request: Request, _user=Depends(get_
Returns:
HTMLResponse: Rendered metrics dashboard template
"""
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse("observability_metrics.html", {"request": request, "root_path": root_path})
@@ -16105,7 +16312,7 @@ async def get_observability_traces(
# Get traces ordered by most recent
traces = query.order_by(ObservabilityTrace.start_time.desc()).limit(limit).all()
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse("observability_traces_list.html", {"request": request, "traces": traces, "root_path": root_path})
finally:
# Ensure close() always runs even if commit() fails
@@ -16138,7 +16345,7 @@ async def get_observability_trace_detail(request: Request, trace_id: str, _user=
if not trace:
raise HTTPException(status_code=404, detail="Trace not found")
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse("observability_trace_detail.html", {"request": request, "trace": trace, "root_path": root_path})
finally:
# Ensure close() always runs even if commit() fails
@@ -17465,7 +17672,7 @@ async def get_tools_partial(
Returns:
HTMLResponse: Rendered tool metrics dashboard partial
"""
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse(
"observability_tools.html",
{
@@ -17672,7 +17879,7 @@ async def get_prompts_partial(
Returns:
HTMLResponse: Rendered prompt metrics dashboard partial
"""
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse(
"observability_prompts.html",
{
@@ -17879,7 +18086,7 @@ async def get_resources_partial(
Returns:
HTMLResponse: Rendered resource metrics dashboard partial
"""
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
return request.app.state.templates.TemplateResponse(
"observability_resources.html",
{
diff --git a/mcpgateway/routers/oauth_router.py b/mcpgateway/routers/oauth_router.py
index eb3c68c64..7b6280a9d 100644
--- a/mcpgateway/routers/oauth_router.py
+++ b/mcpgateway/routers/oauth_router.py
@@ -260,7 +260,7 @@ async def oauth_callback(
try:
# Get root path for URL construction
- root_path = request.scope.get("root_path", "") if request else ""
+ root_path = settings.app_root_path
# Extract gateway_id from state parameter
# Try new base64-encoded JSON format first
diff --git a/mcpgateway/routers/sso.py b/mcpgateway/routers/sso.py
index 962ab1459..67d4c2aa3 100644
--- a/mcpgateway/routers/sso.py
+++ b/mcpgateway/routers/sso.py
@@ -202,7 +202,7 @@ async def handle_sso_callback(
raise HTTPException(status_code=404, detail="SSO authentication is disabled")
# Get root path for URL construction
- root_path = request.scope.get("root_path", "") if request else ""
+ root_path = settings.app_root_path
sso_service = SSOService(db)
@@ -228,7 +228,7 @@ async def handle_sso_callback(
# Third-Party
from fastapi.responses import RedirectResponse
- redirect_response = RedirectResponse(url=f"{root_path}/admin", status_code=302)
+ redirect_response = RedirectResponse(url=f"{root_path}/admin/", status_code=302)
# Set secure HTTP-only cookie using the same method as email auth
# First-Party
diff --git a/mcpgateway/utils/verify_credentials.py b/mcpgateway/utils/verify_credentials.py
index 69c1a28fb..7e98562fe 100644
--- a/mcpgateway/utils/verify_credentials.py
+++ b/mcpgateway/utils/verify_credentials.py
@@ -889,7 +889,7 @@ async def require_admin_auth(
accept_header = request.headers.get("accept", "")
if "text/html" in accept_header:
# Redirect browser to login page with error
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
raise HTTPException(status_code=status.HTTP_302_FOUND, detail="Admin privileges required", headers={"Location": f"{root_path}/admin/login?error=admin_required"})
else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
@@ -906,7 +906,7 @@ async def require_admin_auth(
# For 401, check if we should redirect browser users
accept_header = request.headers.get("accept", "")
if "text/html" in accept_header:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
raise HTTPException(status_code=status.HTTP_302_FOUND, detail="Authentication required", headers={"Location": f"{root_path}/admin/login"})
# If JWT auth fails, fall back to basic auth for backward compatibility
except Exception:
@@ -929,7 +929,7 @@ async def require_admin_auth(
accept_header = request.headers.get("accept", "")
is_htmx = request.headers.get("hx-request") == "true"
if "text/html" in accept_header or is_htmx:
- root_path = request.scope.get("root_path", "")
+ root_path = settings.app_root_path
raise HTTPException(status_code=status.HTTP_302_FOUND, detail="Authentication required", headers={"Location": f"{root_path}/admin/login"})
else:
raise HTTPException(