From 2a6c5886fcf6671cba8fcb9750572834ab6b8374 Mon Sep 17 00:00:00 2001 From: Rocky Date: Sun, 10 May 2026 06:21:40 +0000 Subject: [PATCH 1/3] Support is_error flag for structured tool results (fixes #348)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/migration.md | 9 +++++++++ src/mcp/server/mcpserver/server.py | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 8b70885e8..f281f856a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1136,3 +1136,12 @@ If you encounter issues during migration: 1. Check the [API Reference](api/mcp/index.md) for updated method signatures 2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns 3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance + +## Tool result: marking non-text results as errors +Tools may now return a 3-tuple: (unstructured_content, structured_content, is_error). +If the third element is True, the resulting CallToolResult sent to clients will have +is_error=True. This allows returning non-text content (images, audio) while still +indicating the tool execution failed or produced an error state. + +Alternatively, tools may return a full `CallToolResult` instance directly to control +is_error and other fields explicitly. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b3471163b..6758a0e75 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -316,12 +316,24 @@ async def _handle_call_tool( return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) if isinstance(result, CallToolResult): return result - if isinstance(result, tuple) and len(result) == 2: - unstructured_content, structured_content = result + if isinstance(result, tuple): + # Support either (unstructured_content, structured_content) or + # (unstructured_content, structured_content, is_error). The third element, + # if present, controls the CallToolResult.is_error flag. + if len(result) == 2: + unstructured_content, structured_content = result + is_error = False + elif len(result) == 3: + unstructured_content, structured_content, is_error = result + else: + # Fallback: treat as a sequence of content blocks + return CallToolResult(content=list(result)) return CallToolResult( content=list(unstructured_content), # type: ignore[arg-type] structured_content=structured_content, # type: ignore[arg-type] + is_error=bool(is_error), ) + if isinstance(result, dict): # pragma: no cover # TODO: this code path is unreachable — convert_result never returns a raw dict. # The call_tool return type (Sequence[ContentBlock] | dict[str, Any]) is wrong From 0bf49a2fc414a351cff0e098d4ee2a76c072441f Mon Sep 17 00:00:00 2001 From: Rocky Date: Sun, 10 May 2026 06:23:16 +0000 Subject: [PATCH 2/3] Handle BrokenResourceError in stdio client stdout reader (fixes #1564)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/client/stdio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..600fa8276 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -158,7 +158,7 @@ async def stdout_reader(): session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: lax no cover + except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: lax no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): From 476ee28bf4718a283ce3cbb3bb39037c680c15a3 Mon Sep 17 00:00:00 2001 From: Rocky Date: Sun, 10 May 2026 06:31:05 +0000 Subject: [PATCH 3/3] Avoid closing real stdio when using stdio transport (fixes #1933)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/server/stdio.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff..28fb21dd5 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -18,6 +18,7 @@ async def run_server(): """ import sys +import os from contextlib import asynccontextmanager from io import TextIOWrapper @@ -39,9 +40,13 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. # python is platform-dependent (Windows is particularly problematic), so we # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) + # Duplicate the underlying file descriptors so closing the wrapper + # does not close the real process stdio (fixes issue #1933). + stdin_dup = os.fdopen(os.dup(sys.stdin.buffer.fileno()), 'rb') + stdin = anyio.wrap_file(TextIOWrapper(stdin_dup, encoding="utf-8", errors="replace")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + stdout_dup = os.fdopen(os.dup(sys.stdout.buffer.fileno()), 'wb') + stdout = anyio.wrap_file(TextIOWrapper(stdout_dup, encoding="utf-8")) read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) write_stream, write_stream_reader = create_context_streams[SessionMessage](0)