From 5e57bea807cb534fb8844be0014d566332b107ac Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Jan 2026 17:43:51 +0100 Subject: [PATCH 1/4] refactor: McpError renamed and flatten parameters --- docs/experimental/tasks-client.md | 6 +-- docs/migration.md | 32 +++++++++++ .../clients/url_elicitation_client.py | 6 +-- src/mcp/__init__.py | 4 +- src/mcp/client/session_group.py | 38 +++++-------- .../server/experimental/request_context.py | 20 +++---- .../server/experimental/session_features.py | 4 +- src/mcp/server/experimental/task_context.py | 27 +++------- .../experimental/task_result_handler.py | 6 +-- src/mcp/server/lowlevel/experimental.py | 10 +--- src/mcp/server/lowlevel/server.py | 13 ++--- src/mcp/server/session.py | 2 +- src/mcp/server/validation.py | 15 ++---- src/mcp/shared/exceptions.py | 46 ++++++++++------ .../shared/experimental/tasks/capabilities.py | 31 +++-------- src/mcp/shared/experimental/tasks/helpers.py | 19 ++----- src/mcp/shared/session.py | 18 +++---- src/mcp/types/_types.py | 4 +- src/mcp/types/jsonrpc.py | 12 ++--- tests/client/test_session_group.py | 8 +-- tests/client/test_stdio.py | 4 +- .../experimental/tasks/server/test_server.py | 4 +- .../tasks/server/test_server_task_context.py | 10 ++-- tests/experimental/tasks/server/test_store.py | 18 +++---- .../tasks/server/test_task_result_handler.py | 8 +-- tests/experimental/tasks/test_capabilities.py | 18 +++---- .../tasks/test_request_context.py | 6 +-- tests/issues/test_88_random_error.py | 4 +- tests/server/mcpserver/test_server.py | 6 +-- .../test_url_elicitation_error_throw.py | 47 ++++++++-------- tests/server/test_cancel_handling.py | 4 +- tests/server/test_session.py | 12 ++--- tests/server/test_validation.py | 10 ++-- tests/shared/test_exceptions.py | 6 +-- tests/shared/test_session.py | 14 ++--- tests/shared/test_sse.py | 9 ++-- tests/shared/test_streamable_http.py | 54 ++++--------------- tests/shared/test_ws.py | 23 ++------ 38 files changed, 251 insertions(+), 327 deletions(-) diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md index cfd23e4e14..0374ed86b5 100644 --- a/docs/experimental/tasks-client.md +++ b/docs/experimental/tasks-client.md @@ -337,7 +337,7 @@ if __name__ == "__main__": Handle task errors gracefully: ```python -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError try: result = await session.experimental.call_tool_as_task("my_tool", args) @@ -349,8 +349,8 @@ try: final = await session.experimental.get_task_result(task_id, CallToolResult) -except McpError as e: - print(f"MCP error: {e.error.message}") +except MCPError as e: + print(f"MCP error: {e.message}") except Exception as e: print(f"Error: {e}") ``` diff --git a/docs/migration.md b/docs/migration.md index 63828f481e..822a281e18 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -121,6 +121,38 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) ``` +### `McpError` renamed to `MCPError` + +The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError + +try: + result = await session.call_tool("my_tool") +except McpError as e: + print(f"Error: {e.message}") +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError + +try: + result = await session.call_tool("my_tool") +except MCPError as e: + print(f"Error: {e.message}") +``` + +`MCPError` is also exported from the top-level `mcp` package: + +```python +from mcp import MCPError +``` + ### `FastMCP` renamed to `MCPServer` The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself. diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py index 300c38fa0c..8cf1f88f00 100644 --- a/examples/snippets/clients/url_elicitation_client.py +++ b/examples/snippets/clients/url_elicitation_client.py @@ -33,7 +33,7 @@ from mcp import ClientSession, types from mcp.client.sse import sse_client from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError from mcp.types import URL_ELICITATION_REQUIRED @@ -160,9 +160,9 @@ async def call_tool_with_error_handling( return result - except McpError as e: + except MCPError as e: # Check if this is a URL elicitation required error - if e.error.code == URL_ELICITATION_REQUIRED: + if e.code == URL_ELICITATION_REQUIRED: print("\n[Tool requires URL elicitation to proceed]") # Convert to typed error to access elicitations diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 9823523148..4b5caa9cca 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -4,7 +4,7 @@ from .client.stdio import StdioServerParameters, stdio_client from .server.session import ServerSession from .server.stdio import stdio_server -from .shared.exceptions import McpError, UrlElicitationRequiredError +from .shared.exceptions import MCPError, UrlElicitationRequiredError from .types import ( CallToolRequest, ClientCapabilities, @@ -96,7 +96,7 @@ "ListToolsResult", "LoggingLevel", "LoggingMessageNotification", - "McpError", + "MCPError", "Notification", "PingRequest", "ProgressNotification", diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 4c09d92d72..041a89b32f 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -25,7 +25,7 @@ from mcp.client.stdio import StdioServerParameters from mcp.client.streamable_http import streamable_http_client from mcp.shared._httpx_utils import create_mcp_http_client -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT @@ -216,11 +216,9 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None: session_known_for_stack = session in self._session_exit_stacks if not session_known_for_components and not session_known_for_stack: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message="Provided session is not managed or already disconnected.", - ) + raise MCPError( + code=types.INVALID_PARAMS, + message="Provided session is not managed or already disconnected.", ) if session_known_for_components: # pragma: no branch @@ -352,7 +350,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session name = self._component_name(prompt.name, server_info) prompts_temp[name] = prompt component_names.prompts.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch prompts: {err}") # Query the server for its resources and aggregate to list. @@ -362,7 +360,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session name = self._component_name(resource.name, server_info) resources_temp[name] = resource component_names.resources.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch resources: {err}") # Query the server for its tools and aggregate to list. @@ -373,7 +371,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session tools_temp[name] = tool tool_to_session_temp[name] = session component_names.tools.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch tools: {err}") # Clean up exit stack for session if we couldn't retrieve anything @@ -384,28 +382,16 @@ async def _aggregate_components(self, server_info: types.Implementation, session # Check for duplicates. matching_prompts = prompts_temp.keys() & self._prompts.keys() if matching_prompts: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_prompts} already exist in group prompts.", - ) - ) + raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_prompts} already exist in group prompts.") matching_resources = resources_temp.keys() & self._resources.keys() if matching_resources: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_resources} already exist in group resources.", - ) + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_resources} already exist in group resources.", ) matching_tools = tools_temp.keys() & self._tools.keys() if matching_tools: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_tools} already exist in group tools.", - ) - ) + raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_tools} already exist in group tools.") # Aggregate components. self._sessions[session] = component_names diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 14059f7f3f..09c217e981 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -13,7 +13,7 @@ from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.experimental.task_support import TaskSupport from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY, is_terminal from mcp.types import ( METHOD_NOT_FOUND, @@ -72,13 +72,13 @@ def validate_task_mode( Args: tool_task_mode: The tool's execution.taskSupport value ("forbidden", "optional", "required", or None) - raise_error: If True, raises McpError on validation failure. If False, returns ErrorData. + raise_error: If True, raises MCPError on validation failure. If False, returns ErrorData. Returns: None if valid, ErrorData if invalid and raise_error=False Raises: - McpError: If invalid and raise_error=True + MCPError: If invalid and raise_error=True """ mode = tool_task_mode or TASK_FORBIDDEN @@ -86,18 +86,12 @@ def validate_task_mode( error: ErrorData | None = None if mode == TASK_REQUIRED and not self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool requires task-augmented invocation", - ) + error = ErrorData(code=METHOD_NOT_FOUND, message="This tool requires task-augmented invocation") elif mode == TASK_FORBIDDEN and self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool does not support task-augmented invocation", - ) + error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation") if error is not None and raise_error: - raise McpError(error) + raise MCPError(code=METHOD_NOT_FOUND, message=error.message) return error @@ -113,7 +107,7 @@ def validate_for_tool( Args: tool: The Tool definition - raise_error: If True, raises McpError on validation failure. + raise_error: If True, raises MCPError on validation failure. Returns: None if valid, ErrorData if invalid and raise_error=False diff --git a/src/mcp/server/experimental/session_features.py b/src/mcp/server/experimental/session_features.py index a189c3cbca..bfede64bed 100644 --- a/src/mcp/server/experimental/session_features.py +++ b/src/mcp/server/experimental/session_features.py @@ -114,7 +114,7 @@ async def elicit_as_task( The client's elicitation response Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation """ client_caps = self._session.client_params.capabilities if self._session.client_params else None require_task_augmented_elicitation(client_caps) @@ -174,7 +174,7 @@ async def create_message_as_task( The sampling result from the client Raises: - McpError: If client doesn't support task-augmented sampling or tools + MCPError: If client doesn't support task-augmented sampling or tools ValueError: If tool_use or tool_result message structure is invalid """ client_caps = self._session.client_params.capabilities if self._session.client_params else None diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py index 32394d0ad0..9b626c9862 100644 --- a/src/mcp/server/experimental/task_context.py +++ b/src/mcp/server/experimental/task_context.py @@ -13,7 +13,7 @@ from mcp.server.experimental.task_result_handler import TaskResultHandler from mcp.server.session import ServerSession from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.capabilities import ( require_task_augmented_elicitation, require_task_augmented_sampling, @@ -32,7 +32,6 @@ ElicitationCapability, ElicitRequestedSchema, ElicitResult, - ErrorData, IncludeContext, ModelPreferences, RequestId, @@ -173,22 +172,12 @@ async def _send_notification(self) -> None: def _check_elicitation_capability(self) -> None: """Check if the client supports elicitation.""" if not self._session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support elicitation capability", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support elicitation capability") def _check_sampling_capability(self) -> None: """Check if the client supports sampling.""" if not self._session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support sampling capability", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support sampling capability") async def elicit( self, @@ -213,7 +202,7 @@ async def elicit( The client's response Raises: - McpError: If client doesn't support elicitation capability + MCPError: If client doesn't support elicitation capability """ self._check_elicitation_capability() @@ -281,7 +270,7 @@ async def elicit_url( The client's response indicating acceptance, decline, or cancellation Raises: - McpError: If client doesn't support elicitation capability + MCPError: If client doesn't support elicitation capability RuntimeError: If handler is not configured """ self._check_elicitation_capability() @@ -361,7 +350,7 @@ async def create_message( The sampling result from the client Raises: - McpError: If client doesn't support sampling capability or tools + MCPError: If client doesn't support sampling capability or tools ValueError: If tool_use or tool_result message structure is invalid """ self._check_sampling_capability() @@ -436,7 +425,7 @@ async def elicit_as_task( The client's elicitation response Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation RuntimeError: If handler is not configured """ client_caps = self._session.client_params.capabilities if self._session.client_params else None @@ -529,7 +518,7 @@ async def create_message_as_task( The sampling result from the client Raises: - McpError: If client doesn't support task-augmented sampling or tools + MCPError: If client doesn't support task-augmented sampling or tools ValueError: If tool_use or tool_result message structure is invalid RuntimeError: If handler is not configured """ diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index 4d763ef0e6..991221bd0b 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -15,7 +15,7 @@ import anyio from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue from mcp.shared.experimental.tasks.resolver import Resolver @@ -106,7 +106,7 @@ async def handle( while True: task = await self._store.get_task(task_id) if task is None: - raise McpError(ErrorData(code=INVALID_PARAMS, message=f"Task not found: {task_id}")) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {task_id}") await self._deliver_queued_messages(task_id, session, request_id) @@ -216,6 +216,6 @@ def route_error(self, request_id: RequestId, error: ErrorData) -> bool: """ resolver = self._pending_requests.pop(request_id, None) if resolver is not None and not resolver.done(): - resolver.set_exception(McpError(error)) + resolver.set_exception(MCPError.from_error_data(error)) return True return False diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py index 49387daad7..9b472c0232 100644 --- a/src/mcp/server/lowlevel/experimental.py +++ b/src/mcp/server/lowlevel/experimental.py @@ -11,7 +11,7 @@ from mcp.server.experimental.task_support import TaskSupport from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import cancel_task from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue @@ -20,7 +20,6 @@ INVALID_PARAMS, CancelTaskRequest, CancelTaskResult, - ErrorData, GetTaskPayloadRequest, GetTaskPayloadResult, GetTaskRequest, @@ -135,12 +134,7 @@ def _register_default_task_handlers(self) -> None: async def _default_get_task(req: GetTaskRequest) -> ServerResult: task = await support.store.get_task(req.params.task_id) if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {req.params.task_id}", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {req.params.task_id}") return GetTaskResult( task_id=task.task_id, status=task.status, diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 9137d4eafe..c484453668 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -101,7 +101,7 @@ async def main(): from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.tool_name_validation import validate_and_warn_tool_name @@ -407,9 +407,7 @@ def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any case _: # pragma: no cover raise ValueError(f"Unexpected return type from read_resource: {type(result)}") - return types.ReadResourceResult( # pragma: no cover - contents=[content], - ) + return types.ReadResourceResult(contents=[content]) # pragma: no cover self.request_handlers[types.ReadResourceRequest] = handler return func @@ -781,13 +779,10 @@ async def _handle_request( ) ) response = await handler(req) - except McpError as err: + except MCPError as err: response = err.error except anyio.get_cancelled_exc_class(): - logger.info( - "Request %s cancelled - duplicate response suppressed", - message.request_id, - ) + logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) return except Exception as err: if raise_exceptions: # pragma: no cover diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 50a441d693..591da3189a 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -314,7 +314,7 @@ async def create_message( The sampling result from the client. Raises: - McpError: If tools are provided but client doesn't support them. + MCPError: If tools are provided but client doesn't support them. ValueError: If tool_use or tool_result message structure is invalid. StatelessModeNotSupported: If called in stateless HTTP mode. """ diff --git a/src/mcp/server/validation.py b/src/mcp/server/validation.py index cfd663d43b..5708628074 100644 --- a/src/mcp/server/validation.py +++ b/src/mcp/server/validation.py @@ -4,15 +4,8 @@ that is shared across normal and task-augmented code paths. """ -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_PARAMS, - ClientCapabilities, - ErrorData, - SamplingMessage, - Tool, - ToolChoice, -) +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, ClientCapabilities, SamplingMessage, Tool, ToolChoice def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool: @@ -46,11 +39,11 @@ def validate_sampling_tools( tool_choice: The tool choice setting, if provided Raises: - McpError: If tools/tool_choice are provided but client doesn't support them + MCPError: If tools/tool_choice are provided but client doesn't support them """ if tools is not None or tool_choice is not None: if not check_sampling_tools_capability(client_caps): - raise McpError(ErrorData(code=INVALID_PARAMS, message="Client does not support sampling tools capability")) + raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability") def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index d8bc17b7ab..5c83b1fd12 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -2,18 +2,40 @@ from typing import Any, cast -from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError -class McpError(Exception): +class MCPError(Exception): """Exception type raised when an error arrives over an MCP connection.""" error: ErrorData - def __init__(self, error: ErrorData): - """Initialize McpError.""" - super().__init__(error.message) - self.error = error + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(code, message, data) + self.error = ErrorData(code=code, message=message, data=data) + + @property + def code(self) -> int: + return self.error.code + + @property + def message(self) -> str: + return self.error.message + + @property + def data(self) -> Any: + return self.error.data + + @classmethod + def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError: + return cls.from_error_data(error.error) + + @classmethod + def from_error_data(cls, error: ErrorData) -> MCPError: + return cls(code=error.code, message=error.message, data=error.data) + + def __str__(self) -> str: + return self.message class StatelessModeNotSupported(RuntimeError): @@ -33,7 +55,7 @@ def __init__(self, method: str): self.method = method -class UrlElicitationRequiredError(McpError): +class UrlElicitationRequiredError(MCPError): """Specialized error for when a tool requires URL mode elicitation(s) before proceeding. Servers can raise this error from tool handlers to indicate that the client @@ -42,7 +64,6 @@ class UrlElicitationRequiredError(McpError): Example: raise UrlElicitationRequiredError([ ElicitRequestURLParams( - mode="url", message="Authorization required for your files", url="https://example.com/oauth/authorize", elicitation_id="auth-001" @@ -50,23 +71,18 @@ class UrlElicitationRequiredError(McpError): ]) """ - def __init__( - self, - elicitations: list[ElicitRequestURLParams], - message: str | None = None, - ): + def __init__(self, elicitations: list[ElicitRequestURLParams], message: str | None = None): """Initialize UrlElicitationRequiredError.""" if message is None: message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required" self._elicitations = elicitations - error = ErrorData( + super().__init__( code=URL_ELICITATION_REQUIRED, message=message, data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]}, ) - super().__init__(error) @property def elicitations(self) -> list[ElicitRequestURLParams]: diff --git a/src/mcp/shared/experimental/tasks/capabilities.py b/src/mcp/shared/experimental/tasks/capabilities.py index ec9e53e854..51fe64ecc3 100644 --- a/src/mcp/shared/experimental/tasks/capabilities.py +++ b/src/mcp/shared/experimental/tasks/capabilities.py @@ -7,13 +7,8 @@ WARNING: These APIs are experimental and may change without notice. """ -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_REQUEST, - ClientCapabilities, - ClientTasksCapability, - ErrorData, -) +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_REQUEST, ClientCapabilities, ClientTasksCapability def check_tasks_capability( @@ -76,36 +71,26 @@ def has_task_augmented_sampling(caps: ClientCapabilities) -> bool: def require_task_augmented_elicitation(client_caps: ClientCapabilities | None) -> None: - """Raise McpError if client doesn't support task-augmented elicitation. + """Raise MCPError if client doesn't support task-augmented elicitation. Args: client_caps: The client's declared capabilities, or None if not initialized Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation """ if client_caps is None or not has_task_augmented_elicitation(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented elicitation", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support task-augmented elicitation") def require_task_augmented_sampling(client_caps: ClientCapabilities | None) -> None: - """Raise McpError if client doesn't support task-augmented sampling. + """Raise MCPError if client doesn't support task-augmented sampling. Args: client_caps: The client's declared capabilities, or None if not initialized Raises: - McpError: If client doesn't support task-augmented sampling + MCPError: If client doesn't support task-augmented sampling """ if client_caps is None or not has_task_augmented_sampling(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented sampling", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support task-augmented sampling") diff --git a/src/mcp/shared/experimental/tasks/helpers.py b/src/mcp/shared/experimental/tasks/helpers.py index 95055be828..38ca802daf 100644 --- a/src/mcp/shared/experimental/tasks/helpers.py +++ b/src/mcp/shared/experimental/tasks/helpers.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from uuid import uuid4 -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.context import TaskContext from mcp.shared.experimental.tasks.store import TaskStore from mcp.types import ( @@ -19,7 +19,6 @@ TASK_STATUS_FAILED, TASK_STATUS_WORKING, CancelTaskResult, - ErrorData, Task, TaskMetadata, TaskStatus, @@ -68,7 +67,7 @@ async def cancel_task( CancelTaskResult with the cancelled task state Raises: - McpError: With INVALID_PARAMS (-32602) if: + MCPError: With INVALID_PARAMS (-32602) if: - Task does not exist - Task is already in a terminal state (completed, failed, cancelled) @@ -79,20 +78,10 @@ async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult: """ task = await store.get_task(task_id) if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {task_id}") if is_terminal(task.status): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Cannot cancel task in terminal state '{task.status}'", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Cannot cancel task in terminal state '{task.status}'") # Update task to cancelled status cancelled_task = await store.update_task(task_id, status=TASK_STATUS_CANCELLED) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index b7d68c15e8..453e36274e 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, TypeAdapter from typing_extensions import Self -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter from mcp.types import ( @@ -237,7 +237,7 @@ async def send_request( ) -> ReceiveResultT: """Sends a request and wait for a response. - Raises an McpError if the response contains an error. If a request read timeout is provided, it will take + Raises an MCPError if the response contains an error. If a request read timeout is provided, it will take precedence over the session read timeout. Do not use this method to emit notifications! Use send_notification() instead. @@ -271,18 +271,12 @@ async def send_request( with anyio.fail_after(timeout): response_or_error = await response_stream_reader.receive() except TimeoutError: - raise McpError( - ErrorData( - code=REQUEST_TIMEOUT, - message=( - f"Timed out while waiting for response to {request.__class__.__name__}. " - f"Waited {timeout} seconds." - ), - ) - ) + class_name = request.__class__.__name__ + message = f"Timed out while waiting for response to {class_name}. Waited {timeout} seconds." + raise MCPError(code=REQUEST_TIMEOUT, message=message) if isinstance(response_or_error, JSONRPCError): - raise McpError(response_or_error.error) + raise MCPError.from_jsonrpc_error(response_or_error) else: return result_type.model_validate(response_or_error.result, by_name=False) diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 277277b9c8..26dfde7a60 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -1654,8 +1654,8 @@ class ElicitRequestURLParams(RequestParams): """The URL that the user should navigate to.""" elicitation_id: str - """ - The ID of the elicitation, which must be unique within the context of the server. + """The ID of the elicitation, which must be unique within the context of the server. + The client MUST treat this ID as an opaque value. """ diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index 86066d80dc..897e2450ca 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -59,15 +59,15 @@ class ErrorData(BaseModel): """The error type that occurred.""" message: str - """ - A short description of the error. The message SHOULD be limited to a concise single - sentence. + """A short description of the error. + + The message SHOULD be limited to a concise single sentence. """ data: Any = None - """ - Additional information about the error. The value of this member is defined by the - sender (e.g. detailed error information, nested errors etc.). + """Additional information about the error. + + The value of this member is defined by the sender (e.g. detailed error information, nested errors, etc.). """ diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 1046d43e3c..b480a0cb15 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -13,7 +13,7 @@ StreamableHttpParameters, ) from mcp.client.stdio import StdioServerParameters -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError @pytest.fixture @@ -225,7 +225,7 @@ async def test_client_session_group_disconnect_from_server(): async def test_client_session_group_connect_to_server_duplicate_tool_raises_error( mock_exit_stack: contextlib.AsyncExitStack, ): - """Test McpError raised when connecting a server with a dup name.""" + """Test MCPError raised when connecting a server with a dup name.""" # --- Setup Pre-existing State --- group = ClientSessionGroup(exit_stack=mock_exit_stack) existing_tool_name = "shared_tool" @@ -251,7 +251,7 @@ async def test_client_session_group_connect_to_server_duplicate_tool_raises_erro mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) # --- Test Execution and Assertion --- - with pytest.raises(McpError) as excinfo: + with pytest.raises(MCPError) as excinfo: with mock.patch.object( group, "_establish_session", @@ -274,7 +274,7 @@ async def test_client_session_group_disconnect_non_existent_server(): """Test disconnecting a server that isn't connected.""" session = mock.Mock(spec=mcp.ClientSession) group = ClientSessionGroup() - with pytest.raises(McpError): + with pytest.raises(MCPError): await group.disconnect_from_server(session) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 9f1e085e9b..f70c24eee7 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -16,7 +16,7 @@ _terminate_process_tree, stdio_client, ) -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse @@ -78,7 +78,7 @@ async def test_stdio_client_bad_path(): async with stdio_client(server_params) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # The session should raise an error when the connection closes - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await session.initialize() # Check that we got a connection closed error diff --git a/tests/experimental/tasks/server/test_server.py b/tests/experimental/tasks/server/test_server.py index 5711e55c92..8005380d28 100644 --- a/tests/experimental/tasks/server/test_server.py +++ b/tests/experimental/tasks/server/test_server.py @@ -11,7 +11,7 @@ from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter from mcp.shared.session import RequestResponder @@ -506,7 +506,7 @@ async def run_server() -> None: assert get_result.status == "working" # Test get_task (default handler - not found path) - with pytest.raises(McpError, match="not found"): + with pytest.raises(MCPError, match="not found"): await client_session.send_request( GetTaskRequest(params=GetTaskRequestParams(task_id="nonexistent-task")), GetTaskResult, diff --git a/tests/experimental/tasks/server/test_server_task_context.py b/tests/experimental/tasks/server/test_server_task_context.py index 0fe563a75c..e23299698c 100644 --- a/tests/experimental/tasks/server/test_server_task_context.py +++ b/tests/experimental/tasks/server/test_server_task_context.py @@ -8,7 +8,7 @@ from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue from mcp.types import ( @@ -164,7 +164,7 @@ async def test_server_task_context_fail_with_notify() -> None: @pytest.mark.anyio async def test_elicit_raises_when_client_lacks_capability() -> None: - """Test that elicit() raises McpError when client doesn't support elicitation.""" + """Test that elicit() raises MCPError when client doesn't support elicitation.""" store = InMemoryTaskStore() mock_session = Mock() mock_session.check_client_capability = Mock(return_value=False) @@ -180,7 +180,7 @@ async def test_elicit_raises_when_client_lacks_capability() -> None: handler=handler, ) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await ctx.elicit(message="Test?", requested_schema={"type": "object"}) assert "elicitation capability" in exc_info.value.error.message @@ -190,7 +190,7 @@ async def test_elicit_raises_when_client_lacks_capability() -> None: @pytest.mark.anyio async def test_create_message_raises_when_client_lacks_capability() -> None: - """Test that create_message() raises McpError when client doesn't support sampling.""" + """Test that create_message() raises MCPError when client doesn't support sampling.""" store = InMemoryTaskStore() mock_session = Mock() mock_session.check_client_capability = Mock(return_value=False) @@ -206,7 +206,7 @@ async def test_create_message_raises_when_client_lacks_capability() -> None: handler=handler, ) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await ctx.create_message(messages=[], max_tokens=100) assert "sampling capability" in exc_info.value.error.message diff --git a/tests/experimental/tasks/server/test_store.py b/tests/experimental/tasks/server/test_store.py index d6f297e6c1..0d431899c8 100644 --- a/tests/experimental/tasks/server/test_store.py +++ b/tests/experimental/tasks/server/test_store.py @@ -5,7 +5,7 @@ import pytest -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import cancel_task from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.types import INVALID_PARAMS, CallToolResult, TaskMetadata, TextContent @@ -347,8 +347,8 @@ async def test_cancel_task_succeeds_for_working_task(store: InMemoryTaskStore) - @pytest.mark.anyio async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for nonexistent task.""" - with pytest.raises(McpError) as exc_info: + """Test cancel_task raises MCPError with INVALID_PARAMS for nonexistent task.""" + with pytest.raises(MCPError) as exc_info: await cancel_task(store, "nonexistent-task-id") assert exc_info.value.error.code == INVALID_PARAMS @@ -357,11 +357,11 @@ async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> @pytest.mark.anyio async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for completed task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for completed task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) await store.update_task(task.task_id, status="completed") - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS @@ -370,11 +370,11 @@ async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> N @pytest.mark.anyio async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for failed task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for failed task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) await store.update_task(task.task_id, status="failed") - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS @@ -383,11 +383,11 @@ async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None @pytest.mark.anyio async def test_cancel_task_rejects_already_cancelled_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for already cancelled task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for already cancelled task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) await store.update_task(task.task_id, status="cancelled") - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS diff --git a/tests/experimental/tasks/server/test_task_result_handler.py b/tests/experimental/tasks/server/test_task_result_handler.py index ed6c296b73..8b5a03ce2b 100644 --- a/tests/experimental/tasks/server/test_task_result_handler.py +++ b/tests/experimental/tasks/server/test_task_result_handler.py @@ -8,7 +8,7 @@ import pytest from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage from mcp.shared.experimental.tasks.resolver import Resolver @@ -71,11 +71,11 @@ async def test_handle_returns_result_for_completed_task( async def test_handle_raises_for_nonexistent_task( store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler ) -> None: - """Test that handle() raises McpError for nonexistent task.""" + """Test that handle() raises MCPError for nonexistent task.""" mock_session = Mock() request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="nonexistent")) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await handler.handle(request, mock_session, "req-1") assert "not found" in exc_info.value.error.message @@ -214,7 +214,7 @@ async def test_route_error_resolves_pending_request_with_exception( assert result is True assert resolver.done() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await resolver.wait() assert exc_info.value.error.message == "Something went wrong" diff --git a/tests/experimental/tasks/test_capabilities.py b/tests/experimental/tasks/test_capabilities.py index 4298ebdebb..90a8656ba0 100644 --- a/tests/experimental/tasks/test_capabilities.py +++ b/tests/experimental/tasks/test_capabilities.py @@ -2,7 +2,7 @@ import pytest -from mcp.shared.exceptions import McpError +from mcp import MCPError from mcp.shared.experimental.tasks.capabilities import ( check_tasks_capability, has_task_augmented_elicitation, @@ -231,15 +231,15 @@ class TestRequireTaskAugmentedElicitation: """Tests for require_task_augmented_elicitation function.""" def test_raises_when_none(self) -> None: - """Raises McpError when client_caps is None.""" - with pytest.raises(McpError) as exc_info: + """Raises MCPError when client_caps is None.""" + with pytest.raises(MCPError) as exc_info: require_task_augmented_elicitation(None) assert "task-augmented elicitation" in str(exc_info.value) def test_raises_when_missing(self) -> None: - """Raises McpError when capability is missing.""" + """Raises MCPError when capability is missing.""" caps = ClientCapabilities() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: require_task_augmented_elicitation(caps) assert "task-augmented elicitation" in str(exc_info.value) @@ -259,15 +259,15 @@ class TestRequireTaskAugmentedSampling: """Tests for require_task_augmented_sampling function.""" def test_raises_when_none(self) -> None: - """Raises McpError when client_caps is None.""" - with pytest.raises(McpError) as exc_info: + """Raises MCPError when client_caps is None.""" + with pytest.raises(MCPError) as exc_info: require_task_augmented_sampling(None) assert "task-augmented sampling" in str(exc_info.value) def test_raises_when_missing(self) -> None: - """Raises McpError when capability is missing.""" + """Raises MCPError when capability is missing.""" caps = ClientCapabilities() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: require_task_augmented_sampling(caps) assert "task-augmented sampling" in str(exc_info.value) diff --git a/tests/experimental/tasks/test_request_context.py b/tests/experimental/tasks/test_request_context.py index 0c342d8340..ad4023389e 100644 --- a/tests/experimental/tasks/test_request_context.py +++ b/tests/experimental/tasks/test_request_context.py @@ -3,7 +3,7 @@ import pytest from mcp.server.experimental.request_context import Experimental -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( METHOD_NOT_FOUND, TASK_FORBIDDEN, @@ -58,7 +58,7 @@ def test_validate_task_mode_required_without_task_returns_error() -> None: def test_validate_task_mode_required_without_task_raises_by_default() -> None: exp = Experimental(task_metadata=None) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: exp.validate_task_mode(TASK_REQUIRED) assert exc_info.value.error.code == METHOD_NOT_FOUND @@ -79,7 +79,7 @@ def test_validate_task_mode_forbidden_with_task_returns_error() -> None: def test_validate_task_mode_forbidden_with_task_raises_by_default() -> None: exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: exp.validate_task_mode(TASK_FORBIDDEN) assert exc_info.value.error.code == METHOD_NOT_FOUND diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py index 4ea2f1a454..cd27698e66 100644 --- a/tests/issues/test_88_random_error.py +++ b/tests/issues/test_88_random_error.py @@ -12,7 +12,7 @@ from mcp import types from mcp.client.session import ClientSession from mcp.server.lowlevel import Server -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage from mcp.types import ContentBlock, TextContent @@ -93,7 +93,7 @@ async def client( # Second call should timeout (slow operation with minimal timeout) # Use very small timeout to trigger quickly without waiting - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails assert "Timed out while waiting" in str(exc_info.value) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 9dfaefebf2..76377c2801 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -16,7 +16,7 @@ from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.session import ServerSession from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( AudioContent, BlobResourceContents, @@ -1469,7 +1469,7 @@ async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = MCPServer() async with Client(mcp) as client: - with pytest.raises(McpError, match="Unknown prompt"): + with pytest.raises(MCPError, match="Unknown prompt"): await client.get_prompt("unknown") @pytest.mark.anyio @@ -1482,7 +1482,7 @@ def prompt_fn(name: str) -> str: # pragma: no cover return f"Hello, {name}!" async with Client(mcp) as client: - with pytest.raises(McpError, match="Missing required arguments"): + with pytest.raises(MCPError, match="Missing required arguments"): await client.get_prompt("prompt_fn") diff --git a/tests/server/mcpserver/test_url_elicitation_error_throw.py b/tests/server/mcpserver/test_url_elicitation_error_throw.py index 36caa11523..2d29937995 100644 --- a/tests/server/mcpserver/test_url_elicitation_error_throw.py +++ b/tests/server/mcpserver/test_url_elicitation_error_throw.py @@ -1,16 +1,17 @@ """Test that UrlElicitationRequiredError is properly propagated as MCP error.""" import pytest +from inline_snapshot import snapshot -from mcp import Client, types +from mcp import Client, ErrorData, types from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError @pytest.mark.anyio async def test_url_elicitation_error_thrown_from_tool(): - """Test that UrlElicitationRequiredError raised from a tool is received as McpError by client.""" + """Test that UrlElicitationRequiredError raised from a tool is received as MCPError by client.""" mcp = MCPServer(name="UrlElicitationErrorServer") @mcp.tool(description="A tool that raises UrlElicitationRequiredError") @@ -28,28 +29,30 @@ async def connect_service(service_name: str, ctx: Context[ServerSession, None]) ) async with Client(mcp) as client: - # Call the tool - it should raise McpError with URL_ELICITATION_REQUIRED code - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await client.call_tool("connect_service", {"service_name": "github"}) - # Verify the error details - error = exc_info.value.error - assert error.code == types.URL_ELICITATION_REQUIRED - assert error.message == "URL elicitation required" - - # Verify the error data contains elicitations - assert error.data is not None - assert "elicitations" in error.data - elicitations = error.data["elicitations"] - assert len(elicitations) == 1 - assert elicitations[0]["mode"] == "url" - assert elicitations[0]["url"] == "https://github.example.com/oauth/authorize" - assert elicitations[0]["elicitationId"] == "github-auth-001" + assert exc_info.value.error == snapshot( + ErrorData( + code=types.URL_ELICITATION_REQUIRED, + message="URL elicitation required", + data={ + "elicitations": [ + { + "mode": "url", + "message": "Authorization required to connect to github", + "url": "https://github.example.com/oauth/authorize", + "elicitationId": "github-auth-001", + } + ] + }, + ) + ) @pytest.mark.anyio async def test_url_elicitation_error_from_error(): - """Test that client can reconstruct UrlElicitationRequiredError from McpError.""" + """Test that client can reconstruct UrlElicitationRequiredError from MCPError.""" mcp = MCPServer(name="UrlElicitationErrorServer") @mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations") @@ -73,12 +76,12 @@ async def multi_auth(ctx: Context[ServerSession, None]) -> str: async with Client(mcp) as client: # Call the tool and catch the error - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await client.call_tool("multi_auth", {}) # Reconstruct the typed error mcp_error = exc_info.value - assert mcp_error.error.code == types.URL_ELICITATION_REQUIRED + assert mcp_error.code == types.URL_ELICITATION_REQUIRED url_error = UrlElicitationRequiredError.from_error(mcp_error.error) @@ -98,7 +101,7 @@ async def failing_tool(ctx: Context[ServerSession, None]) -> str: raise ValueError("Something went wrong") async with Client(mcp) as client: - # Normal exceptions should be returned as error results, not McpError + # Normal exceptions should be returned as error results, not MCPError result = await client.call_tool("failing_tool", {}) assert result.is_error is True assert len(result.content) == 1 diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 98f34df465..8775af7857 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -8,7 +8,7 @@ import mcp.types as types from mcp import Client from mcp.server.lowlevel.server import Server -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( CallToolRequest, CallToolRequestParams, @@ -61,7 +61,7 @@ async def first_request(): CallToolResult, ) pytest.fail("First request should have been cancelled") # pragma: no cover - except McpError: + except MCPError: pass # Expected # Start first request diff --git a/tests/server/test_session.py b/tests/server/test_session.py index d4dbdca304..db47e78df2 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -9,7 +9,7 @@ from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( @@ -397,7 +397,7 @@ async def test_create_message_tool_result_validation(): @pytest.mark.anyio async def test_create_message_without_tools_capability(): - """Test that create_message raises McpError when tools are provided without capability.""" + """Test that create_message raises MCPError when tools are provided without capability.""" server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) @@ -426,8 +426,8 @@ async def test_create_message_without_tools_capability(): tool = types.Tool(name="test_tool", input_schema={"type": "object"}) text = types.TextContent(type="text", text="hello") - # Should raise McpError when tools are provided but client lacks capability - with pytest.raises(McpError) as exc_info: + # Should raise MCPError when tools are provided but client lacks capability + with pytest.raises(MCPError) as exc_info: await session.create_message( messages=[types.SamplingMessage(role="user", content=text)], max_tokens=100, @@ -435,8 +435,8 @@ async def test_create_message_without_tools_capability(): ) assert "does not support sampling tools capability" in exc_info.value.error.message - # Should also raise McpError when tool_choice is provided - with pytest.raises(McpError) as exc_info: + # Should also raise MCPError when tool_choice is provided + with pytest.raises(MCPError) as exc_info: await session.create_message( messages=[types.SamplingMessage(role="user", content=text)], max_tokens=100, diff --git a/tests/server/test_validation.py b/tests/server/test_validation.py index 4583e470c4..ad97dd3fd6 100644 --- a/tests/server/test_validation.py +++ b/tests/server/test_validation.py @@ -7,7 +7,7 @@ validate_sampling_tools, validate_tool_use_result_messages, ) -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( ClientCapabilities, SamplingCapability, @@ -55,16 +55,16 @@ def test_validate_sampling_tools_no_error_when_tools_none() -> None: def test_validate_sampling_tools_raises_when_tools_provided_but_no_capability() -> None: - """Raises McpError when tools provided but client doesn't support.""" + """Raises MCPError when tools provided but client doesn't support.""" tool = Tool(name="test", input_schema={"type": "object"}) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: validate_sampling_tools(None, [tool], None) assert "sampling tools capability" in str(exc_info.value) def test_validate_sampling_tools_raises_when_tool_choice_provided_but_no_capability() -> None: - """Raises McpError when tool_choice provided but client doesn't support.""" - with pytest.raises(McpError) as exc_info: + """Raises MCPError when tool_choice provided but client doesn't support.""" + with pytest.raises(MCPError) as exc_info: validate_sampling_tools(None, None, ToolChoice(mode="auto")) assert "sampling tools capability" in str(exc_info.value) diff --git a/tests/shared/test_exceptions.py b/tests/shared/test_exceptions.py index 70d14c9cdc..9a7466264d 100644 --- a/tests/shared/test_exceptions.py +++ b/tests/shared/test_exceptions.py @@ -2,7 +2,7 @@ import pytest -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData @@ -137,7 +137,7 @@ def test_url_elicitation_required_error_data_contains_elicitations() -> None: def test_url_elicitation_required_error_inherits_from_mcp_error() -> None: - """Test that UrlElicitationRequiredError inherits from McpError.""" + """Test that UrlElicitationRequiredError inherits from MCPError.""" elicitation = ElicitRequestURLParams( mode="url", message="Auth required", @@ -146,7 +146,7 @@ def test_url_elicitation_required_error_inherits_from_mcp_error() -> None: ) error = UrlElicitationRequiredError([elicitation]) - assert isinstance(error, McpError) + assert isinstance(error, MCPError) assert isinstance(error, Exception) diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index fa903f8ff4..a2c1797de4 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -7,7 +7,7 @@ from mcp import Client from mcp.client.session import ClientSession from mcp.server.lowlevel.server import Server -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage from mcp.types import ( @@ -77,7 +77,7 @@ async def make_request(client: Client): types.CallToolResult, ) pytest.fail("Request should have been cancelled") # pragma: no cover - except McpError as e: + except MCPError as e: # Expected - request was cancelled assert "Request cancelled" in str(e) ev_cancelled.set() @@ -164,7 +164,7 @@ async def test_error_response_id_type_mismatch_string_to_int(): but the client sent "id": 0 (integer). """ ev_error_received = anyio.Event() - error_holder: list[McpError] = [] + error_holder: list[MCPError | Exception] = [] async with create_client_server_memory_streams() as (client_streams, server_streams): client_read, client_write = client_streams @@ -191,8 +191,8 @@ async def make_request(client_session: ClientSession): nonlocal error_holder try: await client_session.send_ping() - pytest.fail("Expected McpError to be raised") # pragma: no cover - except McpError as e: + pytest.fail("Expected MCPError to be raised") # pragma: no cover + except MCPError as e: error_holder.append(e) ev_error_received.set() @@ -246,7 +246,7 @@ async def make_request(client_session: ClientSession): request_read_timeout_seconds=0.5, ) pytest.fail("Expected timeout") # pragma: no cover - except McpError as e: + except MCPError as e: assert "Timed out" in str(e) ev_timeout.set() @@ -279,7 +279,7 @@ async def make_request(client_session: ClientSession): # any request will do await client_session.initialize() pytest.fail("Request should have errored") # pragma: no cover - except McpError as e: + except MCPError as e: # Expected - request errored assert "Connection closed" in str(e) ev_response.set() diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index fb006424c6..70b3248155 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -25,10 +25,9 @@ from mcp.server import Server from mcp.server.sse import SseServerTransport from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( EmptyResult, - ErrorData, Implementation, InitializeResult, JSONRPCResponse, @@ -70,7 +69,7 @@ async def handle_read_resource(uri: str) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {parsed.netloc}" - raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) + raise MCPError(code=404, message="OOPS! no resource with that URI was found") @self.list_tools() async def handle_list_tools() -> list[Tool]: @@ -266,7 +265,7 @@ async def test_sse_client_exception_handling( initialized_sse_client_session: ClientSession, ) -> None: session = initialized_sse_client_session - with pytest.raises(McpError, match="OOPS! no resource with that URI was found"): + with pytest.raises(MCPError, match="OOPS! no resource with that URI was found"): await session.read_resource(uri="xxx://will-not-work") @@ -282,7 +281,7 @@ async def test_sse_client_timeout( # pragma: no cover assert isinstance(response, ReadResourceResult) with anyio.move_on_after(3): - with pytest.raises(McpError, match="Read timed out"): + with pytest.raises(MCPError, match="Read timed out"): response = await session.read_resource(uri="slow://2") # we should receive an error here return diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index b1332772a3..cd02cacdba 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -26,6 +26,7 @@ from starlette.routing import Mount import mcp.types as types +from mcp import MCPError from mcp.client.session import ClientSession from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client from mcp.server import Server @@ -44,16 +45,9 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder -from mcp.types import ( - InitializeResult, - JSONRPCRequest, - TextContent, - TextResourceContents, - Tool, -) +from mcp.types import InitializeResult, JSONRPCRequest, TextContent, TextResourceContents, Tool from tests.test_helpers import wait_for_server # Test constants @@ -987,11 +981,7 @@ async def initialized_client_session(basic_server: None, basic_server_url: str): @pytest.mark.anyio async def test_streamable_http_client_basic_connection(basic_server: None, basic_server_url: str): """Test basic client connection with initialization.""" - async with streamable_http_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _): async with ClientSession( read_stream, write_stream, @@ -1030,7 +1020,7 @@ async def test_streamable_http_client_tool_invocation(initialized_client_session @pytest.mark.anyio async def test_streamable_http_client_error_handling(initialized_client_session: ClientSession): """Test error handling in client.""" - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await initialized_client_session.read_resource(uri="unknown://test-error") assert exc_info.value.error.code == 0 assert "Unknown resource: unknown://test-error" in exc_info.value.error.message @@ -1067,15 +1057,8 @@ async def test_streamable_http_client_session_persistence(basic_server: None, ba @pytest.mark.anyio async def test_streamable_http_client_json_response(json_response_server: None, json_server_url: str): """Test client with JSON response mode.""" - async with streamable_http_client(f"{json_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - ) as session: + async with streamable_http_client(f"{json_server_url}/mcp") as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: # Initialize the session result = await session.initialize() assert isinstance(result, InitializeResult) @@ -1104,11 +1087,7 @@ async def message_handler( # pragma: no branch if isinstance(message, types.ServerNotification): # pragma: no branch notifications_received.append(message) - async with streamable_http_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: # Initialize the session - this triggers the GET stream setup result = await session.initialize() @@ -1137,11 +1116,7 @@ async def test_streamable_http_client_session_termination(basic_server: None, ba captured_session_id = None # Create the streamable_http_client with a custom httpx client to capture headers - async with streamable_http_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - get_session_id, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, get_session_id): async with ClientSession(read_stream, write_stream) as session: # Initialize the session result = await session.initialize() @@ -1165,7 +1140,7 @@ async def test_streamable_http_client_session_termination(basic_server: None, ba ): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch # Attempt to make a request after termination - with pytest.raises(McpError, match="Session terminated"): # pragma: no branch + with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch await session.list_tools() @@ -1201,11 +1176,7 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt captured_session_id = None # Create the streamable_http_client with a custom httpx client to capture headers - async with streamable_http_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - get_session_id, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, get_session_id): async with ClientSession(read_stream, write_stream) as session: # Initialize the session result = await session.initialize() @@ -1229,10 +1200,7 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt ): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch # Attempt to make a request after termination - with pytest.raises( # pragma: no branch - McpError, - match="Session terminated", - ): + with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch await session.list_tools() diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index 501fe049b7..07e19195d5 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -12,20 +12,12 @@ from starlette.routing import WebSocketRoute from starlette.websockets import WebSocket +from mcp import MCPError from mcp.client.session import ClientSession from mcp.client.websocket import websocket_client from mcp.server import Server from mcp.server.websocket import websocket_server -from mcp.shared.exceptions import McpError -from mcp.types import ( - EmptyResult, - ErrorData, - InitializeResult, - ReadResourceResult, - TextContent, - TextResourceContents, - Tool, -) +from mcp.types import EmptyResult, InitializeResult, ReadResourceResult, TextContent, TextResourceContents, Tool from tests.test_helpers import wait_for_server SERVER_NAME = "test_server_for_WS" @@ -58,7 +50,7 @@ async def handle_read_resource(uri: str) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {parsed.netloc}" - raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) + raise MCPError(code=404, message="OOPS! no resource with that URI was found") @self.list_tools() async def handle_list_tools() -> list[Tool]: @@ -84,12 +76,7 @@ async def handle_ws(websocket: WebSocket): async with websocket_server(websocket.scope, websocket.receive, websocket.send) as streams: await server.run(streams[0], streams[1], server.create_initialization_options()) - app = Starlette( - routes=[ - WebSocketRoute("/ws", endpoint=handle_ws), - ] - ) - + app = Starlette(routes=[WebSocketRoute("/ws", endpoint=handle_ws)]) return app @@ -176,7 +163,7 @@ async def test_ws_client_exception_handling( initialized_ws_client_session: ClientSession, ) -> None: """Test exception handling in WebSocket communication""" - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await initialized_ws_client_session.read_resource("unknown://example") assert exc_info.value.error.code == 404 From 1e3e62f2298fdab31de0ea7caba1d509cb9b9180 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Jan 2026 17:47:40 +0100 Subject: [PATCH 2/4] refactor: McpError renamed and flatten parameters --- src/mcp/client/session_group.py | 5 ++++- src/mcp/shared/exceptions.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 041a89b32f..9b0f80a446 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -382,7 +382,10 @@ async def _aggregate_components(self, server_info: types.Implementation, session # Check for duplicates. matching_prompts = prompts_temp.keys() & self._prompts.keys() if matching_prompts: - raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_prompts} already exist in group prompts.") + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_prompts} already exist in group prompts.", + ) matching_resources = resources_temp.keys() & self._resources.keys() if matching_resources: raise MCPError( # pragma: no cover diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 5c83b1fd12..7a2b2ded4d 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -24,7 +24,7 @@ def message(self) -> str: @property def data(self) -> Any: - return self.error.data + return self.error.data # pragma: no cover @classmethod def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError: From 3c6b258b0734fed757e33e69390a7419b2791da1 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 26 Jan 2026 14:07:27 +0100 Subject: [PATCH 3/4] Apply suggestion from @Kludex --- docs/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index 822a281e18..6380f5487d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -133,7 +133,7 @@ from mcp.shared.exceptions import McpError try: result = await session.call_tool("my_tool") except McpError as e: - print(f"Error: {e.message}") + print(f"Error: {e.error.message}") ``` **After (v2):** From d48a6778edc48938ad047aab394447c6dafce6ba Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 26 Jan 2026 14:30:06 +0100 Subject: [PATCH 4/4] Update src/mcp/server/experimental/request_context.py Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- src/mcp/server/experimental/request_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 09c217e981..b5f35749c8 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -91,7 +91,7 @@ def validate_task_mode( error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation") if error is not None and raise_error: - raise MCPError(code=METHOD_NOT_FOUND, message=error.message) + raise MCPError(code=error.code, message=error.message) return error