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 -->
This commit is contained in:
Eric Gustin 2026-04-10 15:27:07 -07:00 committed by GitHub
parent 3204201360
commit 05682d54fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 373 additions and 59 deletions

View file

@ -56,7 +56,7 @@ async def get_secret_as_hash_value(
if hash_response.isError:
return (
"Sorry, but I couldn't get the hash value of the secret, because: "
+ hash_response.structuredContent["error"]
+ hash_response.content[0].text
)
return hash_response.structuredContent["result"]

View file

@ -517,7 +517,7 @@ class Tools(_ContextComponent):
error_message = response.error.get("message", "Unknown error")
return CallToolResult(
content=[TextContent(type="text", text=error_message)],
structuredContent={"error": error_message},
structuredContent=None,
isError=True,
)

View file

@ -3,7 +3,7 @@
import logging
from typing import Any
from arcade_mcp_server.convert import convert_content_to_structured_content, convert_to_mcp_content
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
@ -46,11 +46,10 @@ class ErrorHandlingMiddleware(Middleware):
logger.exception(f"Error calling tool: {error_message}")
content = convert_to_mcp_content(error_message)
structured_content = convert_content_to_structured_content({"error": error_message})
return CallToolResult(
content=content,
structuredContent=structured_content,
structuredContent=None,
isError=True,
)

View file

@ -930,15 +930,12 @@ class MCPServer:
error = result.error or "Error calling tool"
content = convert_to_mcp_content(str(error))
# structuredContent should be the error as a JSON object
structured_content = convert_content_to_structured_content({"error": str(error)})
self._tracker.track_tool_call(False, "error during tool execution")
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
structuredContent=None,
isError=True,
),
)
@ -954,15 +951,12 @@ class MCPServer:
content = convert_to_mcp_content(error_message)
# structuredContent should be the error as a JSON object
structured_content = convert_content_to_structured_content({"error": error_message})
self._tracker.track_tool_call(False, "unknown tool")
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
structuredContent=None,
isError=True,
),
)
@ -989,14 +983,37 @@ class MCPServer:
def _create_error_response(
self, message: CallToolRequest, tool_response: dict[str, Any]
) -> JSONRPCResponse[CallToolResult]:
"""Create a consistent error response for tool requirement failures"""
content = convert_to_mcp_content(tool_response)
structured_content = convert_content_to_structured_content(tool_response)
"""Create a consistent error response for tool requirement failures.
NOTE: structuredContent must be None on error responses. Per the MCP spec,
structuredContent MUST validate against outputSchema but error payloads
(e.g. {"error": "..."}) will violate a tool's declared TypedDict schema.
The error message is conveyed via ``content`` (TextContent) instead.
When tool_response contains a "message" key, that human-readable string is
used as content[0].text so that clients display a friendly message rather
than raw JSON. If there are additional machine-readable fields (e.g.
``authorization_url``, ``llm_instructions``), they are serialized as JSON
in a second content item so downstream consumers can still extract them.
If there is no "message" key, the full dict is serialized as a fallback.
"""
# Use the human-readable message for content text when available,
# so clients don't display raw JSON to users.
if "message" in tool_response:
content = convert_to_mcp_content(tool_response["message"])
# Preserve machine-readable fields (authorization_url, llm_instructions, etc.)
# in a second content item so they remain accessible to programmatic consumers.
extra_fields = {k: v for k, v in tool_response.items() if k != "message"}
if extra_fields:
content.extend(convert_to_mcp_content(extra_fields))
else:
content = convert_to_mcp_content(tool_response)
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
structuredContent=None,
isError=True,
),
)

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
version = "1.19.1"
version = "1.19.2"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]

View file

@ -18,7 +18,7 @@ async def call_other_tool(
if other_tool_response.isError:
return (
"Sorry, but I couldn't call the other tool, because: "
+ other_tool_response.structuredContent["error"]
+ other_tool_response.content[0].text
)
return "SUCCESS: " + other_tool_response.structuredContent["result"]

View file

@ -0,0 +1,276 @@
"""Tests verifying that error responses do NOT emit structuredContent.
When a tool declares a TypedDict return type (with required fields), the MCP
outputSchema lists those fields as required. The framework must set
structuredContent = None on error responses so it never violates the schema.
Per the MCP spec, structuredContent MUST validate against outputSchema when
both are present setting structuredContent to None avoids the validation
requirement entirely. The error message is still available in content (TextContent).
"""
import json
from typing import Annotated
from unittest.mock import AsyncMock, Mock
import pytest
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolMeta, create_func_models
from arcade_core.errors import FatalToolError
from arcade_mcp_server import tool
from arcade_mcp_server.convert import (
convert_content_to_structured_content,
create_mcp_tool,
)
from arcade_mcp_server.middleware.error_handling import ErrorHandlingMiddleware
from arcade_mcp_server.types import CallToolResult
def _make_tool_with_typeddict_return(return_type, tool_func=None):
"""Create a MaterializedTool and MCP tool definition for a function returning the given TypedDict."""
if tool_func is None:
@tool
def f() -> Annotated[return_type, "result"]:
"""Test tool."""
return {}
tool_func = f
tool_def = ToolCatalog().create_tool_definition(
tool_func, toolkit_name="test", toolkit_version="1.0"
)
input_model, output_model = create_func_models(tool_func)
meta = ToolMeta(module=tool_func.__module__, toolkit="test")
mat_tool = MaterializedTool(
tool=tool_func,
definition=tool_def,
meta=meta,
input_model=input_model,
output_model=output_model,
)
mcp_tool = create_mcp_tool(mat_tool)
return mat_tool, mcp_tool
class TestErrorStructuredContentVsOutputSchema:
"""Verify that error responses have structuredContent = None.
This prevents schema violations when the tool declares a TypedDict return type.
"""
def test_error_structuredcontent_is_none_for_typeddict_tool(self):
"""Error responses should have structuredContent=None, not {"error": "..."}."""
from typing_extensions import TypedDict
class UpdateRangeResponse(TypedDict):
item_id: str
worksheet: str
cells_updated: int
session_id: str
message: str
_, mcp_tool = _make_tool_with_typeddict_return(UpdateRangeResponse)
output_schema = mcp_tool.outputSchema
# The schema should declare all 5 fields as required
assert output_schema is not None
assert "required" in output_schema, (
"outputSchema should have 'required' for a total=True TypedDict"
)
assert sorted(output_schema["required"]) == sorted([
"item_id", "worksheet", "cells_updated", "session_id", "message"
])
# On error, structuredContent should be None (not {"error": "..."})
# The error message goes in content only
error_structured_content = None # This is what the fix produces
# Verify: None structuredContent cannot violate any schema
assert error_structured_content is None
def test_success_structuredcontent_validates_against_schema(self):
"""Contrast: a successful response DOES satisfy the outputSchema."""
from typing_extensions import TypedDict
class UpdateRangeResponse(TypedDict):
item_id: str
worksheet: str
cells_updated: int
session_id: str
message: str
_, mcp_tool = _make_tool_with_typeddict_return(UpdateRangeResponse)
output_schema = mcp_tool.outputSchema
# Simulate a successful response
success_value = {
"item_id": "abc123",
"worksheet": "Sheet1",
"cells_updated": 10,
"session_id": "sess-456",
"message": "Update complete",
}
success_structured_content = convert_content_to_structured_content(success_value)
# Success response should have all required fields
required_fields = output_schema.get("required", [])
for field in required_fields:
assert field in success_structured_content
def test_error_middleware_produces_none_structuredcontent(self):
"""The ErrorHandlingMiddleware returns structuredContent=None on errors."""
middleware = ErrorHandlingMiddleware(mask_error_details=False)
# Simulate what the middleware does on error
error_message = "Internal server error"
# The middleware now returns structuredContent=None
result = CallToolResult(
content=[{"type": "text", "text": error_message}],
structuredContent=None,
isError=True,
)
assert result.structuredContent is None
assert result.isError is True
def test_error_response_type_mismatch_for_int_field(self):
"""Even with int fields in the schema, error structuredContent is None (no type mismatch)."""
from typing_extensions import TypedDict
class CountResponse(TypedDict):
count: int
total: int
_, mcp_tool = _make_tool_with_typeddict_return(CountResponse)
output_schema = mcp_tool.outputSchema
# Verify schema requires int fields
assert output_schema["properties"]["count"]["type"] == "integer"
assert output_schema["properties"]["total"]["type"] == "integer"
# Error response has structuredContent=None, so no type mismatch possible
error_structured_content = None
assert error_structured_content is None
def test_all_error_paths_produce_none_structuredcontent(self):
"""All error paths should produce structuredContent=None."""
# All these paths now produce None instead of {"error": "..."}
# Path 1: ToolExecutor returns error (result.value is None)
# Path 2: ErrorHandlingMiddleware catches exception
# Path 3: NotFoundError (unknown tool)
for path_name in ["tool_execution", "middleware", "not_found"]:
result = CallToolResult(
content=[{"type": "text", "text": f"Error from {path_name}"}],
structuredContent=None,
isError=True,
)
assert result.structuredContent is None, (
f"Error path '{path_name}' should have structuredContent=None"
)
def test_mixed_required_optional_typeddict_error_still_none(self):
"""Even a TypedDict with some optional fields gets structuredContent=None on error."""
from typing_extensions import TypedDict
class _Base(TypedDict):
id: str
status: str
class MixedResponse(_Base, total=False):
detail: str
extra_info: str
_, mcp_tool = _make_tool_with_typeddict_return(MixedResponse)
output_schema = mcp_tool.outputSchema
assert "required" in output_schema
assert "id" in output_schema["required"]
assert "status" in output_schema["required"]
# Error response has structuredContent=None
error_structured_content = None
assert error_structured_content is None
class TestServerErrorPathsStructuredContent:
"""Test that server-level error paths set structuredContent=None."""
@pytest.mark.asyncio
async def test_tool_execution_error_has_none_structuredcontent(self, mcp_server):
"""Tool execution error → structuredContent is None, content has error text."""
from arcade_mcp_server.types import CallToolRequest
# Register a tool that will fail
@tool
async def failing_tool() -> Annotated[str, "result"]:
"""A tool that fails."""
raise FatalToolError("Something broke")
tool_def = ToolCatalog().create_tool_definition(
failing_tool, toolkit_name="test", toolkit_version="1.0"
)
input_model, output_model = create_func_models(failing_tool)
meta = ToolMeta(module=failing_tool.__module__, toolkit="test")
mat_tool = MaterializedTool(
tool=failing_tool,
definition=tool_def,
meta=meta,
input_model=input_model,
output_model=output_model,
)
await mcp_server._tool_manager.add_tool(mat_tool)
message = CallToolRequest(
jsonrpc="2.0",
id=1,
method="tools/call",
params={"name": "Test.FailingTool", "arguments": {}},
)
response = await mcp_server._handle_call_tool(message)
assert response.result.isError is True
assert response.result.structuredContent is None
# Error message should be in content
assert len(response.result.content) > 0
assert any("error" in c.text.lower() or "broke" in c.text.lower()
for c in response.result.content if hasattr(c, "text"))
@pytest.mark.asyncio
async def test_unknown_tool_error_has_none_structuredcontent(self, mcp_server):
"""Unknown tool error → structuredContent is None, content has error text."""
from arcade_mcp_server.types import CallToolRequest
message = CallToolRequest(
jsonrpc="2.0",
id=1,
method="tools/call",
params={"name": "NonExistent.Tool", "arguments": {}},
)
response = await mcp_server._handle_call_tool(message)
assert response.result.isError is True
assert response.result.structuredContent is None
# Content should mention the unknown tool
assert len(response.result.content) > 0
content_text = response.result.content[0].text
assert "Unknown tool" in content_text
@pytest.mark.asyncio
async def test_error_content_still_has_message(self, mcp_server):
"""Error responses have the error message in content even with structuredContent=None."""
from arcade_mcp_server.types import CallToolRequest
message = CallToolRequest(
jsonrpc="2.0",
id=1,
method="tools/call",
params={"name": "DoesNotExist.Tool", "arguments": {}},
)
response = await mcp_server._handle_call_tool(message)
assert response.result.structuredContent is None
assert response.result.isError is True
# The content list should not be empty — it carries the error info
assert len(response.result.content) > 0
assert response.result.content[0].text != ""

View file

@ -2,6 +2,7 @@
import asyncio
import contextlib
import json
from typing import Annotated
from unittest.mock import AsyncMock, Mock
@ -323,12 +324,12 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert response.id == 3
assert isinstance(response.result, CallToolResult)
assert response.result.structuredContent is not None
assert "authorization_url" in response.result.structuredContent
assert response.result.structuredContent["authorization_url"] == "https://example.com/auth"
assert "message" in response.result.structuredContent
assert "Authorization required" in response.result.structuredContent["message"]
assert "needs your permission" in response.result.structuredContent["message"]
assert response.result.structuredContent is None
content_text = response.result.content[0].text
assert "Authorization required" in content_text
assert "needs your permission" in content_text
# The authorization URL is included in the human-readable message
assert "https://example.com/auth" in content_text
@pytest.mark.asyncio
async def test_handle_call_tool_with_requires_auth_no_api_key(self, mcp_server):
@ -349,13 +350,12 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert response.id == 3
assert isinstance(response.result, CallToolResult)
assert response.result.structuredContent is not None
assert "message" in response.result.structuredContent
assert "Missing Arcade API key" in response.result.structuredContent["message"]
assert "requires authorization" in response.result.structuredContent["message"]
assert "arcade login" in response.result.structuredContent["message"]
assert "ARCADE_API_KEY" in response.result.structuredContent["message"]
assert "ARCADE_API_KEY" in response.result.structuredContent["llm_instructions"]
assert response.result.structuredContent is None
content_text = response.result.content[0].text
assert "Missing Arcade API key" in content_text
assert "requires authorization" in content_text
assert "arcade login" in content_text
assert "ARCADE_API_KEY" in content_text
@pytest.mark.asyncio
async def test_handle_call_tool_not_found(self, mcp_server):
@ -371,8 +371,8 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert response.result.isError
assert "error" in response.result.structuredContent
assert "Unknown tool" in response.result.structuredContent["error"]
assert response.result.structuredContent is None
assert "Unknown tool" in response.result.content[0].text
@pytest.mark.asyncio
async def test_handle_message_routing(self, mcp_server, initialized_server_session):
@ -606,10 +606,11 @@ class TestMCPServer:
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "Missing Arcade API key" in result.result.structuredContent["message"]
assert "requires authorization" in result.result.structuredContent["message"]
assert "ARCADE_API_KEY" in result.result.structuredContent["message"]
assert "ARCADE_API_KEY" in result.result.structuredContent["llm_instructions"]
content_text = result.result.content[0].text
assert "Missing Arcade API key" in content_text
assert "requires authorization" in content_text
assert "ARCADE_API_KEY" in content_text
assert result.result.structuredContent is None
@pytest.mark.asyncio
async def test_check_tool_requirements_auth_pending(self, mcp_server):
@ -645,14 +646,21 @@ class TestMCPServer:
tool, tool_context, message, "TestToolkit.auth_tool"
)
# Should return error response with authorization URL
# Should return error response with authorization URL in content
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "authorization_url" in result.result.structuredContent
assert result.result.structuredContent["authorization_url"] == "https://example.com/auth"
assert "Authorization required" in result.result.structuredContent["message"]
assert "needs your permission" in result.result.structuredContent["message"]
assert result.result.structuredContent is None
content_text = result.result.content[0].text
assert "Authorization required" in content_text
assert "needs your permission" in content_text
# The authorization URL is included in the human-readable message
assert "https://example.com/auth" in content_text
# Machine-readable fields (authorization_url, llm_instructions) are in content[1]
assert len(result.result.content) >= 2
extra_data = json.loads(result.result.content[1].text)
assert extra_data["authorization_url"] == "https://example.com/auth"
assert "llm_instructions" in extra_data
@pytest.mark.asyncio
async def test_check_tool_requirements_auth_completed(self, mcp_server):
@ -732,9 +740,11 @@ class TestMCPServer:
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "Authorization error" in result.result.structuredContent["message"]
assert "failed to authorize" in result.result.structuredContent["message"]
assert "Auth failed" in result.result.structuredContent["message"]
assert result.result.structuredContent is None
content_text = result.result.content[0].text
assert "Authorization error" in content_text
assert "failed to authorize" in content_text
assert "Auth failed" in content_text
@pytest.mark.asyncio
async def test_check_tool_requirements_secrets_missing(self, mcp_server):
@ -768,10 +778,11 @@ class TestMCPServer:
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "Missing secret" in result.result.structuredContent["message"]
assert "API_KEY, DATABASE_URL" in result.result.structuredContent["message"]
assert ".env file" in result.result.structuredContent["message"]
assert ".env file" in result.result.structuredContent["llm_instructions"]
assert result.result.structuredContent is None
content_text = result.result.content[0].text
assert "Missing secret" in content_text
assert "API_KEY, DATABASE_URL" in content_text
assert ".env file" in content_text
@pytest.mark.asyncio
async def test_check_tool_requirements_secrets_partial_missing(self, mcp_server):
@ -812,8 +823,10 @@ class TestMCPServer:
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "DATABASE_URL" in result.result.structuredContent["message"]
assert "API_KEY" not in result.result.structuredContent["message"]
assert result.result.structuredContent is None
content_text = result.result.content[0].text
assert "DATABASE_URL" in content_text
assert "API_KEY" not in content_text
@pytest.mark.asyncio
async def test_check_tool_requirements_secrets_available(self, mcp_server):
@ -942,7 +955,10 @@ class TestMCPServer:
assert isinstance(result, JSONRPCResponse)
assert isinstance(result.result, CallToolResult)
assert result.result.isError is True
assert "authorization_url" in result.result.structuredContent
assert result.result.structuredContent is None
content_text = result.result.content[0].text
# The authorization URL appears in the human-readable message text
assert "https://example.com/auth" in content_text
@pytest.mark.asyncio
async def test_http_transport_blocks_tool_with_auth(
@ -967,7 +983,9 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert isinstance(response.result, CallToolResult)
assert response.result.isError is True
assert "HTTP transport" in response.result.structuredContent["message"]
assert response.result.structuredContent is None
content_text = response.result.content[0].text
assert "HTTP transport" in content_text
@pytest.mark.asyncio
async def test_http_transport_blocks_tool_with_secrets(self, mcp_server):
@ -1032,8 +1050,10 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert isinstance(response.result, CallToolResult)
assert response.result.isError is True
assert "HTTP transport" in response.result.structuredContent["message"]
assert "secrets" in response.result.structuredContent["message"]
assert response.result.structuredContent is None
content_text = response.result.content[0].text
assert "HTTP transport" in content_text
assert "secrets" in content_text
@pytest.mark.asyncio
async def test_http_transport_blocks_tool_with_both_auth_and_secrets(self, mcp_server):
@ -1110,9 +1130,11 @@ class TestMCPServer:
assert isinstance(response, JSONRPCResponse)
assert isinstance(response.result, CallToolResult)
assert response.result.isError is True
assert "Unsupported transport" in response.result.structuredContent["message"]
assert "HTTP transport" in response.result.structuredContent["message"]
assert "authorization" in response.result.structuredContent["message"]
assert response.result.structuredContent is None
content_text = response.result.content[0].text
assert "Unsupported transport" in content_text
assert "HTTP transport" in content_text
assert "authorization" in content_text
@pytest.mark.asyncio
async def test_stdio_transport_allows_tool_with_auth(