Skip to content

Commit ac77a19

Browse files
msureshkumar88Suresh Kumar Moharajanjonpspriclaude
authored andcommitted
fix(ui): hide deactivated entities in admin UI catalog and API (#3462)
* resolve conflict Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * add invalid query to get_server , list_servers and list_servers_for_user improve test coverage Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * add remaing two location Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix precommit issues Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: harden deactivated-entity filtering on write paths and clean up tests - Remove with_loader_criteria from update_server and set_server_state write paths to prevent filtered collections from interfering with association replacement on commit - Add enabled-attribute filtering in convert_server_to_read as the single authoritative enforcement point for all API responses - Remove 9 unused disabled_* variables from test methods (ruff F841) - Apply black/isort formatting to test files - Add type annotations to admin_servers_partial_html (user: dict, -> Response) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> Co-authored-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51a72a3 commit ac77a19

6 files changed

Lines changed: 307 additions & 19 deletions

File tree

.secrets.baseline

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-12T07:51:41Z",
6+
"generated_at": "2026-04-12T14:07:06Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -5086,31 +5086,31 @@
50865086
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
50875087
"is_secret": false,
50885088
"is_verified": false,
5089-
"line_number": 4256,
5089+
"line_number": 4261,
50905090
"type": "Secret Keyword",
50915091
"verified_result": null
50925092
},
50935093
{
50945094
"hashed_secret": "559b05f1b2863e725b76e216ac3dadecbf92e244",
50955095
"is_secret": false,
50965096
"is_verified": false,
5097-
"line_number": 4864,
5097+
"line_number": 4869,
50985098
"type": "Secret Keyword",
50995099
"verified_result": null
51005100
},
51015101
{
51025102
"hashed_secret": "a8af4759392d4f7496d613174f33afe2074a4b8d",
51035103
"is_secret": false,
51045104
"is_verified": false,
5105-
"line_number": 4866,
5105+
"line_number": 4871,
51065106
"type": "Secret Keyword",
51075107
"verified_result": null
51085108
},
51095109
{
51105110
"hashed_secret": "85b60d811d16ff56b3654587d4487f713bfa33b7",
51115111
"is_secret": false,
51125112
"is_verified": false,
5113-
"line_number": 15084,
5113+
"line_number": 15089,
51145114
"type": "Secret Keyword",
51155115
"verified_result": null
51165116
}

mcpgateway/admin.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from pydantic_core import ValidationError as CoreValidationError
5656
from sqlalchemy import and_, bindparam, case, cast, desc, false, func, or_, select, String, text
5757
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
58-
from sqlalchemy.orm import joinedload, selectinload, Session
58+
from sqlalchemy.orm import joinedload, selectinload, Session, with_loader_criteria
5959
from sqlalchemy.sql.functions import coalesce
6060
from starlette.background import BackgroundTask
6161
from starlette.datastructures import UploadFile as StarletteUploadFile
@@ -2674,8 +2674,8 @@ async def admin_servers_partial_html(
26742674
render: Optional[str] = Query(None),
26752675
team_id: Optional[str] = Depends(_validated_team_id_param),
26762676
db: Session = Depends(get_db),
2677-
user=Depends(get_current_user_with_permissions),
2678-
):
2677+
user: dict = Depends(get_current_user_with_permissions),
2678+
) -> Response:
26792679
"""Return paginated servers HTML partials for the admin UI.
26802680

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

27192719
# Build base query with eager loading to avoid N+1 queries
2720+
# Filter out deactivated tools, resources, prompts, and agents at query level
27202721
query = select(DbServer).options(
27212722
selectinload(DbServer.tools),
2723+
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
27222724
selectinload(DbServer.resources),
2725+
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
27232726
selectinload(DbServer.prompts),
2727+
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
27242728
selectinload(DbServer.a2a_agents),
2729+
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
27252730
joinedload(DbServer.email_team),
27262731
)
27272732

mcpgateway/services/export_service.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
2323

2424
# Third-Party
2525
from sqlalchemy import or_, select
26-
from sqlalchemy.orm import selectinload, Session
26+
from sqlalchemy.orm import selectinload, Session, with_loader_criteria
2727

2828
# First-Party
2929
from mcpgateway.config import settings
30+
from mcpgateway.db import A2AAgent as DbA2AAgent
3031
from mcpgateway.db import Gateway as DbGateway
3132
from mcpgateway.db import Prompt as DbPrompt
3233
from mcpgateway.db import Resource as DbResource
@@ -981,7 +982,25 @@ async def _export_selected_servers(
981982
return []
982983

983984
# Batch query for selected servers with eager loading to avoid N+1 queries
984-
db_servers = db.execute(select(DbServer).options(selectinload(DbServer.tools)).where(DbServer.id.in_(server_ids))).scalars().all()
985+
# Filter out deactivated tools, resources, prompts, and agents at query level
986+
db_servers = (
987+
db.execute(
988+
select(DbServer)
989+
.options(
990+
selectinload(DbServer.tools),
991+
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
992+
selectinload(DbServer.resources),
993+
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
994+
selectinload(DbServer.prompts),
995+
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
996+
selectinload(DbServer.a2a_agents),
997+
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
998+
)
999+
.where(DbServer.id.in_(server_ids))
1000+
)
1001+
.scalars()
1002+
.all()
1003+
)
9851004
if visible_server_ids is not None:
9861005
db_servers = [db_server for db_server in db_servers if str(db_server.id) in visible_server_ids]
9871006

mcpgateway/services/server_service.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from pydantic import ValidationError
2323
from sqlalchemy import and_, delete, desc, or_, select
2424
from sqlalchemy.exc import IntegrityError, OperationalError
25-
from sqlalchemy.orm import joinedload, selectinload, Session
25+
from sqlalchemy.orm import joinedload, selectinload, Session, with_loader_criteria
2626

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

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

792793
# Build base query with ordering and eager load relationships to avoid N+1
794+
# Filter out deactivated tools, resources, prompts, and agents at query level
793795
query = (
794796
select(DbServer)
795797
.options(
796798
selectinload(DbServer.tools),
799+
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
797800
selectinload(DbServer.resources),
801+
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
798802
selectinload(DbServer.prompts),
803+
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
799804
selectinload(DbServer.a2a_agents),
805+
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
800806
joinedload(DbServer.email_team),
801807
)
802808
.order_by(desc(DbServer.created_at), desc(DbServer.id))
@@ -903,11 +909,16 @@ async def list_servers_for_user(
903909
team_ids = [team.id for team in user_teams]
904910

905911
# Eager load relationships to avoid N+1 queries
912+
# Filter out deactivated tools, resources, prompts, and agents at query level
906913
query = select(DbServer).options(
907914
selectinload(DbServer.tools),
915+
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
908916
selectinload(DbServer.resources),
917+
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
909918
selectinload(DbServer.prompts),
919+
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
910920
selectinload(DbServer.a2a_agents),
921+
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
911922
joinedload(DbServer.email_team),
912923
)
913924

@@ -994,9 +1005,13 @@ async def get_server(self, db: Session, server_id: str) -> ServerRead:
9941005
select(DbServer)
9951006
.options(
9961007
selectinload(DbServer.tools),
1008+
with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
9971009
selectinload(DbServer.resources),
1010+
with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
9981011
selectinload(DbServer.prompts),
1012+
with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
9991013
selectinload(DbServer.a2a_agents),
1014+
with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
10001015
joinedload(DbServer.email_team),
10011016
)
10021017
.where(DbServer.id == server_id)

tests/unit/mcpgateway/services/test_export_service.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,3 +1774,45 @@ async def test_export_selected_gateways_encodes_dict_auth_value(export_service,
17741774
assert exported[0]["auth_type"] == "authheaders"
17751775
assert isinstance(exported[0]["auth_value"], str)
17761776
assert decode_auth(exported[0]["auth_value"]) == auth_dict
1777+
1778+
1779+
@pytest.mark.asyncio
1780+
async def test_export_selected_servers_filters_deactivated_entities(export_service):
1781+
"""Test that _export_selected_servers filters out deactivated tools, prompts, resources, and agents."""
1782+
mock_db = MagicMock()
1783+
1784+
# Create mock entities - one enabled, one disabled for each type
1785+
enabled_tool = SimpleNamespace(id="tool-1", name="enabled_tool", enabled=True)
1786+
1787+
# Create mock server with only enabled entities (due to with_loader_criteria filter)
1788+
mock_server = SimpleNamespace(
1789+
id="server-1",
1790+
name="test_server",
1791+
description="Test server for export",
1792+
enabled=True,
1793+
tags=[],
1794+
# Only enabled entities should be loaded due to with_loader_criteria()
1795+
tools=[enabled_tool],
1796+
)
1797+
1798+
# Mock the database execute chain properly
1799+
mock_scalars = MagicMock()
1800+
mock_scalars.all.return_value = [mock_server]
1801+
mock_result = MagicMock()
1802+
mock_result.scalars.return_value = mock_scalars
1803+
mock_db.execute.return_value = mock_result
1804+
1805+
# Call _export_selected_servers
1806+
exported = await export_service._export_selected_servers(mock_db, ["server-1"], root_path="", user_email=None, token_teams=None)
1807+
1808+
# Verify that only enabled entities are in the exported data
1809+
assert len(exported) == 1, f"Expected 1 server, got {len(exported)}"
1810+
server_data = exported[0]
1811+
1812+
# Check that only enabled tool is included (key is "tool_ids" not "associated_tool_ids")
1813+
assert "tool_ids" in server_data, f"Expected 'tool_ids' key in {server_data.keys()}"
1814+
assert len(server_data["tool_ids"]) == 1, f"Expected 1 tool, got {len(server_data['tool_ids'])}"
1815+
assert "tool-1" in server_data["tool_ids"], f"Expected 'tool-1' in {server_data['tool_ids']}"
1816+
1817+
# Verify the database execute was called
1818+
assert mock_db.execute.called, "Database execute should have been called"

0 commit comments

Comments
 (0)