From 5cf4cd2083c56ef4db3fed4dc23efb39a0af3c72 Mon Sep 17 00:00:00 2001 From: mukunda katta Date: Mon, 20 Apr 2026 00:00:19 -0700 Subject: [PATCH 1/2] fix(shared): preserve MCPError payload on pickle --- src/mcp/shared/exceptions.py | 17 ++++++++++++++ tests/shared/test_exceptions.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index f153ea319..461df3899 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -5,6 +5,20 @@ from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError +def _restore_mcp_error(exc_type: type[MCPError], error: ErrorData) -> MCPError: + """Reconstruct a pickled MCPError or subclass from ErrorData.""" + if exc_type is UrlElicitationRequiredError: + return exc_type.from_error(error) + + if hasattr(exc_type, "from_error_data"): + return exc_type.from_error_data(error) + + restored = exc_type.__new__(exc_type) + Exception.__init__(restored, error.code, error.message, error.data) + restored.error = error + return restored + + class MCPError(Exception): """Exception type raised when an error arrives over an MCP connection.""" @@ -40,6 +54,9 @@ def from_error_data(cls, error: ErrorData) -> MCPError: def __str__(self) -> str: return self.message + def __reduce__(self) -> tuple[Any, tuple[type[MCPError], ErrorData]]: + return (_restore_mcp_error, (type(self), self.error)) + class StatelessModeNotSupported(RuntimeError): """Raised when attempting to use a method that is not supported in stateless mode. diff --git a/tests/shared/test_exceptions.py b/tests/shared/test_exceptions.py index 9a7466264..a4801000e 100644 --- a/tests/shared/test_exceptions.py +++ b/tests/shared/test_exceptions.py @@ -1,5 +1,7 @@ """Tests for MCP exception classes.""" +import pickle + import pytest from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError @@ -162,3 +164,41 @@ def test_url_elicitation_required_error_exception_message() -> None: # The exception's string representation should match the message assert str(error) == "URL elicitation required" + + +def test_mcp_error_pickle_roundtrip() -> None: + """Test that MCPError survives a normal pickle round-trip.""" + original = MCPError( + code=-32600, + message="Authentication Required", + data={"scope": "files.read"}, + ) + + restored = pickle.loads(pickle.dumps(original)) + + assert isinstance(restored, MCPError) + assert restored.code == -32600 + assert restored.message == "Authentication Required" + assert restored.data == {"scope": "files.read"} + assert str(restored) == "Authentication Required" + + +def test_url_elicitation_required_error_pickle_roundtrip() -> None: + """Test that specialized MCPError subclasses survive pickle too.""" + original = UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + ] + ) + + restored = pickle.loads(pickle.dumps(original)) + + assert isinstance(restored, UrlElicitationRequiredError) + assert restored.elicitations[0].elicitation_id == "test-123" + assert restored.elicitations[0].url == "https://example.com/auth" + assert restored.message == "URL elicitation required" From c85c43fb9bf6c0506c8b313e5c781981414c8162 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Wed, 22 Apr 2026 08:05:38 -0700 Subject: [PATCH 2/2] Simplify _restore_mcp_error: every MCPError subclass inherits from_error_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defensive hasattr/fallback branch was unreachable in practice — every MCPError subclass inherits from_error_data() from the base class, so the exc_type.__new__(...) fallback path never fires. Removing it brings coverage to 100% and keeps the reconstructor to 2 lines. Behavior unchanged: - MCPError pickle round-trip - UrlElicitationRequiredError pickle round-trip (specialized path) - Arbitrary MCPError subclass pickle round-trip All 11 tests/shared/test_exceptions.py tests pass. --- src/mcp/shared/exceptions.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 461df3899..9fb43a4ab 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -6,17 +6,16 @@ def _restore_mcp_error(exc_type: type[MCPError], error: ErrorData) -> MCPError: - """Reconstruct a pickled MCPError or subclass from ErrorData.""" + """Reconstruct a pickled MCPError (or subclass) from ErrorData. + + Every MCPError subclass inherits :meth:`MCPError.from_error_data`, so the + generic path is sufficient. :class:`UrlElicitationRequiredError` needs its + specialized reconstructor because it interprets ``error.data`` as a list + of elicitations, not free-form data. + """ if exc_type is UrlElicitationRequiredError: return exc_type.from_error(error) - - if hasattr(exc_type, "from_error_data"): - return exc_type.from_error_data(error) - - restored = exc_type.__new__(exc_type) - Exception.__init__(restored, error.code, error.message, error.data) - restored.error = error - return restored + return exc_type.from_error_data(error) class MCPError(Exception):