Skip to content

Commit 4be0ca2

Browse files
committed
fix(grpc): apply non-blocking review nits flagged across the three review passes
Bundle of low-risk, non-blocking improvements that surfaced during the deep review iterations. Each item below was explicitly classified as fix-now in the review summary; broader / design-dependent items were filed as follow-ups under #4612 and #4613. * Schema parity (#1): add grpc_service_id to ToolRead in schemas.py alongside the existing gateway_id field so API consumers can identify gRPC-discovered tools (review S2.4 / TD1). * db.py cleanup (#2): drop redundant nullable=True from Tool.grpc_service_id; Mapped[Optional[str]] already implies it and the sibling gateway_id column omits it (review TD2). * translate_grpc.load_file_descriptors (#3): widen the parameter type from List[bytes] to Sequence[bytes] and reject a single bytes object passed by mistake. Without the guard Python would silently iterate byte-by-byte (review TD3). * translate_grpc descriptor pool conflict (#4): replace the inaccurate 'no-op if already added' comment with an explicit TypeError handler. protobuf raises TypeError when a file with the same name has conflicting content; the existing descriptor stays authoritative (review S3.3). * test_sync_tools_removes_stale_tools (#5): replace the brittle string-match assertion ('DELETE' in str(call)) with an explicit call_count == 4 (1 select + 3 deletes), which no longer depends on the SQLAlchemy Delete object repr (review S2.6). * translate_grpc close() (#6): convert the f-string log to lazy %-style for consistency with the rest of this PR's logging (review N2.1). * grpc_service _sync_tools_from_reflection (#7): document why input_schema['properties'] is empty by design — gRPC arg shape is validated at the protobuf invocation layer, not the MCP tool-call layer; the actual proto types live in the x-grpc-* extensions (review N2.2). * TestInvokeMethodGuards (#8): add 5 edge-case tests covering invoke_method paths the previous suite did not exercise: - service-not-found -> GrpcServiceNotFoundError - disabled service -> GrpcServiceError('is disabled') - invalid method format (no dot) -> GrpcServiceError - _validate_grpc_target spy called with service.target - _validate_tls_path spy called for both cert and key paths 525 targeted tests pass (was 520). Lint clean. Branch: GrpcMethodsAsTools-2854 Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent e7955c1 commit 4be0ca2

5 files changed

Lines changed: 125 additions & 14 deletions

File tree

mcpgateway/db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3264,7 +3264,7 @@ class Tool(Base):
32643264
# federated_with = relationship("Gateway", secondary=tool_gateway_table, back_populates="federated_tools")
32653265

32663266
# Federation relationship with a gRPC service
3267-
grpc_service_id: Mapped[Optional[str]] = mapped_column(ForeignKey("grpc_services.id", ondelete="CASCADE"), nullable=True)
3267+
grpc_service_id: Mapped[Optional[str]] = mapped_column(ForeignKey("grpc_services.id", ondelete="CASCADE"))
32683268
grpc_service: Mapped[Optional["GrpcService"]] = relationship("GrpcService", back_populates="tools")
32693269

32703270
# Many-to-many relationship with Servers

mcpgateway/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,7 @@ class ToolRead(BaseModelWithConfigDict):
15181518
enabled: bool
15191519
reachable: bool
15201520
gateway_id: Optional[str]
1521+
grpc_service_id: Optional[str] = Field(None, description="ID of the gRPC service this tool was discovered from")
15211522
execution_count: Optional[int] = Field(None)
15221523
metrics: Optional[ToolMetrics] = Field(None)
15231524
name: str

mcpgateway/services/grpc_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,9 @@ def _sync_tools_from_reflection(
842842
try:
843843
_validate_reflected_tool_name(tool_name)
844844
description = f"gRPC method {tool_name}"
845+
# ``properties: {}`` is intentional: gRPC argument shape is validated at the
846+
# protobuf invocation layer, not at the MCP tool-call layer. The actual proto
847+
# types are recorded in the x-grpc-* extensions for tooling/inspection.
845848
input_schema = {
846849
"type": "object",
847850
"properties": {},

mcpgateway/translate_grpc.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# Standard
1515
import asyncio
1616
from pathlib import Path
17-
from typing import Any, AsyncGenerator, Dict, List, Optional
17+
from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence
1818

1919
try:
2020
# Third-Party
@@ -387,21 +387,26 @@ async def close(self) -> None:
387387
"""Close the gRPC channel."""
388388
if self._channel:
389389
self._channel.close()
390-
logger.info(f"Closed gRPC connection to {self._target}")
390+
logger.info("Closed gRPC connection to %s", self._target)
391391

392-
def load_file_descriptors(self, file_descriptor_protos: List[bytes]) -> None:
392+
def load_file_descriptors(self, file_descriptor_protos: Sequence[bytes]) -> None:
393393
"""Load serialized FileDescriptorProto bytes into the descriptor pool.
394394
395395
This populates the pool with message type definitions so that
396396
invoke() can serialize/deserialize requests without a reflection
397397
round-trip.
398398
399399
Args:
400-
file_descriptor_protos: List of raw FileDescriptorProto bytes.
400+
file_descriptor_protos: Sequence of raw FileDescriptorProto bytes.
401+
Passing a single ``bytes`` object directly is rejected because
402+
Python would silently iterate it byte-by-byte.
401403
402404
Raises:
403-
ValueError: If unable to parse the protobuf descriptor
405+
TypeError: If a single bytes object is passed instead of a sequence.
406+
ValueError: If unable to parse the protobuf descriptor.
404407
"""
408+
if isinstance(file_descriptor_protos, (bytes, bytearray)):
409+
raise TypeError("file_descriptor_protos must be a sequence of bytes, not a single bytes object")
405410
for proto_bytes in file_descriptor_protos:
406411
fd = FileDescriptorProto()
407412
try:
@@ -410,8 +415,14 @@ def load_file_descriptors(self, file_descriptor_protos: List[bytes]) -> None:
410415
logger.error("Failed to decode protobuf: %s", proto_bytes[:100])
411416
raise ValueError("Unable to parse protobuf descriptor") from err
412417

413-
# NOTE: Adding a descriptor is a no-op if already added
414-
self._pool.Add(fd)
418+
try:
419+
self._pool.Add(fd)
420+
except TypeError as conflict_err:
421+
# protobuf raises TypeError when a file with the same name is already registered
422+
# with conflicting content. The existing descriptor stays authoritative; this is
423+
# NOT a true no-op, but treating it as "skip-and-log" matches the prior intent
424+
# without masking actual programming errors.
425+
logger.debug("Descriptor pool conflict for %s: %s", fd.name, conflict_err)
415426

416427
def get_services(self) -> List[str]:
417428
"""Get list of discovered service names.

tests/unit/mcpgateway/services/test_grpc_service.py

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -733,12 +733,10 @@ def test_sync_tools_removes_stale_tools(self, service, mock_db, sample_db_servic
733733

734734
service._sync_tools_from_reflection(mock_db, sample_db_service)
735735

736-
# Should have executed delete statements for the stale tool
737-
# 3 deletes: ToolMetric, server_tool_association, DbTool
738-
delete_calls = [call for call in mock_db.execute.call_args_list if "DELETE" in str(call) or "delete" in str(call).lower()]
739-
assert len(delete_calls) == 3
740-
# At minimum, execute was called for the select + 3 deletes
741-
assert mock_db.execute.call_count >= 4 # 1 select + 3 deletes
736+
# Stale-tool cleanup fires exactly 3 deletes (ToolMetric, server_tool_association, DbTool)
737+
# plus the initial SELECT for existing tools. Asserting on call_count is more robust than
738+
# string-matching the SQLAlchemy Delete object repr.
739+
assert mock_db.execute.call_count == 4
742740

743741
def test_sync_tools_empty_discovered_services(self, service, mock_db, sample_db_service):
744742
"""Test _sync_tools_from_reflection with empty discovered services and no existing tools."""
@@ -1282,3 +1280,101 @@ def test_sync_isolates_per_tool_failures(self):
12821280

12831281
# Exactly one DbTool was added (the good one).
12841282
assert db.add.call_count == 1
1283+
1284+
1285+
class TestInvokeMethodGuards:
1286+
"""Edge-case coverage for GrpcService.invoke_method security/integrity guards."""
1287+
1288+
@pytest.fixture
1289+
def service(self):
1290+
return GrpcService()
1291+
1292+
@pytest.fixture
1293+
def mock_db(self):
1294+
return MagicMock()
1295+
1296+
@pytest.mark.asyncio
1297+
async def test_invoke_method_service_not_found_raises(self, service, mock_db):
1298+
mock_db.execute.return_value.scalar_one_or_none.return_value = None
1299+
with pytest.raises(GrpcServiceNotFoundError, match="not found"):
1300+
await service.invoke_method(mock_db, "missing-id", "svc.M", {})
1301+
1302+
@pytest.mark.asyncio
1303+
async def test_invoke_method_disabled_service_raises(self, service, mock_db):
1304+
disabled = MagicMock(spec=DbGrpcService)
1305+
disabled.id = "svc-1"
1306+
disabled.name = "svc"
1307+
disabled.enabled = False
1308+
mock_db.execute.return_value.scalar_one_or_none.return_value = disabled
1309+
with pytest.raises(GrpcServiceError, match="is disabled"):
1310+
await service.invoke_method(mock_db, "svc-1", "svc.M", {})
1311+
1312+
@pytest.mark.asyncio
1313+
async def test_invoke_method_invalid_method_format_raises(self, service, mock_db, monkeypatch):
1314+
enabled = MagicMock(spec=DbGrpcService)
1315+
enabled.id = "svc-1"
1316+
enabled.name = "svc"
1317+
enabled.enabled = True
1318+
enabled.target = "localhost:50051"
1319+
enabled.tls_cert_path = None
1320+
enabled.tls_key_path = None
1321+
mock_db.execute.return_value.scalar_one_or_none.return_value = enabled
1322+
# Bypass real network/TLS validation; the GuardCheck runs after the format check.
1323+
monkeypatch.setattr("mcpgateway.services.grpc_service._validate_grpc_target", lambda _t: None)
1324+
with pytest.raises(GrpcServiceError, match="Invalid method name"):
1325+
await service.invoke_method(mock_db, "svc-1", "NoDotMethod", {})
1326+
1327+
@pytest.mark.asyncio
1328+
async def test_invoke_method_calls_target_validator(self, service, mock_db, monkeypatch):
1329+
enabled = MagicMock(spec=DbGrpcService)
1330+
enabled.id = "svc-1"
1331+
enabled.name = "svc"
1332+
enabled.enabled = True
1333+
enabled.target = "host.example:50051"
1334+
enabled.tls_cert_path = None
1335+
enabled.tls_key_path = None
1336+
enabled.discovered_services = {}
1337+
enabled.tls_enabled = False
1338+
enabled.grpc_metadata = {}
1339+
mock_db.execute.return_value.scalar_one_or_none.return_value = enabled
1340+
1341+
# The spy raises a sentinel error so the test asserts the spy was called and exits the
1342+
# invoke flow before reaching the (network-dependent) GrpcEndpoint construction.
1343+
sentinel = GrpcServiceError("spy-aborted")
1344+
spy = MagicMock(side_effect=sentinel)
1345+
monkeypatch.setattr("mcpgateway.services.grpc_service._validate_grpc_target", spy)
1346+
1347+
with pytest.raises(GrpcServiceError, match="spy-aborted"):
1348+
await service.invoke_method(mock_db, "svc-1", "svc.M", {})
1349+
spy.assert_called_once_with("host.example:50051")
1350+
1351+
@pytest.mark.asyncio
1352+
async def test_invoke_method_calls_tls_validator_when_paths_set(self, service, mock_db, monkeypatch):
1353+
enabled = MagicMock(spec=DbGrpcService)
1354+
enabled.id = "svc-1"
1355+
enabled.name = "svc"
1356+
enabled.enabled = True
1357+
enabled.target = "host.example:50051"
1358+
enabled.tls_cert_path = "/tls/cert.pem"
1359+
enabled.tls_key_path = "/tls/key.pem"
1360+
enabled.discovered_services = {}
1361+
enabled.tls_enabled = True
1362+
enabled.grpc_metadata = {}
1363+
mock_db.execute.return_value.scalar_one_or_none.return_value = enabled
1364+
1365+
monkeypatch.setattr("mcpgateway.services.grpc_service._validate_grpc_target", lambda _t: None)
1366+
# The TLS spy raises after the second call so we both assert it was invoked AND short-circuit
1367+
# before GrpcEndpoint construction tries to open a real channel.
1368+
tls_calls: list = []
1369+
1370+
def tls_spy(path, label="TLS path"):
1371+
tls_calls.append((path, label))
1372+
if len(tls_calls) == 2:
1373+
raise GrpcServiceError("spy-aborted")
1374+
return None
1375+
1376+
monkeypatch.setattr("mcpgateway.services.grpc_service._validate_tls_path", tls_spy)
1377+
with pytest.raises(GrpcServiceError, match="spy-aborted"):
1378+
await service.invoke_method(mock_db, "svc-1", "svc.M", {})
1379+
labels = {label for _path, label in tls_calls}
1380+
assert labels == {"TLS cert path", "TLS key path"}

0 commit comments

Comments
 (0)