Skip to content

Commit 4fc2764

Browse files
committed
fix: propagate tool validation errors in gateway refresh response (#5290)
The _refresh_gateway_tools_resources_prompts method was discarding the 5th return value from _initialize_gateway() (validation_errors), causing GatewayRefreshResponse.validation_errors to always be empty. - Capture validation_errors instead of discarding with _ - Populate result['validation_errors'] after successful init - Add tests covering manual refresh, auto-refresh, and API response Closes #5290 Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
1 parent 30a7057 commit 4fc2764

4 files changed

Lines changed: 75 additions & 21 deletions

File tree

mcpgateway/services/gateway_service.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3749,14 +3749,7 @@ def _get_due_gateway_lifecycle_ids(self) -> List[str]:
37493749
claim_filter = or_(DbGateway.lifecycle_claim_expires_at.is_(None), DbGateway.lifecycle_claim_expires_at <= now)
37503750
deleting_ids = db.execute(select(DbGateway.id).where(DbGateway.status == "deleting").where(claim_filter)).scalars().all()
37513751
pending_ids = (
3752-
db.execute(
3753-
select(DbGateway.id)
3754-
.where(DbGateway.status == "pending")
3755-
.where(or_(DbGateway.next_retry_at.is_(None), DbGateway.next_retry_at <= now))
3756-
.where(claim_filter)
3757-
)
3758-
.scalars()
3759-
.all()
3752+
db.execute(select(DbGateway.id).where(DbGateway.status == "pending").where(or_(DbGateway.next_retry_at.is_(None), DbGateway.next_retry_at <= now)).where(claim_filter)).scalars().all()
37603753
)
37613754
return [*deleting_ids, *(f"pending:{gateway_id}" for gateway_id in pending_ids)]
37623755

@@ -4933,6 +4926,7 @@ async def _run_health_checks(self, user_email: str) -> None:
49334926
while True:
49344927
try:
49354928
if self._redis_client and settings.cache_type == "redis":
4929+
49364930
async def require_redis_leader() -> bool:
49374931
"""Check for redis leader"""
49384932
current_leader = await self._redis_client.get(self._leader_key)
@@ -5572,9 +5566,7 @@ def _created_via_allowed(created_via: Optional[str]) -> bool:
55725566

55735567
stale_resource_ids = []
55745568
if not skip_stale_cleanup and catalog_sync.new_resource_uris is not None:
5575-
stale_resource_ids = [
5576-
resource.id for resource in gateway.resources if resource.uri not in catalog_sync.new_resource_uris and _created_via_allowed(getattr(resource, "created_via", None))
5577-
]
5569+
stale_resource_ids = [resource.id for resource in gateway.resources if resource.uri not in catalog_sync.new_resource_uris and _created_via_allowed(getattr(resource, "created_via", None))]
55785570
if stale_resource_ids:
55795571
for i in range(0, len(stale_resource_ids), 500):
55805572
chunk = stale_resource_ids[i : i + 500]
@@ -5585,9 +5577,7 @@ def _created_via_allowed(created_via: Optional[str]) -> bool:
55855577

55865578
stale_prompt_ids = []
55875579
if not skip_stale_cleanup and catalog_sync.new_prompt_names is not None:
5588-
stale_prompt_ids = [
5589-
prompt.id for prompt in gateway.prompts if prompt.original_name not in catalog_sync.new_prompt_names and _created_via_allowed(getattr(prompt, "created_via", None))
5590-
]
5580+
stale_prompt_ids = [prompt.id for prompt in gateway.prompts if prompt.original_name not in catalog_sync.new_prompt_names and _created_via_allowed(getattr(prompt, "created_via", None))]
55915581
if stale_prompt_ids:
55925582
for i in range(0, len(stale_prompt_ids), 500):
55935583
chunk = stale_prompt_ids[i : i + 500]
@@ -5601,13 +5591,9 @@ def _created_via_allowed(created_via: Optional[str]) -> bool:
56015591
if not skip_stale_cleanup:
56025592
gateway.tools = [tool for tool in gateway.tools if tool.original_name in catalog_sync.new_tool_names or not _created_via_allowed(getattr(tool, "created_via", None))]
56035593
if catalog_sync.new_resource_uris is not None:
5604-
gateway.resources = [
5605-
resource for resource in gateway.resources if resource.uri in catalog_sync.new_resource_uris or not _created_via_allowed(getattr(resource, "created_via", None))
5606-
]
5594+
gateway.resources = [resource for resource in gateway.resources if resource.uri in catalog_sync.new_resource_uris or not _created_via_allowed(getattr(resource, "created_via", None))]
56075595
if catalog_sync.new_prompt_names is not None:
5608-
gateway.prompts = [
5609-
prompt for prompt in gateway.prompts if prompt.original_name in catalog_sync.new_prompt_names or not _created_via_allowed(getattr(prompt, "created_via", None))
5610-
]
5596+
gateway.prompts = [prompt for prompt in gateway.prompts if prompt.original_name in catalog_sync.new_prompt_names or not _created_via_allowed(getattr(prompt, "created_via", None))]
56115597

56125598
tools_removed = len(stale_tool_ids)
56135599
resources_removed = len(stale_resource_ids)
@@ -5819,7 +5805,7 @@ async def _refresh_gateway_tools_resources_prompts(
58195805
_refresh_key = _enc.decrypt_secret_or_plaintext(_refresh_key)
58205806
except Exception:
58215807
logger.debug("client_key decryption skipped during gateway refresh")
5822-
_capabilities, tools, resources, prompts, _ = await self._initialize_gateway(
5808+
_capabilities, tools, resources, prompts, validation_errors = await self._initialize_gateway(
58235809
url=gateway_url,
58245810
authentication=gateway_auth_value,
58255811
transport=gateway_transport,
@@ -5839,6 +5825,8 @@ async def _refresh_gateway_tools_resources_prompts(
58395825
result["error"] = str(e)
58405826
return result
58415827

5828+
result["validation_errors"] = validation_errors
5829+
58425830
# For authorization_code OAuth gateways, empty responses may indicate incomplete auth flow
58435831
# Skip only if it's an auth_code gateway with no data (user may not have completed authorization)
58445832
is_auth_code_gateway = gateway_oauth_config and isinstance(gateway_oauth_config, dict) and gateway_oauth_config.get("grant_type") == "authorization_code"

tests/unit/mcpgateway/services/test_gateway_auto_refresh.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,29 @@ async def test_refresh_skips_auth_code_gateway_with_empty_response(self, gateway
212212
assert result["tools_added"] == 0
213213
assert result["tools_removed"] == 0
214214

215+
@pytest.mark.asyncio
216+
async def test_refresh_propagates_validation_errors(self, gateway_service):
217+
"""Validation errors from _initialize_gateway should propagate through auto-refresh."""
218+
mock_gateway = _make_mock_gateway()
219+
mock_session = MagicMock()
220+
mock_session.execute.return_value.scalar_one_or_none.return_value = mock_gateway
221+
222+
validation_errors = [
223+
"bad_tool: Tool name exceeds MCP spec limit of 128 characters (got 129)",
224+
]
225+
226+
with (
227+
patch("mcpgateway.services.gateway_service.fresh_db_session") as mock_fresh,
228+
patch.object(gateway_service, "_initialize_gateway", new_callable=AsyncMock) as mock_init,
229+
):
230+
mock_fresh.return_value.__enter__.return_value = mock_session
231+
mock_init.return_value = ({}, [], [], [], validation_errors)
232+
233+
result = await gateway_service._refresh_gateway_tools_resources_prompts("gw-123")
234+
235+
assert result["validation_errors"] == validation_errors
236+
assert result["success"] is True
237+
215238
@pytest.mark.asyncio
216239
async def test_refresh_processes_empty_non_auth_code_gateway(self, gateway_service):
217240
"""Test that refresh processes empty responses from non-auth_code gateways."""

tests/unit/mcpgateway/services/test_gateway_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6471,6 +6471,29 @@ async def test_init_failure_returns_error(self, gateway_service):
64716471
assert result["success"] is False
64726472
assert "connection refused" in result["error"]
64736473

6474+
@pytest.mark.asyncio
6475+
async def test_validation_errors_propagated(self, gateway_service):
6476+
"""Validation errors from _initialize_gateway populate result['validation_errors'] before early return."""
6477+
gw = SimpleNamespace(
6478+
enabled=True,
6479+
reachable=True,
6480+
name="val-gw",
6481+
url="http://example.com",
6482+
transport="sse",
6483+
auth_type="oauth",
6484+
auth_value=None,
6485+
oauth_config={"grant_type": "authorization_code"},
6486+
ca_certificate=None,
6487+
auth_query_params=None,
6488+
)
6489+
validation_errors = [
6490+
"bad_tool: Tool name exceeds MCP spec limit of 128 characters (got 129)",
6491+
]
6492+
gateway_service._initialize_gateway = AsyncMock(return_value=({}, [], [], [], validation_errors))
6493+
result = await gateway_service._refresh_gateway_tools_resources_prompts("gw-1", gateway=gw)
6494+
assert result["validation_errors"] == validation_errors
6495+
assert result["success"] is True
6496+
64746497
@pytest.mark.asyncio
64756498
async def test_auth_code_empty_response_returns_early(self, gateway_service):
64766499
gw = SimpleNamespace(

tests/unit/mcpgateway/test_main_extended.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4904,6 +4904,7 @@ async def test_refresh_gateway_tools_success_and_errors(self, monkeypatch):
49044904
"duration_ms": 1.0,
49054905
"refreshed_at": datetime.now(timezone.utc),
49064906
"tools_added": 1,
4907+
"validation_errors": [],
49074908
}
49084909
monkeypatch.setattr(main_mod.gateway_service, "get_gateway", AsyncMock(return_value=SimpleNamespace(id="gw-1")))
49094910
monkeypatch.setattr(main_mod, "_enforce_scoped_resource_access", lambda *_args, **_kwargs: None)
@@ -4918,6 +4919,25 @@ async def test_refresh_gateway_tools_success_and_errors(self, monkeypatch):
49184919
)
49194920
assert response.gateway_id == "gw-1"
49204921
assert response.tools_added == 1
4922+
assert response.validation_errors == []
4923+
4924+
# Test with non-empty validation errors
4925+
result_payload_with_errors = {
4926+
"duration_ms": 1.0,
4927+
"refreshed_at": datetime.now(timezone.utc),
4928+
"tools_added": 0,
4929+
"validation_errors": ["bad_tool: Tool name exceeds MCP spec limit of 128 characters (got 129)"],
4930+
}
4931+
monkeypatch.setattr(main_mod.gateway_service, "refresh_gateway_manually", AsyncMock(return_value=result_payload_with_errors))
4932+
response_with_errors = await main_mod.refresh_gateway_tools(
4933+
"gw-1",
4934+
request,
4935+
include_resources=True,
4936+
include_prompts=False,
4937+
db=db,
4938+
user={"email": "user@example.com"},
4939+
)
4940+
assert response_with_errors.validation_errors == result_payload_with_errors["validation_errors"]
49214941

49224942
monkeypatch.setattr(main_mod.gateway_service, "refresh_gateway_manually", AsyncMock(side_effect=GatewayNotFoundError("missing")))
49234943
with pytest.raises(HTTPException) as excinfo:

0 commit comments

Comments
 (0)