Skip to content

Commit a02ac5e

Browse files
fix(visibility): prevent auto-refresh from overriding tool/prompt/resource visibility (#3574)
* restrict visibility change with auto refresh Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * linting Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix: formatting cleanup in test_gateway_service_extended.py Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(visibility): new tools inherit gateway visibility instead of hardcoded 'public' _create_db_tool() hardcoded visibility="public" for all newly discovered tools, while resources and prompts correctly used gateway.visibility. This caused new tools on team-scoped or private gateways to be created with public visibility, breaking team isolation. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(visibility): use explicit update_visibility flag instead of created_via check The previous check `should_update_visibility = created_via == "update"` was too broad: any gateway edit (e.g. description change) would trigger visibility propagation to all linked tools/resources/prompts, overwriting per-item customizations. Replace with an explicit `update_visibility: bool` parameter on the three sync helpers, set to True only when the user actually changed the gateway's visibility in the request (`gateway_update.visibility is not None`). This aligns the helper-level gate with the direct propagation logic at lines 1902-1912 which already checks the same condition. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 336b124 commit a02ac5e

File tree

2 files changed

+171
-26
lines changed

2 files changed

+171
-26
lines changed

mcpgateway/services/gateway_service.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,14 +2078,16 @@ async def update_gateway(
20782078
gateway.auth_value = None
20792079
gateway.oauth_config = None
20802080

2081-
# Update tools using helper method
2082-
tools_to_add = self._update_or_create_tools(db, tools, gateway, "update")
2081+
# Update tools using helper method — only propagate visibility
2082+
# when the user explicitly changed it in this request
2083+
_vis_changed = gateway_update.visibility is not None
2084+
tools_to_add = self._update_or_create_tools(db, tools, gateway, "update", update_visibility=_vis_changed)
20832085

20842086
# Update resources using helper method
2085-
resources_to_add = self._update_or_create_resources(db, resources, gateway, "update")
2087+
resources_to_add = self._update_or_create_resources(db, resources, gateway, "update", update_visibility=_vis_changed)
20862088

20872089
# Update prompts using helper method
2088-
prompts_to_add = self._update_or_create_prompts(db, prompts, gateway, "update")
2090+
prompts_to_add = self._update_or_create_prompts(db, prompts, gateway, "update", update_visibility=_vis_changed)
20892091

20902092
# Log newly added items
20912093
items_added = len(tools_to_add) + len(resources_to_add) + len(prompts_to_add)
@@ -4051,17 +4053,18 @@ def _create_db_tool(
40514053
# Inherit team assignment and visibility from gateway
40524054
team_id=gateway.team_id,
40534055
owner_email=gateway.owner_email,
4054-
visibility="public", # Federated tools should be public for discovery
4056+
visibility=gateway.visibility,
40554057
)
40564058

4057-
def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGateway, created_via: str) -> List[DbTool]:
4059+
def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGateway, created_via: str, update_visibility: bool = False) -> List[DbTool]:
40584060
"""Helper to handle update-or-create logic for tools from MCP server.
40594061
40604062
Args:
40614063
db: Database session
40624064
tools: List of tools from MCP server
40634065
gateway: Gateway object
40644066
created_via: String indicating creation source ("oauth", "update", etc.)
4067+
update_visibility: Whether to propagate gateway visibility to existing tools
40654068
40664069
Returns:
40674070
List of new tools to be added to the database
@@ -4123,7 +4126,8 @@ def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGate
41234126
except Exception:
41244127
gateway_tool_auth_value = encode_auth(gateway.auth_value) if isinstance(gateway.auth_value, dict) else gateway.auth_value
41254128
auth_value_changed = existing_tool.auth_value != gateway_tool_auth_value
4126-
auth_fields_changed = existing_tool.auth_type != gateway.auth_type or auth_value_changed or existing_tool.visibility != gateway.visibility
4129+
4130+
auth_fields_changed = existing_tool.auth_type != gateway.auth_type or auth_value_changed or (update_visibility and existing_tool.visibility != gateway.visibility)
41274131

41284132
if basic_fields_changed or schema_fields_changed or auth_fields_changed:
41294133
fields_to_update = True
@@ -4142,7 +4146,8 @@ def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGate
41424146
existing_tool.jsonpath_filter = tool.jsonpath_filter
41434147
existing_tool.auth_type = gateway.auth_type
41444148
existing_tool.auth_value = encode_auth(gateway.auth_value) if isinstance(gateway.auth_value, dict) else gateway.auth_value
4145-
existing_tool.visibility = gateway.visibility
4149+
if update_visibility:
4150+
existing_tool.visibility = gateway.visibility
41464151
logger.debug(f"Updated existing tool: {tool.name}")
41474152
else:
41484153
# Create new tool if it doesn't exist
@@ -4162,14 +4167,15 @@ def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGate
41624167

41634168
return tools_to_add
41644169

4165-
def _update_or_create_resources(self, db: Session, resources: List[Any], gateway: DbGateway, created_via: str) -> List[DbResource]:
4170+
def _update_or_create_resources(self, db: Session, resources: List[Any], gateway: DbGateway, created_via: str, update_visibility: bool = False) -> List[DbResource]:
41664171
"""Helper to handle update-or-create logic for resources from MCP server.
41674172
41684173
Args:
41694174
db: Database session
41704175
resources: List of resources from MCP server
41714176
gateway: Gateway object
41724177
created_via: String indicating creation source ("oauth", "update", etc.)
4178+
update_visibility: Whether to propagate gateway visibility to existing resources
41734179
41744180
Returns:
41754181
List of new resources to be added to the database
@@ -4206,7 +4212,7 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway
42064212
or existing_resource.description != resource.description
42074213
or existing_resource.mime_type != resource.mime_type
42084214
or existing_resource.uri_template != resource.uri_template
4209-
or existing_resource.visibility != gateway.visibility
4215+
or (update_visibility and existing_resource.visibility != gateway.visibility)
42104216
):
42114217
fields_to_update = True
42124218

@@ -4215,7 +4221,8 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway
42154221
existing_resource.description = resource.description
42164222
existing_resource.mime_type = resource.mime_type
42174223
existing_resource.uri_template = resource.uri_template
4218-
existing_resource.visibility = gateway.visibility
4224+
if update_visibility:
4225+
existing_resource.visibility = gateway.visibility
42194226
logger.debug(f"Updated existing resource: {resource.uri}")
42204227
else:
42214228
# Create new resource if it doesn't exist
@@ -4238,14 +4245,15 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway
42384245

42394246
return resources_to_add
42404247

4241-
def _update_or_create_prompts(self, db: Session, prompts: List[Any], gateway: DbGateway, created_via: str) -> List[DbPrompt]:
4248+
def _update_or_create_prompts(self, db: Session, prompts: List[Any], gateway: DbGateway, created_via: str, update_visibility: bool = False) -> List[DbPrompt]:
42424249
"""Helper to handle update-or-create logic for prompts from MCP server.
42434250
42444251
Args:
42454252
db: Database session
42464253
prompts: List of prompts from MCP server
42474254
gateway: Gateway object
42484255
created_via: String indicating creation source ("oauth", "update", etc.)
4256+
update_visibility: Whether to propagate gateway visibility to existing prompts
42494257
42504258
Returns:
42514259
List of new prompts to be added to the database
@@ -4280,14 +4288,15 @@ def _update_or_create_prompts(self, db: Session, prompts: List[Any], gateway: Db
42804288
if (
42814289
existing_prompt.description != prompt.description
42824290
or existing_prompt.template != (prompt.template if hasattr(prompt, "template") else "")
4283-
or existing_prompt.visibility != gateway.visibility
4291+
or (update_visibility and existing_prompt.visibility != gateway.visibility)
42844292
):
42854293
fields_to_update = True
42864294

42874295
if fields_to_update:
42884296
existing_prompt.description = prompt.description
42894297
existing_prompt.template = prompt.template if hasattr(prompt, "template") else ""
4290-
existing_prompt.visibility = gateway.visibility
4298+
if update_visibility:
4299+
existing_prompt.visibility = gateway.visibility
42914300
logger.debug(f"Updated existing prompt: {prompt.name}")
42924301
else:
42934302
# Create new prompt if it doesn't exist

tests/unit/mcpgateway/services/test_gateway_service_extended.py

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -700,8 +700,8 @@ async def test_update_or_create_tools_existing_tools(self):
700700
tools = [mock_tool]
701701
context = "update"
702702

703-
# Call the helper method
704-
result = service._update_or_create_tools(mock_db, tools, mock_gateway, context)
703+
# Call the helper method (with explicit visibility change)
704+
result = service._update_or_create_tools(mock_db, tools, mock_gateway, context, update_visibility=True)
705705

706706
# Should return empty list (no new tools, existing one updated)
707707
assert len(result) == 0
@@ -851,8 +851,8 @@ async def test_update_or_create_resources_existing_resources(self):
851851
resources = [mock_resource]
852852
context = "update"
853853

854-
# Call method
855-
result = service._update_or_create_resources(mock_db, resources, mock_gateway, context)
854+
# Call method (with explicit visibility change)
855+
result = service._update_or_create_resources(mock_db, resources, mock_gateway, context, update_visibility=True)
856856

857857
# Should return empty list (no new resources)
858858
assert len(result) == 0
@@ -943,8 +943,8 @@ async def test_update_or_create_prompts_existing_prompts(self):
943943
prompts = [mock_prompt]
944944
context = "update"
945945

946-
# Call the helper method
947-
result = service._update_or_create_prompts(mock_db, prompts, mock_gateway, context)
946+
# Call the helper method (with explicit visibility change)
947+
result = service._update_or_create_prompts(mock_db, prompts, mock_gateway, context, update_visibility=True)
948948

949949
# Should return empty list (no new prompts, existing one updated)
950950
assert len(result) == 0
@@ -1048,7 +1048,7 @@ async def test_helper_methods_mixed_operations(self):
10481048
assert existing_tool2.original_description == "Updated description"
10491049
assert existing_tool2.url == "http://new.com" # Updated from gateway
10501050
assert existing_tool2.auth_type == "bearer" # Updated from gateway
1051-
assert existing_tool2.visibility == "public" # Updated from gateway
1051+
assert existing_tool2.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
10521052

10531053
@pytest.mark.asyncio
10541054
async def test_helper_methods_empty_input_lists(self):
@@ -1277,7 +1277,7 @@ async def test_helper_methods_tool_removal_scenario(self):
12771277
# existing_tool1 should be updated with gateway values (even if description stays the same)
12781278
assert existing_tool1.url == "http://new.com" # Updated from gateway
12791279
assert existing_tool1.auth_type == "bearer" # Updated from gateway
1280-
assert existing_tool1.visibility == "public" # Updated from gateway
1280+
assert existing_tool1.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
12811281

12821282
# existing_tool3 should be updated (description not customized, so upstream value applies)
12831283
assert existing_tool3.description == "Updated description"
@@ -1350,13 +1350,13 @@ async def test_helper_methods_resource_removal_scenario(self):
13501350

13511351
# existing_resource1 should be updated with gateway values
13521352
assert existing_resource1.description == "Keep this resource"
1353-
assert existing_resource1.visibility == "public" # Updated from gateway
1353+
assert existing_resource1.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
13541354

13551355
# existing_resource3 should be updated
13561356
assert existing_resource3.description == "Updated description"
13571357
assert existing_resource3.mime_type == "application/json"
13581358
assert existing_resource3.uri_template == "new template"
1359-
assert existing_resource3.visibility == "public" # Updated from gateway
1359+
assert existing_resource3.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
13601360

13611361
@pytest.mark.asyncio
13621362
async def test_helper_methods_prompt_removal_scenario(self):
@@ -1416,12 +1416,12 @@ async def test_helper_methods_prompt_removal_scenario(self):
14161416
# existing_prompt1 should be updated with gateway values
14171417
assert existing_prompt1.description == "Keep this prompt"
14181418
assert existing_prompt1.template == "Keep template"
1419-
assert existing_prompt1.visibility == "public" # Updated from gateway
1419+
assert existing_prompt1.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
14201420

14211421
# existing_prompt3 should be updated
14221422
assert existing_prompt3.description == "Updated description"
14231423
assert existing_prompt3.template == "Updated template"
1424-
assert existing_prompt3.visibility == "public" # Updated from gateway
1424+
assert existing_prompt3.visibility == "private" # Visibility NOT updated from gateway because context is NOT update
14251425

14261426
@pytest.mark.asyncio
14271427
async def test_fetch_tools_after_oauth_prompt_stale_removal_uses_original_name(self):
@@ -1536,3 +1536,139 @@ async def test_helper_methods_complete_removal_scenario(self):
15361536
assert tools_to_remove[0].original_name == "old_tool"
15371537
assert resources_to_remove[0].uri == "file:///old.txt"
15381538
assert prompts_to_remove[0].name == "old_prompt"
1539+
1540+
@pytest.mark.asyncio
1541+
async def test_update_visibility_logic(self):
1542+
"""Test that existing items retain visibility on auto-refresh, but inherit on manual update."""
1543+
service = GatewayService()
1544+
mock_db = MagicMock()
1545+
mock_gateway = MagicMock()
1546+
mock_gateway.id = "gw"
1547+
mock_gateway.url = "http://gw.com"
1548+
mock_gateway.auth_type = "none"
1549+
mock_gateway.visibility = "public"
1550+
1551+
# Mock tools
1552+
existing_tool = MagicMock()
1553+
existing_tool.original_name = "test_tool"
1554+
existing_tool.description = "Test Tool"
1555+
existing_tool.original_description = "Test Tool"
1556+
existing_tool.visibility = "private"
1557+
1558+
tool_from_server = MagicMock()
1559+
tool_from_server.name = "test_tool"
1560+
tool_from_server.description = "Test Tool"
1561+
1562+
# Mock resources
1563+
existing_res = MagicMock()
1564+
existing_res.uri = "file:///test"
1565+
existing_res.name = "test"
1566+
existing_res.description = "Test Res"
1567+
existing_res.visibility = "team"
1568+
1569+
res_from_server = MagicMock()
1570+
res_from_server.uri = "file:///test"
1571+
res_from_server.name = "test"
1572+
res_from_server.description = "Test Res"
1573+
1574+
# Mock prompts
1575+
existing_prompt = MagicMock()
1576+
existing_prompt.original_name = "test_prompt"
1577+
existing_prompt.name = "test_prompt"
1578+
existing_prompt.description = "Test Prompt"
1579+
existing_prompt.visibility = "private"
1580+
1581+
prompt_from_server = MagicMock()
1582+
prompt_from_server.name = "test_prompt"
1583+
prompt_from_server.description = "Test Prompt"
1584+
1585+
# --- Test 1: AUTO REFRESH Context ---
1586+
def create_mock_result(item):
1587+
mock_result = MagicMock()
1588+
mock_result.scalars.return_value.all.return_value = [item]
1589+
return mock_result
1590+
1591+
# Reset visibilities
1592+
existing_tool.visibility = "private"
1593+
existing_res.visibility = "team"
1594+
existing_prompt.visibility = "private"
1595+
1596+
mock_db.execute.side_effect = [
1597+
create_mock_result(existing_tool),
1598+
]
1599+
service._update_or_create_tools(mock_db, [tool_from_server], mock_gateway, "auto_refresh")
1600+
assert existing_tool.visibility == "private"
1601+
1602+
mock_db.execute.side_effect = [
1603+
create_mock_result(existing_res),
1604+
]
1605+
service._update_or_create_resources(mock_db, [res_from_server], mock_gateway, "health_check")
1606+
assert existing_res.visibility == "team"
1607+
1608+
mock_db.execute.side_effect = [
1609+
create_mock_result(existing_prompt),
1610+
]
1611+
service._update_or_create_prompts(mock_db, [prompt_from_server], mock_gateway, "rediscovery")
1612+
assert existing_prompt.visibility == "private"
1613+
1614+
# --- Test 2: MANUAL UPDATE with explicit visibility change ---
1615+
mock_db.execute.side_effect = [
1616+
create_mock_result(existing_tool),
1617+
]
1618+
service._update_or_create_tools(mock_db, [tool_from_server], mock_gateway, "update", update_visibility=True)
1619+
assert existing_tool.visibility == "public"
1620+
1621+
mock_db.execute.side_effect = [
1622+
create_mock_result(existing_res),
1623+
]
1624+
service._update_or_create_resources(mock_db, [res_from_server], mock_gateway, "update", update_visibility=True)
1625+
assert existing_res.visibility == "public"
1626+
1627+
mock_db.execute.side_effect = [
1628+
create_mock_result(existing_prompt),
1629+
]
1630+
service._update_or_create_prompts(mock_db, [prompt_from_server], mock_gateway, "update", update_visibility=True)
1631+
assert existing_prompt.visibility == "public"
1632+
1633+
# --- Test 3: UPDATE without visibility change (e.g. description-only edit) ---
1634+
# Visibility must NOT be overwritten even though created_via is "update"
1635+
existing_tool.visibility = "private"
1636+
existing_res.visibility = "team"
1637+
existing_prompt.visibility = "private"
1638+
1639+
mock_db.execute.side_effect = [create_mock_result(existing_tool)]
1640+
service._update_or_create_tools(mock_db, [tool_from_server], mock_gateway, "update", update_visibility=False)
1641+
assert existing_tool.visibility == "private"
1642+
1643+
mock_db.execute.side_effect = [create_mock_result(existing_res)]
1644+
service._update_or_create_resources(mock_db, [res_from_server], mock_gateway, "update", update_visibility=False)
1645+
assert existing_res.visibility == "team"
1646+
1647+
mock_db.execute.side_effect = [create_mock_result(existing_prompt)]
1648+
service._update_or_create_prompts(mock_db, [prompt_from_server], mock_gateway, "update", update_visibility=False)
1649+
assert existing_prompt.visibility == "private"
1650+
1651+
def test_create_db_tool_inherits_gateway_visibility(self):
1652+
"""New tools created via _create_db_tool inherit visibility from the gateway, not hardcoded 'public'."""
1653+
service = GatewayService()
1654+
tool = MagicMock()
1655+
tool.name = "new_tool"
1656+
tool.description = "A tool"
1657+
tool.request_type = "POST"
1658+
tool.headers = {}
1659+
tool.input_schema = {}
1660+
tool.annotations = {}
1661+
tool.jsonpath_filter = None
1662+
1663+
for vis in ("private", "team", "public"):
1664+
gateway = MagicMock()
1665+
gateway.url = "http://gw.com"
1666+
gateway.name = "gw"
1667+
gateway.auth_type = "none"
1668+
gateway.auth_value = None
1669+
gateway.team_id = "t1"
1670+
gateway.owner_email = "owner@example.com"
1671+
gateway.visibility = vis
1672+
1673+
db_tool = service._create_db_tool(tool=tool, gateway=gateway)
1674+
assert db_tool.visibility == vis, f"Expected {vis}, got {db_tool.visibility}"

0 commit comments

Comments
 (0)