Skip to content

Commit 1f39bd5

Browse files
teryltTeryl Taylor
andauthored
feat: add mcp error code to validation and plugin errors. (#1208)
* feat: add mcp error code to validation and plugin errors. Signed-off-by: Teryl Taylor <[email protected]> * fix: documentation error. Signed-off-by: Teryl Taylor <[email protected]> * fix: use JSONRPCError. Signed-off-by: Teryl Taylor <[email protected]> * fix: updated doctest. Signed-off-by: Teryl Taylor <[email protected]> * fix: json mcp error codes and added test cases. Signed-off-by: Teryl Taylor <[email protected]> --------- Signed-off-by: Teryl Taylor <[email protected]> Co-authored-by: Teryl Taylor <[email protected]>
1 parent fcc82bf commit 1f39bd5

File tree

3 files changed

+277
-12
lines changed

3 files changed

+277
-12
lines changed

mcpgateway/main.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@
7272
from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware
7373
from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware
7474
from mcpgateway.middleware.token_scoping import token_scoping_middleware
75-
from mcpgateway.models import InitializeResult, ListResourceTemplatesResult, LogLevel, Root
75+
from mcpgateway.models import InitializeResult
76+
from mcpgateway.models import JSONRPCError as PydanticJSONRPCError
77+
from mcpgateway.models import ListResourceTemplatesResult, LogLevel, Root
7678
from mcpgateway.observability import init_telemetry
7779
from mcpgateway.plugins.framework import PluginError, PluginManager, PluginViolationError
7880
from mcpgateway.routers.well_known import router as well_known_router
@@ -702,15 +704,16 @@ async def plugin_violation_exception_handler(_request: Request, exc: PluginViola
702704
violation details.
703705
704706
Returns:
705-
JSONResponse: A 403 response with access forbidden.
707+
JSONResponse: A 200 response with error details in JSON-RPC format.
706708
707709
Examples:
708710
>>> from mcpgateway.plugins.framework import PluginViolationError
709711
>>> from mcpgateway.plugins.framework.models import PluginViolation
710712
>>> from fastapi import Request
711713
>>> import asyncio
714+
>>> import json
712715
>>>
713-
>>> # Create a mock integrity error
716+
>>> # Create a plugin violation error
714717
>>> mock_error = PluginViolationError(message="plugin violation",violation = PluginViolation(
715718
... reason="Invalid input",
716719
... description="The input contains prohibited content",
@@ -719,11 +722,31 @@ async def plugin_violation_exception_handler(_request: Request, exc: PluginViola
719722
... ))
720723
>>> result = asyncio.run(plugin_violation_exception_handler(None, mock_error))
721724
>>> result.status_code
722-
403
725+
200
726+
>>> content = json.loads(result.body.decode())
727+
>>> content["error"]["code"]
728+
-32602
729+
>>> "Plugin Violation:" in content["error"]["message"]
730+
True
731+
>>> content["error"]["data"]["plugin_error_code"]
732+
'PROHIBITED_CONTENT'
723733
"""
724734
policy_violation = exc.violation.model_dump() if exc.violation else {}
735+
message = exc.violation.description if exc.violation else "A plugin violation occurred."
725736
policy_violation["message"] = exc.message
726-
return JSONResponse(status_code=403, content=policy_violation)
737+
status_code = exc.violation.mcp_error_code if exc.violation and exc.violation.mcp_error_code else -32602
738+
violation_details: dict[str, Any] = {}
739+
if exc.violation:
740+
if exc.violation.description:
741+
violation_details["description"] = exc.violation.description
742+
if exc.violation.details:
743+
violation_details["details"] = exc.violation.details
744+
if exc.violation.code:
745+
violation_details["plugin_error_code"] = exc.violation.code
746+
if exc.violation.plugin_name:
747+
violation_details["plugin_name"] = exc.violation.plugin_name
748+
json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Violation: " + message, data=violation_details)
749+
return JSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
727750

728751

729752
@app.exception_handler(PluginError)
@@ -740,15 +763,16 @@ async def plugin_exception_handler(_request: Request, exc: PluginError):
740763
violation details.
741764
742765
Returns:
743-
JSONResponse: A 500 response with internal server error.
766+
JSONResponse: A 200 response with error details in JSON-RPC format.
744767
745768
Examples:
746-
>>> from mcpgateway.plugins.framework import PluginViolationError
769+
>>> from mcpgateway.plugins.framework import PluginError
747770
>>> from mcpgateway.plugins.framework.models import PluginErrorModel
748771
>>> from fastapi import Request
749772
>>> import asyncio
773+
>>> import json
750774
>>>
751-
>>> # Create a mock integrity error
775+
>>> # Create a plugin error
752776
>>> mock_error = PluginError(error = PluginErrorModel(
753777
... message="plugin error",
754778
... code="timeout",
@@ -757,10 +781,29 @@ async def plugin_exception_handler(_request: Request, exc: PluginError):
757781
... ))
758782
>>> result = asyncio.run(plugin_exception_handler(None, mock_error))
759783
>>> result.status_code
760-
500
761-
"""
762-
error_obj = exc.error.model_dump() if exc.error else {}
763-
return JSONResponse(status_code=500, content=error_obj)
784+
200
785+
>>> content = json.loads(result.body.decode())
786+
>>> content["error"]["code"]
787+
-32603
788+
>>> "Plugin Error:" in content["error"]["message"]
789+
True
790+
>>> content["error"]["data"]["plugin_error_code"]
791+
'timeout'
792+
>>> content["error"]["data"]["plugin_name"]
793+
'abc'
794+
"""
795+
message = exc.error.message if exc.error else "A plugin error occurred."
796+
status_code = exc.error.mcp_error_code if exc.error else -32603
797+
error_details: dict[str, Any] = {}
798+
if exc.error:
799+
if exc.error.details:
800+
error_details["details"] = exc.error.details
801+
if exc.error.code:
802+
error_details["plugin_error_code"] = exc.error.code
803+
if exc.error.plugin_name:
804+
error_details["plugin_name"] = exc.error.plugin_name
805+
json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Error: " + message, data=error_details)
806+
return JSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
764807

765808

766809
class DocsAuthMiddleware(BaseHTTPMiddleware):

mcpgateway/plugins/framework/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,12 +667,14 @@ class PluginErrorModel(BaseModel):
667667
code (str): an error code.
668668
details: (dict[str, Any]): additional error details.
669669
plugin_name (str): the plugin name.
670+
mcp_error_code ([int]): The MCP error code passed back to the client. Defaults to Internal Error.
670671
"""
671672

672673
message: str
673674
code: Optional[str] = ""
674675
details: Optional[dict[str, Any]] = Field(default_factory=dict)
675676
plugin_name: str
677+
mcp_error_code: int = -32603
676678

677679

678680
class PluginViolation(BaseModel):
@@ -684,6 +686,7 @@ class PluginViolation(BaseModel):
684686
code (str): a violation code.
685687
details: (dict[str, Any]): additional violation details.
686688
_plugin_name (str): the plugin name, private attribute set by the plugin manager.
689+
mcp_error_code(Optional[int]): A valid mcp error code which will be sent back to the client if plugin enabled.
687690
688691
Examples:
689692
>>> violation = PluginViolation(
@@ -706,6 +709,7 @@ class PluginViolation(BaseModel):
706709
code: str
707710
details: dict[str, Any]
708711
_plugin_name: str = PrivateAttr(default="")
712+
mcp_error_code: Optional[int] = None
709713

710714
@property
711715
def plugin_name(self) -> str:

tests/unit/mcpgateway/test_main.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,3 +1582,221 @@ def test_jsonpath_modifier_invalid_expressions(sample_people):
15821582

15831583
with pytest.raises(HTTPException):
15841584
jsonpath_modifier(sample_people, "$[*]", mappings={"bad": "$["}) # invalid mapping expr
1585+
1586+
1587+
# ----------------------------------------------------- #
1588+
# Plugin Exception Handler Tests #
1589+
# ----------------------------------------------------- #
1590+
class TestPluginExceptionHandlers:
1591+
"""Tests for plugin exception handlers: PluginViolationError and PluginError."""
1592+
1593+
def test_plugin_violation_exception_handler_with_full_violation(self):
1594+
"""Test plugin_violation_exception_handler with complete violation details."""
1595+
# Standard
1596+
import asyncio
1597+
1598+
# First-Party
1599+
from mcpgateway.main import plugin_violation_exception_handler
1600+
from mcpgateway.plugins.framework.errors import PluginViolationError
1601+
from mcpgateway.plugins.framework.models import PluginViolation
1602+
1603+
violation = PluginViolation(
1604+
reason="Invalid input",
1605+
description="The input contains prohibited content",
1606+
code="PROHIBITED_CONTENT",
1607+
details={"field": "message", "value": "sensitive_data"},
1608+
)
1609+
violation._plugin_name = "content_filter"
1610+
exc = PluginViolationError(message="Policy violation detected", violation=violation)
1611+
1612+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1613+
1614+
assert result.status_code == 200
1615+
content = json.loads(result.body.decode())
1616+
assert "error" in content
1617+
assert content["error"]["code"] == -32602
1618+
assert "Plugin Violation:" in content["error"]["message"]
1619+
assert "The input contains prohibited content" in content["error"]["message"]
1620+
assert content["error"]["data"]["description"] == "The input contains prohibited content"
1621+
assert content["error"]["data"]["details"] == {"field": "message", "value": "sensitive_data"}
1622+
assert content["error"]["data"]["plugin_error_code"] == "PROHIBITED_CONTENT"
1623+
assert content["error"]["data"]["plugin_name"] == "content_filter"
1624+
1625+
def test_plugin_violation_exception_handler_with_custom_mcp_error_code(self):
1626+
"""Test plugin_violation_exception_handler with custom MCP error code."""
1627+
# Standard
1628+
import asyncio
1629+
1630+
# First-Party
1631+
from mcpgateway.main import plugin_violation_exception_handler
1632+
from mcpgateway.plugins.framework.errors import PluginViolationError
1633+
from mcpgateway.plugins.framework.models import PluginViolation
1634+
1635+
violation = PluginViolation(
1636+
reason="Rate limit exceeded",
1637+
description="Too many requests from this client",
1638+
code="RATE_LIMIT",
1639+
details={"requests": 100, "limit": 50},
1640+
mcp_error_code=-32000, # Custom error code
1641+
)
1642+
violation._plugin_name = "rate_limiter"
1643+
exc = PluginViolationError(message="Rate limit violation", violation=violation)
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"] == -32000
1650+
assert "Too many requests from this client" in content["error"]["message"]
1651+
assert content["error"]["data"]["plugin_error_code"] == "RATE_LIMIT"
1652+
assert content["error"]["data"]["plugin_name"] == "rate_limiter"
1653+
1654+
def test_plugin_violation_exception_handler_with_minimal_violation(self):
1655+
"""Test plugin_violation_exception_handler with minimal violation details."""
1656+
# Standard
1657+
import asyncio
1658+
1659+
# First-Party
1660+
from mcpgateway.main import plugin_violation_exception_handler
1661+
from mcpgateway.plugins.framework.errors import PluginViolationError
1662+
from mcpgateway.plugins.framework.models import PluginViolation
1663+
1664+
violation = PluginViolation(
1665+
reason="Violation occurred",
1666+
description="Minimal violation",
1667+
code="MIN_VIOLATION",
1668+
details={},
1669+
)
1670+
exc = PluginViolationError(message="Minimal violation", violation=violation)
1671+
1672+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1673+
1674+
assert result.status_code == 200
1675+
content = json.loads(result.body.decode())
1676+
assert content["error"]["code"] == -32602
1677+
assert "Minimal violation" in content["error"]["message"]
1678+
assert content["error"]["data"]["plugin_error_code"] == "MIN_VIOLATION"
1679+
1680+
def test_plugin_violation_exception_handler_without_violation_object(self):
1681+
"""Test plugin_violation_exception_handler when violation object is None."""
1682+
# Standard
1683+
import asyncio
1684+
1685+
# First-Party
1686+
from mcpgateway.main import plugin_violation_exception_handler
1687+
from mcpgateway.plugins.framework.errors import PluginViolationError
1688+
1689+
exc = PluginViolationError(message="Generic plugin violation", violation=None)
1690+
1691+
result = asyncio.run(plugin_violation_exception_handler(None, exc))
1692+
1693+
assert result.status_code == 200
1694+
content = json.loads(result.body.decode())
1695+
assert content["error"]["code"] == -32602
1696+
assert "A plugin violation occurred" in content["error"]["message"]
1697+
assert content["error"]["data"] == {}
1698+
1699+
def test_plugin_exception_handler_with_full_error(self):
1700+
"""Test plugin_exception_handler with complete error details."""
1701+
# Standard
1702+
import asyncio
1703+
1704+
# First-Party
1705+
from mcpgateway.main import plugin_exception_handler
1706+
from mcpgateway.plugins.framework.errors import PluginError
1707+
from mcpgateway.plugins.framework.models import PluginErrorModel
1708+
1709+
error = PluginErrorModel(
1710+
message="Plugin execution failed",
1711+
code="EXECUTION_ERROR",
1712+
plugin_name="data_processor",
1713+
details={"error_type": "timeout", "duration": 30},
1714+
)
1715+
exc = PluginError(error=error)
1716+
1717+
result = asyncio.run(plugin_exception_handler(None, exc))
1718+
1719+
assert result.status_code == 200
1720+
content = json.loads(result.body.decode())
1721+
assert "error" in content
1722+
assert content["error"]["code"] == -32603
1723+
assert "Plugin Error:" in content["error"]["message"]
1724+
assert "Plugin execution failed" in content["error"]["message"]
1725+
assert content["error"]["data"]["details"] == {"error_type": "timeout", "duration": 30}
1726+
assert content["error"]["data"]["plugin_error_code"] == "EXECUTION_ERROR"
1727+
assert content["error"]["data"]["plugin_name"] == "data_processor"
1728+
1729+
def test_plugin_exception_handler_with_custom_mcp_error_code(self):
1730+
"""Test plugin_exception_handler with custom MCP error code."""
1731+
# Standard
1732+
import asyncio
1733+
1734+
# First-Party
1735+
from mcpgateway.main import plugin_exception_handler
1736+
from mcpgateway.plugins.framework.errors import PluginError
1737+
from mcpgateway.plugins.framework.models import PluginErrorModel
1738+
1739+
error = PluginErrorModel(
1740+
message="Custom error occurred",
1741+
code="CUSTOM_ERROR",
1742+
plugin_name="custom_plugin",
1743+
details={"context": "test"},
1744+
mcp_error_code=-32001, # Custom MCP error code
1745+
)
1746+
exc = PluginError(error=error)
1747+
1748+
result = asyncio.run(plugin_exception_handler(None, exc))
1749+
1750+
assert result.status_code == 200
1751+
content = json.loads(result.body.decode())
1752+
assert content["error"]["code"] == -32001
1753+
assert "Custom error occurred" in content["error"]["message"]
1754+
assert content["error"]["data"]["plugin_error_code"] == "CUSTOM_ERROR"
1755+
1756+
def test_plugin_exception_handler_with_minimal_error(self):
1757+
"""Test plugin_exception_handler with minimal error details."""
1758+
# Standard
1759+
import asyncio
1760+
1761+
# First-Party
1762+
from mcpgateway.main import plugin_exception_handler
1763+
from mcpgateway.plugins.framework.errors import PluginError
1764+
from mcpgateway.plugins.framework.models import PluginErrorModel
1765+
1766+
error = PluginErrorModel(message="Minimal error", plugin_name="minimal_plugin")
1767+
exc = PluginError(error=error)
1768+
1769+
result = asyncio.run(plugin_exception_handler(None, exc))
1770+
1771+
assert result.status_code == 200
1772+
content = json.loads(result.body.decode())
1773+
assert content["error"]["code"] == -32603
1774+
assert "Minimal error" in content["error"]["message"]
1775+
assert content["error"]["data"]["plugin_name"] == "minimal_plugin"
1776+
1777+
def test_plugin_exception_handler_with_empty_code(self):
1778+
"""Test plugin_exception_handler when error has empty code field."""
1779+
# Standard
1780+
import asyncio
1781+
1782+
# First-Party
1783+
from mcpgateway.main import plugin_exception_handler
1784+
from mcpgateway.plugins.framework.errors import PluginError
1785+
from mcpgateway.plugins.framework.models import PluginErrorModel
1786+
1787+
error = PluginErrorModel(
1788+
message="Error without code",
1789+
code="",
1790+
plugin_name="test_plugin",
1791+
details={"info": "test"},
1792+
)
1793+
exc = PluginError(error=error)
1794+
1795+
result = asyncio.run(plugin_exception_handler(None, exc))
1796+
1797+
assert result.status_code == 200
1798+
content = json.loads(result.body.decode())
1799+
assert content["error"]["code"] == -32603
1800+
assert "Error without code" in content["error"]["message"]
1801+
# Empty code should not be included in data
1802+
assert "plugin_error_code" not in content["error"]["data"] or content["error"]["data"]["plugin_error_code"] == ""

0 commit comments

Comments
 (0)