Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ async def handler(req: types.CallToolRequest):
# output normalization
unstructured_content: UnstructuredContent
maybe_structured_content: StructuredContent | None
is_error = False

if isinstance(results, types.CallToolResult):
return types.ServerResult(results)
elif isinstance(results, types.CreateTaskResult):
Expand All @@ -556,12 +558,18 @@ async def handler(req: types.CallToolRequest):
else: # pragma: no cover
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")

# output validation
# output validation: a tool with an outputSchema must still
# be able to surface a tool-execution failure via unstructured
# content. If structured output is missing for such a tool,
# treat the unstructured payload as a tool-error result
# (isError=true) instead of replacing it with a hard
# "outputSchema defined but no structured output returned"
# error that obscures the original failure message. This
# mirrors the TypeScript SDK fix in
# modelcontextprotocol/typescript-sdk#655.
if tool and tool.outputSchema is not None:
if maybe_structured_content is None:
return self._make_error_result(
"Output validation error: outputSchema defined but no structured output returned"
)
is_error = True
else:
try:
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
Expand All @@ -573,7 +581,7 @@ async def handler(req: types.CallToolRequest):
types.CallToolResult(
content=list(unstructured_content),
structuredContent=maybe_structured_content,
isError=False,
isError=is_error,
)
)
except UrlElicitationRequiredError:
Expand Down
23 changes: 18 additions & 5 deletions tests/server/test_lowlevel_output_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,15 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:


@pytest.mark.anyio
async def test_content_only_with_output_schema_error():
"""Test error when outputSchema is defined but only content is returned."""
async def test_content_only_with_output_schema_surfaces_error():
"""When an outputSchema-bound tool returns only unstructured content,
the result is treated as a tool-error (isError=True) and the original
unstructured payload is preserved so the caller sees the tool's own
error message instead of a generic validation message.

Mirrors the TypeScript SDK fix in modelcontextprotocol/typescript-sdk#655.
See #2429 for the original report.
"""
tools = [
Tool(
name="structured_tool",
Expand All @@ -234,21 +241,27 @@ async def test_content_only_with_output_schema_error():
]

async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
# This returns only content, but outputSchema expects structured data
# This returns only content, but outputSchema expects structured data.
# Real-world callers use this path to surface tool-execution errors
# like "Resource not found" or "API rate limit hit"; that text must
# not be replaced by a generic validation message.
return [TextContent(type="text", text="This is not structured")]

async def test_callback(client_session: ClientSession) -> CallToolResult:
return await client_session.call_tool("structured_tool", {})

result = await run_tool_test(tools, call_tool_handler, test_callback)

# Verify error
# Result is flagged as an error (isError=True) so callers can detect it,
# but the tool's original unstructured payload is preserved verbatim.
assert result is not None
assert result.isError
assert len(result.content) == 1
assert result.content[0].type == "text"
assert isinstance(result.content[0], TextContent)
assert "Output validation error: outputSchema defined but no structured output returned" in result.content[0].text
assert result.content[0].text == "This is not structured"
# No structured content was produced; that field stays None.
assert result.structuredContent is None


@pytest.mark.anyio
Expand Down
Loading