From 58889e60d39aa78db4b82799f4a1bd18ec12a542 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 14 Apr 2026 23:07:38 -0700 Subject: [PATCH 1/2] fix(server): allow tools with outputSchema to surface errors via unstructured content Per #2429, a low-level tool with an `outputSchema` cannot return a tool-execution failure (e.g. "Resource not found") via unstructured `TextContent` because the call_tool decorator unconditionally validates the result against `outputSchema` and replaces the original error message with a hard "outputSchema defined but no structured output returned" error. Mirror the TypeScript SDK fix (modelcontextprotocol/typescript-sdk#655): when an outputSchema-bound tool returns no structured content, treat the unstructured payload as a tool-error result (`isError=True`) instead of replacing it. Tools that *do* return structured output keep their existing validation contract, and the explicit `CallToolResult`-returning path is unchanged. Github-Issue:#2429 --- src/mcp/server/lowlevel/server.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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: From 5239245385fcd91fc582086c2afc5f9e85e9d833 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Mon, 27 Apr 2026 11:06:39 -0700 Subject: [PATCH 2/2] test(lowlevel): update outputSchema-no-structured-content test for new behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI flagged that the existing test_content_only_with_output_schema_error asserted the old contract (replace tool output with a generic 'outputSchema validation' message). This PR's whole point is to NOT do that, so the assertion was guaranteed to flip. Renames the test to test_content_only_with_output_schema_surfaces_error and updates assertions to verify the new contract: - isError = True (so callers can branch on it) - the tool's original unstructured TextContent payload is preserved verbatim (so callers see the tool's own error message, e.g. 'Resource not found', instead of a generic SDK validation string) - structuredContent stays None Verified locally with `python -m pytest tests/server/test_lowlevel_output_validation.py` → 9 passed. Signed-off-by: SAY-5 --- .../server/test_lowlevel_output_validation.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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