Skip to content

McpError is not pickle-safe and fails to unpickle #2431

Description

@rain87

Initial Checks

Description

Summary

mcp.shared.exceptions.McpError does not survive a normal cloudpickle.dumps() / cloudpickle.loads() round-trip.

The failure appears to come from McpError.__init__ expecting an ErrorData object, while exception unpickling reconstructs it with a plain string from Exception.args.

This is surfacing for us through background task execution, but the bug reproduces without Docket/FastMCP task machinery.

Actual behavior

Unpickling fails with:

AttributeError: 'str' object has no attribute 'message'

Traceback points at McpError.__init__:

class McpError(Exception):
    error: ErrorData

    def __init__(self, error: ErrorData):
        super().__init__(error.message)
        self.error = error

Expected behavior

McpError(ErrorData(...)) should round-trip through pickle/cloudpickle without crashing.

At minimum, this should work:

  • serialize McpError
  • deserialize McpError
  • preserve the message
  • preserve the error payload, or at least degrade safely without raising during unpickle

Suspected root cause

McpError stores error.message in Exception.args via super().__init__(error.message).

On unpickle, exception reconstruction uses args, so McpError is effectively reconstructed as:

McpError("Authentication Required")

But McpError.__init__ assumes error is always an ErrorData, so it does:

error.message

which crashes for str.

Suggested fix

McpError likely needs to be pickle-safe by design. Any of these would probably fix it:

  1. Make __init__ accept both ErrorData and str, normalizing str into an ErrorData.
  2. Implement __reduce__ so pickle reconstructs using the full ErrorData.
  3. Ensure constructor args and exception state are aligned with standard exception pickling behavior.

A robust version would probably do both __reduce__ and tolerant initialization.

Notes

This bug is easy to misattribute to cloudpickle or task runners, but the reproducer above shows it is local to McpError itself.

Example Code

from importlib.metadata import version

import cloudpickle
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData


print("Versions:")
print(f"  mcp={version('mcp')}")
print(f"  cloudpickle={version('cloudpickle')}")

original = McpError(ErrorData(code=-32600, message="Authentication Required"))

print("\nOriginal exception:")
print(f"  type={type(original).__name__}")
print(f"  str={str(original)!r}")
print(f"  error_type={type(original.error).__name__}")
print(f"  error_message={original.error.message!r}")

payload = cloudpickle.dumps(original)

print("\nUnpickling:")
restored = cloudpickle.loads(payload)
print(f"  restored_type={type(restored).__name__}")
print(f"  restored_args={restored.args!r}")
print(f"  restored_error={getattr(restored, 'error', None)!r}")

Python & MCP Python SDK

- `mcp==1.26.0`
- `fastmcp==3.2.3`
- `cloudpickle==3.1.2`
- Python 3.13

Activity

  1. faridun-m commented on Apr 13, 2026

    @faridun-m

    Hi! I'd like to work on this.
    I've reproduced the bug on mcp==1.27.0 with both pickle and cloudpickle. As you noted, the root cause is super().init(error.message) storing a plain string in Exception.args, while McpError.init expects ErrorData.
    I agree that combining approaches 2 + 1 is the most robust fix:

    reduce to preserve the full ErrorData through pickle round-trips
    Tolerant init that normalizes str → ErrorData as a safety net

    I also noticed UrlElicitationRequiredError has the same issue on v1.x — its init expects (elicitations, message) but inherits args that don't match. I'll fix both in the same PR.
    Targeting v1.x per the contributing guide. Can I be assigned?

  2. added a commit that references this issue on Apr 14, 2026
    8dc5231
  3. mcp-claude commented on Apr 17, 2026

    @mcp-claude

    bug is partially fixed on main (MCPError now pickle-safe), but UrlElicitationRequiredError still fails. both McpError and UrlElicitationRequiredError fail on v1.x.

    root cause: pickle reconstructs exceptions by calling cls(*self.args). on main, UrlElicitationRequiredError stores args=(code_int, message_str, data_dict) via MCPError.__init__, but its own __init__ expects (elicitations: list[ElicitRequestURLParams], message) — the types don't match. on v1.x, McpError stores args=(message_str,) but its __init__ expects an ErrorData object.

    workaround: implement __reduce__ to control pickle reconstruction, or add a __new__-compatible signature.

    repro script (repro.py, runs on main)
    import pickle
    import traceback
    
    from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
    from mcp.types import ElicitRequestURLParams, ErrorData
    
    print("=== MCPError pickle round-trip ===")
    try:
        original = MCPError(code=-32600, message="Authentication Required")
        payload = pickle.dumps(original)
        restored = pickle.loads(payload)
        print(f"restored message: {restored.message!r}")
        print("MCPError: PASS")
    except Exception as e:
        print(f"MCPError: FAIL — {type(e).__name__}: {e}")
        traceback.print_exc()
    
    print()
    print("=== UrlElicitationRequiredError pickle round-trip ===")
    try:
        elicitations = [
            ElicitRequestURLParams(
                message="Authorization required",
                url="https://example.com/oauth/authorize",
                elicitation_id="auth-001",
            )
        ]
        original = UrlElicitationRequiredError(elicitations)
        print(f"original: args={original.args!r}")
        payload = pickle.dumps(original)
        restored = pickle.loads(payload)
        print(f"restored elicitations: {restored.elicitations!r}")
        print("UrlElicitationRequiredError: PASS")
    except Exception as e:
        print(f"UrlElicitationRequiredError: FAIL — {type(e).__name__}: {e}")
        traceback.print_exc()
    command + output (main, commit 3d7b311)
    $ uv run python repro.py
    === MCPError pickle round-trip ===
    original: args=(-32600, 'Authentication Required', None), error=ErrorData(...)
    restored message: 'Authentication Required'
    MCPError: PASS
    
    === UrlElicitationRequiredError pickle round-trip ===
    original: args=(-32042, 'URL elicitation required', {'elicitations': [...]})
    UrlElicitationRequiredError: FAIL — TypeError: UrlElicitationRequiredError.__init__() takes from 2 to 3 positional arguments but 4 were given
    
    v1.x (commit 73d458b)

    both fail:

    • McpError: AttributeError: 'str' object has no attribute 'message'
    • UrlElicitationRequiredError: AttributeError: 'str' object has no attribute 'model_dump'

    status: fixed on main for MCPError, not fixed for UrlElicitationRequiredError. v1.x needs both fixed. fix is straightforward — add __reduce__ to UrlElicitationRequiredError on main; add __reduce__ to both McpError and UrlElicitationRequiredError on v1.x.

    suggested fix
    # src/mcp/shared/exceptions.py
             elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations]
             return cls(elicitations, error.message)
    +
    +    def __reduce__(self) -> tuple:
    +        return (self.from_error, (self.error,))

    test to verify: pickle round-trip of UrlElicitationRequiredError restores elicitations list and message intact (see test_url_elicitation_required_error_pickle_roundtrip in tests/shared/test_exceptions.py).

  4. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    fix proposedBot has a verified fix diff in the comment
    on Apr 17, 2026
  5. Christian-Sidak commented on Apr 19, 2026

    @Christian-Sidak

    Opened a fix in #2471 -- adds a __reduce__ method to UrlElicitationRequiredError so it round-trips correctly through pickle, and adds pickle round-trip tests for both MCPError and UrlElicitationRequiredError.

  6. Naveenreddie-think commented on Jul 23, 2026

    @Naveenreddie-think

    I'd like to pick this up , noticed PR #2439 had a working approach that stalled from inactivity. Happy to open a fresh PR building on that fix if that works.

  7. IgorGanapolsky commented on Jul 24, 2026

    @IgorGanapolsky

    Reliability note when MCP errors cross process boundaries:

    If McpError is not pickle-safe, any fan-out that uses multiprocessing / job queues / Ray-style workers will convert a classified tool failure into an unpickling crash. Operators then see “worker died” instead of the original MCP error code/message — classic expected-vs-actual loss.

    Fail-closed patterns while fixed:

    1. Catch McpError in the worker and re-raise a plain exception with code, message, and data as strings/dicts only.
    2. Prefer JSON serialization for cross-process tool results; do not rely on pickle for control-plane errors.
    3. Test: pickle.loads(pickle.dumps(err)) in CI for every public error type the SDK exports.

    Free diagnostic framing only — no product pitch.

  8. guiyangyuan commented on Aug 19, 2026

    @guiyangyuan

    I reproduced this against current main: MCPError round-trips successfully, while UrlElicitationRequiredError fails during unpickling because its inherited exception args do not match its constructor signature.

    I have a minimal v2-scoped fix that defines reduce to reconstruct through the existing from_error method, together with a focused pickle round-trip test preserving the complete ErrorData and elicitation payload.

    Local validation: the regression test fails before the fix and passes afterward; the full suite reports 5712 passed with 100% coverage, and Ruff and Linux Pyright are clean.

    I would be happy to submit the PR. Please assign #2431 to @guiyangyuan if this approach is welcome.

  9. mikemikimike commented on Aug 27, 2026

    @mikemikimike

    I was able to reproduce this issue with the current MCP Python SDK.

    The failure appears to come from the mismatch between McpError.__init__, which expects an ErrorData instance, and the standard exception pickling path, which reconstructs the exception from Exception.args.

    If this issue is suitable for assignment, I’d be happy to work on it. My initial approach would be to add a focused pickle round-trip regression test, then make McpError reconstruct safely while preserving the original ErrorData payload. I’ll also verify compatibility with the existing constructor and error-handling paths, including the message and optional data fields.

    Please let me know if this direction fits the project’s expectations.

  10. jstar0 commented on Aug 28, 2026

    @jstar0

    I would like to prepare the remaining v1.x backport for this issue. Current main already handles the MCPError case, while v1.x still needs pickle-safe reconstruction for McpError and UrlElicitationRequiredError; I will keep the change limited to the exception model and focused round-trip tests.

    I have reviewed the repository guidance and will disclose AI assistance in the PR while personally reviewing the implementation and verification. Please assign this issue to me if this scope is still welcome.

  11. jstar0 commented on Aug 28, 2026

    @jstar0

    Independent v1.x confirmation: on the current maintenance branch, standard-library pickle.loads(pickle.dumps(...)) still fails for both McpError (AttributeError: str has no attribute message) and UrlElicitationRequiredError (AttributeError: str has no attribute model_dump). The two focused regression tests in PR #3410 pass with explicit reconstruction from the original ErrorData, while the full v1.x suite passes with the proxy environment disabled.

    PR #3410 was auto-closed by the repository readiness workflow because this issue is not assigned to the contributor; this is an assignment gate, not an implementation failure. I will leave the branch available and wait for maintainer assignment rather than opening another PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions