arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/middleware/error_handling.py
Eric Gustin 05682d54fe
Don't return structuredContent when error (#817)
We recently added outputSchema support for our MCP tools (not yet for
worker routes yet). Today, we always return structuredContent. On tool
execution errors we return structuredContent: {"error": "..."} with
isError: True, even when that shape does not match the tool’s declared
outputSchema. Since the MCP spec says clients SHOULD validate
structuredContent against outputSchema, some clients reject these
responses.

Since structuredContent is optional, we’re going to omit it when
isError: true.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes the shape of tool error responses across the MCP server, which
may break clients or tools that previously relied on
`structuredContent["error"]` for failures. Behavior is more
spec-compliant but touches core request/response paths and test
expectations.
> 
> **Overview**
> Prevents MCP tool error responses from violating a tool’s declared
`outputSchema` by **always setting `structuredContent=None` when
`isError=True`** (server execution errors, unknown tools, middleware
exceptions, and `Context.tools.call_raw` JSON-RPC errors).
> 
> Updates requirement-failure error formatting to put the human-friendly
message in `content[0]` and (when present) serialize extra
machine-readable fields (e.g. `authorization_url`, `llm_instructions`)
into an additional `content` item. Examples and integration/unit tests
are updated to read errors from `content[0].text`, and
`arcade-mcp-server` is bumped to `1.19.2`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4213bdd4aa44362de85c30f5f31c576243c132d5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-10 15:27:07 -07:00

106 lines
3.8 KiB
Python

"""Error handling middleware for MCP server."""
import logging
from typing import Any
from arcade_mcp_server.convert import convert_to_mcp_content
from arcade_mcp_server.middleware.base import CallNext, Middleware, MiddlewareContext
from arcade_mcp_server.types import CallToolResult, JSONRPCError
logger = logging.getLogger("arcade.mcp")
class ErrorHandlingMiddleware(Middleware):
"""Middleware that handles errors and converts them to appropriate responses."""
def __init__(self, mask_error_details: bool = True):
"""Initialize error handling middleware.
Args:
mask_error_details: Whether to mask error details in responses
"""
self.mask_error_details = mask_error_details
async def on_message(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Wrap all messages with error handling."""
try:
return await call_next(context)
except Exception as e:
return self._handle_error(context, e)
async def on_call_tool(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Handle tool call errors specially."""
try:
return await call_next(context)
except Exception as e:
# For tool calls, return error as CallToolResult
error_message = self._get_error_message(e)
logger.exception(f"Error calling tool: {error_message}")
content = convert_to_mcp_content(error_message)
return CallToolResult(
content=content,
structuredContent=None,
isError=True,
)
def _handle_error(self, context: MiddlewareContext[Any], error: Exception) -> Any:
"""Convert exception to appropriate error response."""
error_message = self._get_error_message(error)
# Log the full error
logger.exception(f"Error processing {context.method}: {error}")
# Get request ID if available
request_id = context.request_id
if not request_id and hasattr(context.message, "id"):
request_id = str(getattr(context.message, "id", "unknown"))
# Return JSON-RPC error
return JSONRPCError(
id=request_id or "unknown",
error={
"code": self._get_error_code(error),
"message": error_message,
},
)
def _get_error_message(self, error: Exception) -> str:
"""Get appropriate error message based on configuration."""
if self.mask_error_details:
# Return generic message for security
error_type = type(error).__name__
if error_type in ["ValueError", "TypeError", "KeyError"]:
return "Invalid request parameters"
elif error_type in ["NotFoundError", "FileNotFoundError"]:
return "Resource not found"
elif error_type in ["PermissionError", "AuthorizationError"]:
return "Permission denied"
else:
return "Internal server error"
else:
# Return actual error message for debugging
return str(error)
def _get_error_code(self, error: Exception) -> int:
"""Get JSON-RPC error code for exception."""
error_type = type(error).__name__
# Map common errors to JSON-RPC codes
if error_type in ["ValueError", "TypeError", "KeyError"]:
return -32602 # Invalid params
elif error_type in ["NotFoundError", "FileNotFoundError"]:
return -32601 # Method not found
elif error_type in ["PermissionError", "AuthorizationError"]:
return -32603 # Internal error (used for auth)
else:
return -32603 # Generic internal error