Skip to content

Commit 4e9e39f

Browse files
authored
Improve Error Messages #363 (#438)
* issue 363 Signed-off-by: Rakhi Dutta <[email protected]> * issue 363 Signed-off-by: Rakhi Dutta <[email protected]> * issue 363 Signed-off-by: Rakhi Dutta <[email protected]> * flake8 error Signed-off-by: Rakhi Dutta <[email protected]> * flake8 error Signed-off-by: Rakhi Dutta <[email protected]> * flake8 error Signed-off-by: Rakhi Dutta <[email protected]> * makefile Signed-off-by: Rakhi Dutta <[email protected]> * issue 363 Signed-off-by: Rakhi Dutta <[email protected]> * flake8 Signed-off-by: Rakhi Dutta <[email protected]> * revert Signed-off-by: Rakhi Dutta <[email protected]> * revert Signed-off-by: Rakhi Dutta <[email protected]> --------- Signed-off-by: Rakhi Dutta <[email protected]>
1 parent dc979b5 commit 4e9e39f

File tree

4 files changed

+164
-2
lines changed

4 files changed

+164
-2
lines changed

mcpgateway/admin.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
2929
import httpx
3030
from sqlalchemy.orm import Session
31+
from sqlalchemy.exc import IntegrityError
32+
from pydantic import ValidationError
3133

3234
# First-Party
3335
from mcpgateway.config import settings
@@ -67,6 +69,7 @@
6769
)
6870
from mcpgateway.utils.create_jwt_token import get_jwt_token
6971
from mcpgateway.utils.verify_credentials import require_auth, require_basic_auth
72+
from mcpgateway.utils.error_formatter import ErrorFormatter
7073

7174
# Initialize services
7275
server_service = ServerService()
@@ -807,6 +810,7 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
807810
auth_header_key=form.get("auth_header_key", ""),
808811
auth_header_value=form.get("auth_header_value", ""),
809812
)
813+
810814
try:
811815
await gateway_service.register_gateway(db, gateway)
812816
return JSONResponse(
@@ -821,6 +825,13 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
821825
return JSONResponse(content={"message": str(ex), "success": False}, status_code=400)
822826
if isinstance(ex, RuntimeError):
823827
return JSONResponse(content={"message": str(ex), "success": False}, status_code=500)
828+
if isinstance(ex, ValidationError):
829+
return JSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
830+
if isinstance(ex, IntegrityError):
831+
return JSONResponse(
832+
status_code=409,
833+
content=ErrorFormatter.format_database_error(ex)
834+
)
824835
return JSONResponse(content={"message": str(ex), "success": False}, status_code=500)
825836

826837

mcpgateway/main.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
from sqlalchemy import text
5454
from sqlalchemy.orm import Session
5555
from starlette.middleware.base import BaseHTTPMiddleware
56+
from pydantic import ValidationError
57+
from sqlalchemy.exc import IntegrityError
58+
5659

5760
# First-Party
5861
from mcpgateway import __version__
@@ -128,6 +131,8 @@
128131
validate_request,
129132
)
130133

134+
from mcpgateway.utils.error_formatter import ErrorFormatter
135+
131136
# Import the admin routes from the new module
132137
from mcpgateway.version import router as version_router
133138

@@ -244,6 +249,24 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
244249
)
245250

246251

252+
# Global exceptions handlers
253+
254+
@app.exception_handler(ValidationError)
255+
async def validation_exception_handler(request: Request, exc: ValidationError):
256+
return JSONResponse(
257+
status_code=422,
258+
content=ErrorFormatter.format_validation_error(exc)
259+
)
260+
261+
262+
@app.exception_handler(IntegrityError)
263+
async def database_exception_handler(request: Request, exc: IntegrityError):
264+
return JSONResponse(
265+
status_code=409,
266+
content=ErrorFormatter.format_database_error(exc)
267+
)
268+
269+
247270
class DocsAuthMiddleware(BaseHTTPMiddleware):
248271
"""
249272
Middleware to protect FastAPI's auto-generated documentation routes
@@ -419,9 +442,7 @@ async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
419442
resource_cache.clear()
420443

421444

422-
#################
423445
# Protocol APIs #
424-
#################
425446
@protocol_router.post("/initialize")
426447
async def initialize(request: Request, user: str = Depends(require_auth)) -> InitializeResult:
427448
"""

mcpgateway/services/gateway_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from mcp.client.streamable_http import streamablehttp_client
3232
from sqlalchemy import select
3333
from sqlalchemy.orm import Session
34+
from sqlalchemy.exc import IntegrityError
3435

3536
try:
3637
# Third-Party
@@ -187,6 +188,7 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway
187188
GatewayConnectionError: If there was an error connecting to the gateway
188189
ValueError: If required values are missing
189190
RuntimeError: If there is an error during processing that is not covered by other exceptions
191+
IntegrityError: If there is a database integrity error
190192
BaseException: If an unexpected error occurs
191193
192194
Examples:
@@ -281,6 +283,11 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway
281283
re: ExceptionGroup[RuntimeError]
282284
logger.error(f"RuntimeErrors in group: {re.exceptions}")
283285
raise re.exceptions[0]
286+
except* IntegrityError as ie:
287+
if TYPE_CHECKING:
288+
ie: ExceptionGroup[IntegrityError]
289+
logger.error(f"IntegrityErrors in group: {ie.exceptions}")
290+
raise ie.exceptions[0]
284291
except* BaseException as other: # catches every other sub-exception
285292
if TYPE_CHECKING:
286293
other: ExceptionGroup[BaseException]

mcpgateway/utils/error_formatter.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# -*- coding: utf-8 -*-
2+
"""MCP Gateway Centralized for Pydantic validation error, SQL exception.
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
Authors: Mihai Criveti
7+
8+
9+
"""
10+
11+
# Standard
12+
from typing import Dict, Any
13+
import logging
14+
15+
# Third-Party
16+
from pydantic import ValidationError
17+
from sqlalchemy.exc import IntegrityError, DatabaseError
18+
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class ErrorFormatter:
24+
"""
25+
Transform technical errors into user-friendly messages.
26+
"""
27+
@staticmethod
28+
def format_validation_error(error: ValidationError) -> Dict[str, Any]:
29+
"""
30+
Convert Pydantic errors to user-friendly format.
31+
32+
Args:
33+
error (ValidationError): The Pydantic validation error.
34+
35+
Returns:
36+
Dict[str, Any]: A dictionary with formatted error details.
37+
"""
38+
errors = []
39+
40+
for err in error.errors():
41+
field = err.get('loc', ['field'])[-1]
42+
msg = err.get('msg', 'Invalid value')
43+
44+
# Map technical messages to user-friendly ones
45+
user_message = ErrorFormatter._get_user_message(field, msg)
46+
errors.append({
47+
"field": field,
48+
"message": user_message
49+
})
50+
51+
# Log the full error for debugging
52+
logger.debug(f"Validation error: {error}")
53+
print(type(error))
54+
55+
return {
56+
"message": "Validation failed",
57+
"details": errors,
58+
"success": False
59+
}
60+
61+
@staticmethod
62+
def _get_user_message(field: str, technical_msg: str) -> str:
63+
"""
64+
Map technical validation messages to user-friendly ones.
65+
66+
Args:
67+
field (str): The field name.
68+
technical_msg (str): The technical validation message.
69+
70+
Returns:
71+
str: User-friendly error message.
72+
"""
73+
mappings = {
74+
"Tool name must start with a letter": f"{field.title()} must start with a letter and contain only letters, numbers, and underscores",
75+
"Tool name exceeds maximum length": f"{field.title()} is too long (maximum 255 characters)",
76+
"Tool URL must start with": f"{field.title()} must be a valid HTTP or WebSocket URL",
77+
"cannot contain directory traversal": f"{field.title()} contains invalid characters",
78+
"contains HTML tags": f"{field.title()} cannot contain HTML or script tags",
79+
}
80+
81+
for pattern, friendly_msg in mappings.items():
82+
if pattern in technical_msg:
83+
return friendly_msg
84+
85+
# Default fallback
86+
return f"Invalid {field}"
87+
88+
@staticmethod
89+
def format_database_error(error: DatabaseError) -> Dict[str, Any]:
90+
"""
91+
Convert database errors to user-friendly format.
92+
93+
Args:
94+
error (DatabaseError): The database error.
95+
96+
Returns:
97+
Dict[str, Any]: A dictionary with formatted error details.
98+
"""
99+
error_str = str(error.orig) if hasattr(error, 'orig') else str(error)
100+
101+
# Log full error
102+
logger.error(f"Database error: {error}")
103+
104+
# Map common database errors
105+
if isinstance(error, IntegrityError):
106+
if "UNIQUE constraint failed" in error_str:
107+
if "gateways.url" in error_str:
108+
return {"message": "A gateway with this URL already exists", "success": False}
109+
elif "gateways.name" in error_str:
110+
return {"message": "A gateway with this name already exists", "success": False}
111+
elif "tools.name" in error_str:
112+
return {"message": "A tool with this name already exists", "success": False}
113+
elif "resources.uri" in error_str:
114+
return {"message": "A resource with this URI already exists", "success": False}
115+
elif "FOREIGN KEY constraint failed" in error_str:
116+
return {"message": "Referenced item not found", "success": False}
117+
elif "NOT NULL constraint failed" in error_str:
118+
return {"message": "Required field is missing", "success": False}
119+
elif "CHECK constraint failed:" in error_str:
120+
return {"message": "Gateway validation failed. Please check the input data.", "success": False}
121+
122+
# Generic database error
123+
return {"message": "Unable to complete the operation. Please try again.", "success": False}

0 commit comments

Comments
 (0)