diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 2dd1a8277..b6dc913e6 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -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): @@ -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) @@ -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: diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py index f73544521..30bbd91be 100644 --- a/tests/server/test_lowlevel_output_validation.py +++ b/tests/server/test_lowlevel_output_validation.py @@ -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", @@ -234,7 +241,10 @@ 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: @@ -242,13 +252,16 @@ async def test_callback(client_session: ClientSession) -> CallToolResult: 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