Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|^.secrets.baseline$",
"lines": null
},
"generated_at": "2026-04-12T07:51:41Z",
"generated_at": "2026-04-12T14:07:06Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -5086,31 +5086,31 @@
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
"is_secret": false,
"is_verified": false,
"line_number": 4256,
"line_number": 4261,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "559b05f1b2863e725b76e216ac3dadecbf92e244",
"is_secret": false,
"is_verified": false,
"line_number": 4864,
"line_number": 4869,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "a8af4759392d4f7496d613174f33afe2074a4b8d",
"is_secret": false,
"is_verified": false,
"line_number": 4866,
"line_number": 4871,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "85b60d811d16ff56b3654587d4487f713bfa33b7",
"is_secret": false,
"is_verified": false,
"line_number": 15084,
"line_number": 15089,
"type": "Secret Keyword",
"verified_result": null
}
Expand Down
11 changes: 8 additions & 3 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from pydantic_core import ValidationError as CoreValidationError
from sqlalchemy import and_, bindparam, case, cast, desc, false, func, or_, select, String, text
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
from sqlalchemy.orm import joinedload, selectinload, Session
from sqlalchemy.orm import joinedload, selectinload, Session, with_loader_criteria
from sqlalchemy.sql.functions import coalesce
from starlette.background import BackgroundTask
from starlette.datastructures import UploadFile as StarletteUploadFile
Expand Down Expand Up @@ -2674,8 +2674,8 @@ async def admin_servers_partial_html(
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),
):
user: dict = Depends(get_current_user_with_permissions),
) -> Response:
"""Return paginated servers HTML partials for the admin UI.

This HTMX endpoint returns only the partial HTML used by the admin UI for
Expand Down Expand Up @@ -2717,11 +2717,16 @@ async def admin_servers_partial_html(
team_ids = await _get_user_team_ids(user, db)

# Build base query with eager loading to avoid N+1 queries
# Filter out deactivated tools, resources, prompts, and agents at query level
query = select(DbServer).options(
selectinload(DbServer.tools),
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
selectinload(DbServer.resources),
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
selectinload(DbServer.prompts),
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
selectinload(DbServer.a2a_agents),
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
joinedload(DbServer.email_team),
)

Expand Down
23 changes: 21 additions & 2 deletions mcpgateway/services/export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@

# Third-Party
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload, Session
from sqlalchemy.orm import selectinload, Session, with_loader_criteria

# First-Party
from mcpgateway.config import settings
from mcpgateway.db import A2AAgent as DbA2AAgent
from mcpgateway.db import Gateway as DbGateway
from mcpgateway.db import Prompt as DbPrompt
from mcpgateway.db import Resource as DbResource
Expand Down Expand Up @@ -981,7 +982,25 @@ async def _export_selected_servers(
return []

# Batch query for selected servers with eager loading to avoid N+1 queries
db_servers = db.execute(select(DbServer).options(selectinload(DbServer.tools)).where(DbServer.id.in_(server_ids))).scalars().all()
# Filter out deactivated tools, resources, prompts, and agents at query level
db_servers = (
db.execute(
select(DbServer)
.options(
selectinload(DbServer.tools),
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
selectinload(DbServer.resources),
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
selectinload(DbServer.prompts),
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
selectinload(DbServer.a2a_agents),
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
)
.where(DbServer.id.in_(server_ids))
)
.scalars()
.all()
)
if visible_server_ids is not None:
db_servers = [db_server for db_server in db_servers if str(db_server.id) in visible_server_ids]

Expand Down
27 changes: 21 additions & 6 deletions mcpgateway/services/server_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from pydantic import ValidationError
from sqlalchemy import and_, delete, desc, or_, select
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import joinedload, selectinload, Session
from sqlalchemy.orm import joinedload, selectinload, Session, with_loader_criteria

# First-Party
from mcpgateway.config import settings
Expand Down Expand Up @@ -359,11 +359,12 @@ def convert_server_to_read(self, server: DbServer, include_metrics: bool = False
else:
server_dict["metrics"] = None
# Add associated IDs from relationships
server_dict["associated_tools"] = [tool.name for tool in server.tools] if server.tools else []
server_dict["associated_tool_ids"] = [str(tool.id) for tool in server.tools] if server.tools else []
server_dict["associated_resources"] = [res.id for res in server.resources] if server.resources else []
server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else []
server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents] if server.a2a_agents else []
# Filter out deactivated entities for consistent API responses
server_dict["associated_tools"] = [tool.name for tool in server.tools if getattr(tool, "enabled", True)] if server.tools else []
server_dict["associated_tool_ids"] = [str(tool.id) for tool in server.tools if getattr(tool, "enabled", True)] if server.tools else []
server_dict["associated_resources"] = [res.id for res in server.resources if getattr(res, "enabled", True)] if server.resources else []
server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts if getattr(prompt, "enabled", True)] if server.prompts else []
server_dict["associated_a2a_agents"] = [agent.id for agent in server.a2a_agents if getattr(agent, "enabled", True)] if server.a2a_agents else []

# Team name is loaded via server.team property from email_team relationship
server_dict["team"] = getattr(server, "team", None)
Expand Down Expand Up @@ -790,13 +791,18 @@ async def list_servers(
return (cached_servers, cached.get("next_cursor"))

# Build base query with ordering and eager load relationships to avoid N+1
# Filter out deactivated tools, resources, prompts, and agents at query level
query = (
select(DbServer)
.options(
selectinload(DbServer.tools),
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
selectinload(DbServer.resources),
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
selectinload(DbServer.prompts),
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
selectinload(DbServer.a2a_agents),
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
joinedload(DbServer.email_team),
)
.order_by(desc(DbServer.created_at), desc(DbServer.id))
Expand Down Expand Up @@ -903,11 +909,16 @@ async def list_servers_for_user(
team_ids = [team.id for team in user_teams]

# Eager load relationships to avoid N+1 queries
# Filter out deactivated tools, resources, prompts, and agents at query level
query = select(DbServer).options(
selectinload(DbServer.tools),
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
selectinload(DbServer.resources),
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
selectinload(DbServer.prompts),
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
selectinload(DbServer.a2a_agents),
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
joinedload(DbServer.email_team),
)

Expand Down Expand Up @@ -994,9 +1005,13 @@ async def get_server(self, db: Session, server_id: str) -> ServerRead:
select(DbServer)
.options(
selectinload(DbServer.tools),
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
selectinload(DbServer.resources),
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
selectinload(DbServer.prompts),
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
selectinload(DbServer.a2a_agents),
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
joinedload(DbServer.email_team),
)
.where(DbServer.id == server_id)
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/mcpgateway/services/test_export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1774,3 +1774,45 @@ async def test_export_selected_gateways_encodes_dict_auth_value(export_service,
assert exported[0]["auth_type"] == "authheaders"
assert isinstance(exported[0]["auth_value"], str)
assert decode_auth(exported[0]["auth_value"]) == auth_dict


@pytest.mark.asyncio
async def test_export_selected_servers_filters_deactivated_entities(export_service):
"""Test that _export_selected_servers filters out deactivated tools, prompts, resources, and agents."""
mock_db = MagicMock()

# Create mock entities - one enabled, one disabled for each type
enabled_tool = SimpleNamespace(id="tool-1", name="enabled_tool", enabled=True)

# Create mock server with only enabled entities (due to with_loader_criteria filter)
mock_server = SimpleNamespace(
id="server-1",
name="test_server",
description="Test server for export",
enabled=True,
tags=[],
# Only enabled entities should be loaded due to with_loader_criteria()
tools=[enabled_tool],
)

# Mock the database execute chain properly
mock_scalars = MagicMock()
mock_scalars.all.return_value = [mock_server]
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
mock_db.execute.return_value = mock_result

# Call _export_selected_servers
exported = await export_service._export_selected_servers(mock_db, ["server-1"], root_path="", user_email=None, token_teams=None)

# Verify that only enabled entities are in the exported data
assert len(exported) == 1, f"Expected 1 server, got {len(exported)}"
server_data = exported[0]

# Check that only enabled tool is included (key is "tool_ids" not "associated_tool_ids")
assert "tool_ids" in server_data, f"Expected 'tool_ids' key in {server_data.keys()}"
assert len(server_data["tool_ids"]) == 1, f"Expected 1 tool, got {len(server_data['tool_ids'])}"
assert "tool-1" in server_data["tool_ids"], f"Expected 'tool-1' in {server_data['tool_ids']}"

# Verify the database execute was called
assert mock_db.execute.called, "Database execute should have been called"
Loading
Loading
โšก