Skip to content

Commit 973e989

Browse files
author
Teryl Taylor
committed
fix: json mcp error codes and added test cases.
Signed-off-by: Teryl Taylor <[email protected]>
1 parent 63358f8 commit 973e989

File tree

2 files changed

+274
-19
lines changed

2 files changed

+274
-19
lines changed

mcpgateway/main.py

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@
6969
from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware
7070
from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware
7171
from mcpgateway.middleware.token_scoping import token_scoping_middleware
72-
from mcpgateway.models import InitializeResult, ListResourceTemplatesResult, LogLevel, Root
72+
from mcpgateway.models import InitializeResult
73+
from mcpgateway.models import JSONRPCError as PydanticJSONRPCError
74+
from mcpgateway.models import ListResourceTemplatesResult, LogLevel, Root
7375
from mcpgateway.observability import init_telemetry
7476
from mcpgateway.plugins.framework import PluginError, PluginManager, PluginViolationError
7577
from mcpgateway.routers.well_known import router as well_known_router
@@ -606,32 +608,50 @@ async def plugin_violation_exception_handler(_request: Request, exc: PluginViola
606608
exc: The PluginViolationError exception containing constraint
607609
violation details.
608610
609-
Raises:
610-
JSONRPCError: A -32602 JSON RPC error, which indicates invalid parameters.
611+
Returns:
612+
JSONResponse: A 200 response with error details in JSON-RPC format.
611613
612614
Examples:
613615
>>> from mcpgateway.plugins.framework import PluginViolationError
614616
>>> from mcpgateway.plugins.framework.models import PluginViolation
615617
>>> from fastapi import Request
616618
>>> import asyncio
619+
>>> import json
617620
>>>
618-
>>> # Create a mock integrity error
621+
>>> # Create a plugin violation error
619622
>>> mock_error = PluginViolationError(message="plugin violation",violation = PluginViolation(
620623
... reason="Invalid input",
621624
... description="The input contains prohibited content",
622625
... code="PROHIBITED_CONTENT",
623626
... details={"field": "message", "value": "test"}
624627
... ))
625-
>>> asyncio.run(plugin_violation_exception_handler(None, mock_error))
626-
Traceback (most recent call last):
627-
...
628-
mcpgateway.validation.jsonrpc.JSONRPCError: The input contains prohibited content
628+
>>> result = asyncio.run(plugin_violation_exception_handler(None, mock_error))
629+
>>> result.status_code
630+
200
631+
>>> content = json.loads(result.body.decode())
632+
>>> content["error"]["code"]
633+
-32602
634+
>>> "Plugin Violation:" in content["error"]["message"]
635+
True
636+
>>> content["error"]["data"]["plugin_error_code"]
637+
'PROHIBITED_CONTENT'
629638
"""
630639
policy_violation = exc.violation.model_dump() if exc.violation else {}
631640
message = exc.violation.description if exc.violation else "A plugin violation occurred."
632641
policy_violation["message"] = exc.message
633642
status_code = exc.violation.mcp_error_code if exc.violation and exc.violation.mcp_error_code else -32602
634-
raise JSONRPCError(code=status_code, message=message, data=policy_violation)
643+
violation_details: dict[str, Any] = {}
644+
if exc.violation:
645+
if exc.violation.description:
646+
violation_details["description"] = exc.violation.description
647+
if exc.violation.details:
648+
violation_details["details"] = exc.violation.details
649+
if exc.violation.code:
650+
violation_details["plugin_error_code"] = exc.violation.code
651+
if exc.violation.plugin_name:
652+
violation_details["plugin_name"] = exc.violation.plugin_name
653+
json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Violation: " + message, data=violation_details)
654+
return JSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
635655

636656

637657
@app.exception_handler(PluginError)
@@ -647,31 +667,48 @@ async def plugin_exception_handler(_request: Request, exc: PluginError):
647667
exc: The PluginError exception containing constraint
648668
violation details.
649669
650-
Raises:
651-
JSONRPCError: A -32603 JSON RPC error internal server error.
670+
Returns:
671+
JSONResponse: A 200 response with error details in JSON-RPC format.
652672
653673
Examples:
654-
>>> from mcpgateway.plugins.framework import PluginViolationError
674+
>>> from mcpgateway.plugins.framework import PluginError
655675
>>> from mcpgateway.plugins.framework.models import PluginErrorModel
656676
>>> from fastapi import Request
657677
>>> import asyncio
678+
>>> import json
658679
>>>
659-
>>> # Create a mock integrity error
680+
>>> # Create a plugin error
660681
>>> mock_error = PluginError(error = PluginErrorModel(
661682
... message="plugin error",
662683
... code="timeout",
663684
... plugin_name="abc",
664685
... details={"field": "message", "value": "test"}
665686
... ))
666-
>>> asyncio.run(plugin_exception_handler(None, mock_error))
667-
Traceback (most recent call last):
668-
...
669-
mcpgateway.validation.jsonrpc.JSONRPCError: plugin error
687+
>>> result = asyncio.run(plugin_exception_handler(None, mock_error))
688+
>>> result.status_code
689+
200
690+
>>> content = json.loads(result.body.decode())
691+
>>> content["error"]["code"]
692+
-32603
693+
>>> "Plugin Error:" in content["error"]["message"]
694+
True
695+
>>> content["error"]["data"]["plugin_error_code"]
696+
'timeout'
697+
>>> content["error"]["data"]["plugin_name"]
698+
'abc'
670699
"""
671-
error_obj = exc.error.model_dump() if exc.error else {}
672700
message = exc.error.message if exc.error else "A plugin error occurred."
673701
status_code = exc.error.mcp_error_code if exc.error else -32603
674-
raise JSONRPCError(code=status_code, message=message, data=error_obj)
702+
error_details: dict[str, Any] = {}
703+
if exc.error:
704+
if exc.error.details:
705+
error_details["details"] = exc.error.details
706+
if exc.error.code:
707+
error_details["plugin_error_code"] = exc.error.code
708+
if exc.error.plugin_name:
709+
error_details["plugin_name"] = exc.error.plugin_name
710+
json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Error: " + message, data=error_details)
711+
return JSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
675712

676713

677714
class DocsAuthMiddleware(BaseHTTPMiddleware):

tests/unit/mcpgateway/test_main.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,3 +1536,221 @@ def test_redoc_with_auth(self, test_client, auth_headers):
15361536
"""Test GET /redoc with authentication returns 200 or redirect."""
15371537
response = test_client.get("/redoc", headers=auth_headers)
15381538
assert response.status_code == 200
1539+
1540+
1541+
# ----------------------------------------------------- #
1542+
# Plugin Exception Handler Tests #
1543+
# ----------------------------------------------------- #
1544+
class TestPluginExceptionHandlers:
1545+
"""Tests for plugin exception handlers: PluginViolationError and PluginError."""
1546+
1547+
def test_plugin_violation_exception_handler_with_full_violation(self):
1548+
"""Test plugin_violation_exception_handler with complete violation details."""
1549+
# Standard
1550+
import asyncio
1551+
1552+
# First-Party
1553+
from mcpgateway.main import plugin_violation_exception_handler
1554+
from mcpgateway.plugins.framework.errors import PluginViolationError
1555+
from mcpgateway.plugins.framework.models import PluginViolation
1556+
1557+
violation = PluginViolation(
1558+
reason="Invalid input",
1559+
description="The input contains prohibited content",
1560+
code="PROHIBITED_CONTENT",
1561+
details={"field": "message", "value": "sensitive_data"},
1562+
)
1563+
violation._plugin_name = "content_filter"
1564+
exc = PluginViolationError(message="Policy violation detected", violation=violation)
1565+
1566+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1567+
1568+
assert result.status_code == 200
1569+
content = json.loads(result.body.decode())
1570+
assert "error" in content
1571+
assert content["error"]["code"] == -32602
1572+
assert "Plugin Violation:" in content["error"]["message"]
1573+
assert "The input contains prohibited content" in content["error"]["message"]
1574+
assert content["error"]["data"]["description"] == "The input contains prohibited content"
1575+
assert content["error"]["data"]["details"] == {"field": "message", "value": "sensitive_data"}
1576+
assert content["error"]["data"]["plugin_error_code"] == "PROHIBITED_CONTENT"
1577+
assert content["error"]["data"]["plugin_name"] == "content_filter"
1578+
1579+
def test_plugin_violation_exception_handler_with_custom_mcp_error_code(self):
1580+
"""Test plugin_violation_exception_handler with custom MCP error code."""
1581+
# Standard
1582+
import asyncio
1583+
1584+
# First-Party
1585+
from mcpgateway.main import plugin_violation_exception_handler
1586+
from mcpgateway.plugins.framework.errors import PluginViolationError
1587+
from mcpgateway.plugins.framework.models import PluginViolation
1588+
1589+
violation = PluginViolation(
1590+
reason="Rate limit exceeded",
1591+
description="Too many requests from this client",
1592+
code="RATE_LIMIT",
1593+
details={"requests": 100, "limit": 50},
1594+
mcp_error_code=-32000, # Custom error code
1595+
)
1596+
violation._plugin_name = "rate_limiter"
1597+
exc = PluginViolationError(message="Rate limit violation", violation=violation)
1598+
1599+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1600+
1601+
assert result.status_code == 200
1602+
content = json.loads(result.body.decode())
1603+
assert content["error"]["code"] == -32000
1604+
assert "Too many requests from this client" in content["error"]["message"]
1605+
assert content["error"]["data"]["plugin_error_code"] == "RATE_LIMIT"
1606+
assert content["error"]["data"]["plugin_name"] == "rate_limiter"
1607+
1608+
def test_plugin_violation_exception_handler_with_minimal_violation(self):
1609+
"""Test plugin_violation_exception_handler with minimal violation details."""
1610+
# Standard
1611+
import asyncio
1612+
1613+
# First-Party
1614+
from mcpgateway.main import plugin_violation_exception_handler
1615+
from mcpgateway.plugins.framework.errors import PluginViolationError
1616+
from mcpgateway.plugins.framework.models import PluginViolation
1617+
1618+
violation = PluginViolation(
1619+
reason="Violation occurred",
1620+
description="Minimal violation",
1621+
code="MIN_VIOLATION",
1622+
details={},
1623+
)
1624+
exc = PluginViolationError(message="Minimal violation", violation=violation)
1625+
1626+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1627+
1628+
assert result.status_code == 200
1629+
content = json.loads(result.body.decode())
1630+
assert content["error"]["code"] == -32602
1631+
assert "Minimal violation" in content["error"]["message"]
1632+
assert content["error"]["data"]["plugin_error_code"] == "MIN_VIOLATION"
1633+
1634+
def test_plugin_violation_exception_handler_without_violation_object(self):
1635+
"""Test plugin_violation_exception_handler when violation object is None."""
1636+
# Standard
1637+
import asyncio
1638+
1639+
# First-Party
1640+
from mcpgateway.main import plugin_violation_exception_handler
1641+
from mcpgateway.plugins.framework.errors import PluginViolationError
1642+
1643+
exc = PluginViolationError(message="Generic plugin violation", violation=None)
1644+
1645+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1646+
1647+
assert result.status_code == 200
1648+
content = json.loads(result.body.decode())
1649+
assert content["error"]["code"] == -32602
1650+
assert "A plugin violation occurred" in content["error"]["message"]
1651+
assert content["error"]["data"] == {}
1652+
1653+
def test_plugin_exception_handler_with_full_error(self):
1654+
"""Test plugin_exception_handler with complete error details."""
1655+
# Standard
1656+
import asyncio
1657+
1658+
# First-Party
1659+
from mcpgateway.main import plugin_exception_handler
1660+
from mcpgateway.plugins.framework.errors import PluginError
1661+
from mcpgateway.plugins.framework.models import PluginErrorModel
1662+
1663+
error = PluginErrorModel(
1664+
message="Plugin execution failed",
1665+
code="EXECUTION_ERROR",
1666+
plugin_name="data_processor",
1667+
details={"error_type": "timeout", "duration": 30},
1668+
)
1669+
exc = PluginError(error=error)
1670+
1671+
result = asyncio.run(plugin_exception_handler(None, exc))
1672+
1673+
assert result.status_code == 200
1674+
content = json.loads(result.body.decode())
1675+
assert "error" in content
1676+
assert content["error"]["code"] == -32603
1677+
assert "Plugin Error:" in content["error"]["message"]
1678+
assert "Plugin execution failed" in content["error"]["message"]
1679+
assert content["error"]["data"]["details"] == {"error_type": "timeout", "duration": 30}
1680+
assert content["error"]["data"]["plugin_error_code"] == "EXECUTION_ERROR"
1681+
assert content["error"]["data"]["plugin_name"] == "data_processor"
1682+
1683+
def test_plugin_exception_handler_with_custom_mcp_error_code(self):
1684+
"""Test plugin_exception_handler with custom MCP error code."""
1685+
# Standard
1686+
import asyncio
1687+
1688+
# First-Party
1689+
from mcpgateway.main import plugin_exception_handler
1690+
from mcpgateway.plugins.framework.errors import PluginError
1691+
from mcpgateway.plugins.framework.models import PluginErrorModel
1692+
1693+
error = PluginErrorModel(
1694+
message="Custom error occurred",
1695+
code="CUSTOM_ERROR",
1696+
plugin_name="custom_plugin",
1697+
details={"context": "test"},
1698+
mcp_error_code=-32001, # Custom MCP error code
1699+
)
1700+
exc = PluginError(error=error)
1701+
1702+
result = asyncio.run(plugin_exception_handler(None, exc))
1703+
1704+
assert result.status_code == 200
1705+
content = json.loads(result.body.decode())
1706+
assert content["error"]["code"] == -32001
1707+
assert "Custom error occurred" in content["error"]["message"]
1708+
assert content["error"]["data"]["plugin_error_code"] == "CUSTOM_ERROR"
1709+
1710+
def test_plugin_exception_handler_with_minimal_error(self):
1711+
"""Test plugin_exception_handler with minimal error details."""
1712+
# Standard
1713+
import asyncio
1714+
1715+
# First-Party
1716+
from mcpgateway.main import plugin_exception_handler
1717+
from mcpgateway.plugins.framework.errors import PluginError
1718+
from mcpgateway.plugins.framework.models import PluginErrorModel
1719+
1720+
error = PluginErrorModel(message="Minimal error", plugin_name="minimal_plugin")
1721+
exc = PluginError(error=error)
1722+
1723+
result = asyncio.run(plugin_exception_handler(None, exc))
1724+
1725+
assert result.status_code == 200
1726+
content = json.loads(result.body.decode())
1727+
assert content["error"]["code"] == -32603
1728+
assert "Minimal error" in content["error"]["message"]
1729+
assert content["error"]["data"]["plugin_name"] == "minimal_plugin"
1730+
1731+
def test_plugin_exception_handler_with_empty_code(self):
1732+
"""Test plugin_exception_handler when error has empty code field."""
1733+
# Standard
1734+
import asyncio
1735+
1736+
# First-Party
1737+
from mcpgateway.main import plugin_exception_handler
1738+
from mcpgateway.plugins.framework.errors import PluginError
1739+
from mcpgateway.plugins.framework.models import PluginErrorModel
1740+
1741+
error = PluginErrorModel(
1742+
message="Error without code",
1743+
code="",
1744+
plugin_name="test_plugin",
1745+
details={"info": "test"},
1746+
)
1747+
exc = PluginError(error=error)
1748+
1749+
result = asyncio.run(plugin_exception_handler(None, exc))
1750+
1751+
assert result.status_code == 200
1752+
content = json.loads(result.body.decode())
1753+
assert content["error"]["code"] == -32603
1754+
assert "Error without code" in content["error"]["message"]
1755+
# Empty code should not be included in data
1756+
assert "plugin_error_code" not in content["error"]["data"] or content["error"]["data"]["plugin_error_code"] == ""

0 commit comments

Comments
 (0)