From 5e57bea807cb534fb8844be0014d566332b107ac Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Jan 2026 17:43:51 +0100 Subject: [PATCH 1/5] 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/5] 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 bb6cfa97f33e64a47ebe2ab33b24f8ef08d00cd7 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Jan 2026 19:36:34 +0100 Subject: [PATCH 3/5] Add tests for MCPServer.read_resource exception handling Cover the two exception paths in read_resource that were previously excluded from coverage: unknown resource errors and resource read failures. The tests verify that both cases properly raise MCPError with appropriate messages. Also improve exception chaining by using 'from exc' syntax and simplify the error message format. --- src/mcp/server/lowlevel/server.py | 7 +- src/mcp/server/mcpserver/server.py | 7 +- tests/server/mcpserver/test_server.py | 386 ++++++++++---------------- 3 files changed, 155 insertions(+), 245 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c484453668..5481372e17 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -795,12 +795,7 @@ async def _handle_request( await message.respond(response) else: # pragma: no cover - await message.respond( - types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) - ) + await message.respond(types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")) logger.debug("Response sent") diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 600f392458..2bcbca3607 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -348,15 +348,14 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent context = self.get_context() resource = await self._resource_manager.get_resource(uri, context=context) - if not resource: # pragma: no cover + if not resource: raise ResourceError(f"Unknown resource: {uri}") try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] - except Exception as e: # pragma: no cover - logger.exception(f"Error reading resource {uri}") - raise ResourceError(str(e)) + except Exception as exc: + raise ResourceError(f"Error reading resource {uri}") from exc def add_tool( self, diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 76377c2801..9a9a2626f3 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from inline_snapshot import snapshot from pydantic import BaseModel from starlette.applications import Starlette from starlette.routing import Mount, Route @@ -22,15 +23,24 @@ BlobResourceContents, ContentBlock, EmbeddedResource, + GetPromptResult, Icon, ImageContent, + ListPromptsResult, + Prompt, + PromptArgument, + PromptMessage, + ReadResourceResult, + Resource, + ResourceTemplate, TextContent, TextResourceContents, ) +pytestmark = pytest.mark.anyio + class TestServer: - @pytest.mark.anyio async def test_create_server(self): mcp = MCPServer( title="MCPServer Server", @@ -50,7 +60,6 @@ async def test_create_server(self): assert len(mcp.icons) == 1 assert mcp.icons[0].src == "https://example.com/icon.png" - @pytest.mark.anyio async def test_sse_app_returns_starlette_app(self): """Test that sse_app returns a Starlette application with correct routes.""" mcp = MCPServer("test") @@ -68,7 +77,6 @@ async def test_sse_app_returns_starlette_app(self): assert sse_routes[0].path == "/sse" assert mount_routes[0].path == "/messages" - @pytest.mark.anyio async def test_non_ascii_description(self): """Test that MCPServer handles non-ASCII characters in descriptions correctly""" mcp = MCPServer() @@ -92,7 +100,6 @@ def hello_world(name: str = "世界") -> str: assert isinstance(content, TextContent) assert "¡Hola, 世界! 👋" == content.text - @pytest.mark.anyio async def test_add_tool_decorator(self): mcp = MCPServer() @@ -102,7 +109,6 @@ def sum(x: int, y: int) -> int: # pragma: no cover assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_add_tool_decorator_incorrect_usage(self): mcp = MCPServer() @@ -112,7 +118,6 @@ async def test_add_tool_decorator_incorrect_usage(self): def sum(x: int, y: int) -> int: # pragma: no cover return x + y - @pytest.mark.anyio async def test_add_resource_decorator(self): mcp = MCPServer() @@ -122,7 +127,6 @@ def get_data(x: str) -> str: # pragma: no cover assert len(mcp._resource_manager._templates) == 1 - @pytest.mark.anyio async def test_add_resource_decorator_incorrect_usage(self): mcp = MCPServer() @@ -219,14 +223,12 @@ def mixed_content_tool_fn() -> list[ContentBlock]: class TestServerTools: - @pytest.mark.anyio async def test_add_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) mcp.add_tool(tool_fn) assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_list_tools(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -234,7 +236,6 @@ async def test_list_tools(self): tools = await client.list_tools() assert len(tools.tools) == 1 - @pytest.mark.anyio async def test_call_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -243,7 +244,6 @@ async def test_call_tool(self): assert not hasattr(result, "error") assert len(result.content) > 0 - @pytest.mark.anyio async def test_tool_exception_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) @@ -255,7 +255,6 @@ async def test_tool_exception_handling(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_handling(self): mcp = MCPServer() mcp.add_tool(error_tool_fn) @@ -267,7 +266,6 @@ async def test_tool_error_handling(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" mcp = MCPServer() @@ -280,7 +278,6 @@ async def test_tool_error_details(self): assert "Test error" in content.text assert result.is_error is True - @pytest.mark.anyio async def test_tool_return_value_conversion(self): mcp = MCPServer() mcp.add_tool(tool_fn) @@ -294,7 +291,6 @@ async def test_tool_return_value_conversion(self): assert result.structured_content is not None assert result.structured_content == {"result": 3} - @pytest.mark.anyio async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" @@ -315,7 +311,6 @@ async def test_tool_image_helper(self, tmp_path: Path): # Check structured content - Image return type should NOT have structured output assert result.structured_content is None - @pytest.mark.anyio async def test_tool_audio_helper(self, tmp_path: Path): # Create a test audio audio_path = tmp_path / "test.wav" @@ -348,7 +343,6 @@ async def test_tool_audio_helper(self, tmp_path: Path): ("test.unknown", "application/octet-stream"), # Unknown extension fallback ], ) - @pytest.mark.anyio async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, expected_mime_type: str): """Test that Audio helper correctly detects MIME types from file suffixes""" mcp = MCPServer() @@ -369,7 +363,6 @@ async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, decoded = base64.b64decode(content.data) assert decoded == b"fake audio data" - @pytest.mark.anyio async def test_tool_mixed_content(self): mcp = MCPServer() mcp.add_tool(mixed_content_tool_fn) @@ -400,7 +393,6 @@ async def test_tool_mixed_content(self): for key, value in expected.items(): assert structured_result[i][key] == value - @pytest.mark.anyio async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled correctly""" @@ -453,7 +445,6 @@ def mixed_list_fn() -> list: # type: ignore # Check structured content - untyped list with Image objects should NOT have structured output assert result.structured_content is None - @pytest.mark.anyio async def test_tool_structured_output_basemodel(self): """Test tool with structured output returning BaseModel""" @@ -488,7 +479,6 @@ def get_user(user_id: int) -> UserOutput: assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text - @pytest.mark.anyio async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" @@ -515,7 +505,6 @@ def calculate_sum(a: int, b: int) -> int: assert result.structured_content is not None assert result.structured_content == {"result": 12} - @pytest.mark.anyio async def test_tool_structured_output_list(self): """Test tool with structured output returning list""" @@ -532,7 +521,6 @@ def get_numbers() -> list[int]: assert result.structured_content is not None assert result.structured_content == {"result": [1, 2, 3, 4, 5]} - @pytest.mark.anyio async def test_tool_structured_output_server_side_validation_error(self): """Test that server-side validation errors are handled properly""" @@ -549,7 +537,6 @@ def get_numbers() -> list[int]: assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) - @pytest.mark.anyio async def test_tool_structured_output_dict_str_any(self): """Test tool with dict[str, Any] structured output""" @@ -591,7 +578,6 @@ def get_metadata() -> dict[str, Any]: } assert result.structured_content == expected - @pytest.mark.anyio async def test_tool_structured_output_dict_str_typed(self): """Test tool with dict[str, T] structured output for specific T""" @@ -615,7 +601,6 @@ def get_settings() -> dict[str, str]: assert result.is_error is False assert result.structured_content == {"theme": "dark", "language": "en", "timezone": "UTC"} - @pytest.mark.anyio async def test_remove_tool(self): """Test removing a tool from the server.""" mcp = MCPServer() @@ -630,7 +615,6 @@ async def test_remove_tool(self): # Verify tool is removed assert len(mcp._tool_manager.list_tools()) == 0 - @pytest.mark.anyio async def test_remove_nonexistent_tool(self): """Test that removing a non-existent tool raises ToolError.""" mcp = MCPServer() @@ -638,7 +622,6 @@ async def test_remove_nonexistent_tool(self): with pytest.raises(ToolError, match="Unknown tool: nonexistent"): mcp.remove_tool("nonexistent") - @pytest.mark.anyio async def test_remove_tool_and_list(self): """Test that a removed tool doesn't appear in list_tools.""" mcp = MCPServer() @@ -662,7 +645,6 @@ async def test_remove_tool_and_list(self): assert len(tools.tools) == 1 assert tools.tools[0].name == "error_tool_fn" - @pytest.mark.anyio async def test_remove_tool_and_call(self): """Test that calling a removed tool fails appropriately.""" mcp = MCPServer() @@ -689,7 +671,6 @@ async def test_remove_tool_and_call(self): class TestServerResources: - @pytest.mark.anyio async def test_text_resource(self): mcp = MCPServer() @@ -699,16 +680,32 @@ def get_text(): resource = FunctionResource(uri="resource://test", name="test", fn=get_text) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("resource://test") - async with Client(mcp) as client: result = await client.read_resource("resource://test") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" - @pytest.mark.anyio + async def test_read_unknown_resource(self): + """Test that reading an unknown resource raises MCPError.""" + mcp = MCPServer() + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown resource: unknown://missing"): + await client.read_resource("unknown://missing") + + async def test_read_resource_error(self): + """Test that resource read errors are properly wrapped in MCPError.""" + mcp = MCPServer() + + @mcp.resource("resource://failing") + def failing_resource(): + raise ValueError("Resource read failed") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error reading resource resource://failing"): + await client.read_resource("resource://failing") + async def test_binary_resource(self): mcp = MCPServer() @@ -723,16 +720,12 @@ def get_binary(): ) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("resource://binary") - async with Client(mcp) as client: result = await client.read_resource("resource://binary") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() - @pytest.mark.anyio async def test_file_resource_text(self, tmp_path: Path): mcp = MCPServer() @@ -743,16 +736,12 @@ async def test_file_resource_text(self, tmp_path: Path): resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("file://test.txt") - async with Client(mcp) as client: result = await client.read_resource("file://test.txt") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" - @pytest.mark.anyio async def test_file_resource_binary(self, tmp_path: Path): mcp = MCPServer() @@ -768,16 +757,12 @@ async def test_file_resource_binary(self, tmp_path: Path): ) mcp.add_resource(resource) - async with Client(mcp) as client: - result = await client.read_resource("file://test.bin") - async with Client(mcp) as client: result = await client.read_resource("file://test.bin") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode() - @pytest.mark.anyio async def test_function_resource(self): mcp = MCPServer() @@ -797,7 +782,6 @@ def get_data() -> str: # pragma: no cover class TestServerResourceTemplates: - @pytest.mark.anyio async def test_resource_with_params(self): """Test that a resource with function parameters raises an error if the URI parameters don't match""" @@ -809,7 +793,6 @@ async def test_resource_with_params(self): def get_data_fn(param: str) -> str: # pragma: no cover return f"Data: {param}" - @pytest.mark.anyio async def test_resource_with_uri_params(self): """Test that a resource with URI parameters is automatically a template""" mcp = MCPServer() @@ -820,7 +803,6 @@ async def test_resource_with_uri_params(self): def get_data() -> str: # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_with_untyped_params(self): """Test that a resource with untyped parameters raises an error""" mcp = MCPServer() @@ -829,7 +811,6 @@ async def test_resource_with_untyped_params(self): def get_data(param) -> str: # type: ignore # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_matching_params(self): """Test that a resource with matching URI and function parameters works""" mcp = MCPServer() @@ -838,16 +819,12 @@ async def test_resource_matching_params(self): def get_data(name: str) -> str: return f"Data for {name}" - async with Client(mcp) as client: - result = await client.read_resource("resource://test/data") - async with Client(mcp) as client: result = await client.read_resource("resource://test/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" - @pytest.mark.anyio async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() @@ -858,7 +835,6 @@ async def test_resource_mismatched_params(self): def get_data(user: str) -> str: # pragma: no cover return f"Data for {user}" - @pytest.mark.anyio async def test_resource_multiple_params(self): """Test that multiple parameters work correctly""" mcp = MCPServer() @@ -867,16 +843,12 @@ async def test_resource_multiple_params(self): def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" - async with Client(mcp) as client: - result = await client.read_resource("resource://cursor/myrepo/data") - async with Client(mcp) as client: result = await client.read_resource("resource://cursor/myrepo/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/myrepo" - @pytest.mark.anyio async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = MCPServer() @@ -894,16 +866,12 @@ def get_data_mismatched(org: str, repo_2: str) -> str: # pragma: no cover def get_static_data() -> str: return "Static data" - async with Client(mcp) as client: - result = await client.read_resource("resource://static") - async with Client(mcp) as client: result = await client.read_resource("resource://static") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" - @pytest.mark.anyio async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" mcp = MCPServer() @@ -922,7 +890,6 @@ def get_data(name: str) -> str: result = await resource.read() assert result == "Data for test" - @pytest.mark.anyio async def test_resource_template_includes_mime_type(self): """Test that list resource templates includes the correct mimeType.""" mcp = MCPServer() @@ -932,20 +899,21 @@ def get_csv(user: str) -> str: return f"csv for {user}" templates = await mcp.list_resource_templates() - assert len(templates) == 1 - template = templates[0] - - assert hasattr(template, "mime_type") - assert template.mime_type == "text/csv" - - async with Client(mcp) as client: - result = await client.read_resource("resource://bob/csv") + assert templates == snapshot( + [ + ResourceTemplate( + name="get_csv", uri_template="resource://{user}/csv", description="", mime_type="text/csv" + ) + ] + ) async with Client(mcp) as client: result = await client.read_resource("resource://bob/csv") - - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "csv for bob" + assert result == snapshot( + ReadResourceResult( + contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] + ) + ) class TestServerResourceMetadata: @@ -955,74 +923,76 @@ class TestServerResourceMetadata: Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type). """ - @pytest.mark.anyio async def test_resource_decorator_with_metadata(self): """Test that @resource decorator accepts and passes meta parameter.""" # Tests static resource flow: decorator -> FunctionResource -> list_resources (server.py:544,635,361) mcp = MCPServer() - metadata = {"ui": {"component": "file-viewer"}, "priority": "high"} - - @mcp.resource("resource://config", meta=metadata) - def get_config() -> str: # pragma: no cover - return '{"debug": false}' + @mcp.resource("resource://config", meta={"ui": {"component": "file-viewer"}, "priority": "high"}) + def get_config() -> str: ... resources = await mcp.list_resources() - assert len(resources) == 1 - assert resources[0].meta is not None - assert resources[0].meta == metadata - assert resources[0].meta["ui"]["component"] == "file-viewer" - assert resources[0].meta["priority"] == "high" + assert resources == snapshot( + [ + Resource( + name="get_config", + uri="resource://config", + description="", + mime_type="text/plain", + meta={"ui": {"component": "file-viewer"}, "priority": "high"}, # type: ignore[reportCallIssue] + ) + ] + ) - @pytest.mark.anyio async def test_resource_template_decorator_with_metadata(self): """Test that @resource decorator passes meta to templates.""" # Tests template resource flow: decorator -> add_template() -> list_resource_templates (server.py:544,622,377) mcp = MCPServer() - metadata = {"api_version": "v2", "deprecated": False} - - @mcp.resource("resource://{city}/weather", meta=metadata) - def get_weather(city: str) -> str: # pragma: no cover - return f"Weather for {city}" + @mcp.resource("resource://{city}/weather", meta={"api_version": "v2", "deprecated": False}) + def get_weather(city: str) -> str: ... templates = await mcp.list_resource_templates() - assert len(templates) == 1 - assert templates[0].meta is not None - assert templates[0].meta == metadata - assert templates[0].meta["api_version"] == "v2" + assert templates == snapshot( + [ + ResourceTemplate( + name="get_weather", + uri_template="resource://{city}/weather", + description="", + mime_type="text/plain", + meta={"api_version": "v2", "deprecated": False}, # type: ignore[reportCallIssue] + ) + ] + ) - @pytest.mark.anyio async def test_read_resource_returns_meta(self): """Test that read_resource includes meta in response.""" # Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371) mcp = MCPServer() - metadata = {"version": "1.0", "category": "config"} - - @mcp.resource("resource://data", meta=metadata) + @mcp.resource("resource://data", meta={"version": "1.0", "category": "config"}) def get_data() -> str: return "test data" async with Client(mcp) as client: result = await client.read_resource("resource://data") - - async with Client(mcp) as client: - result = await client.read_resource("resource://data") - - # Verify content and metadata in protocol response - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "test data" - assert result.contents[0].meta is not None - assert result.contents[0].meta == metadata - assert result.contents[0].meta["version"] == "1.0" - assert result.contents[0].meta["category"] == "config" + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://data", + mime_type="text/plain", + meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType] + text="test data", + ) + ] + ) + ) class TestContextInjection: """Test context injection in tools, resources, and prompts.""" - @pytest.mark.anyio async def test_context_detection(self): """Test that context parameters are properly detected.""" mcp = MCPServer() @@ -1033,7 +1003,6 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: # prag tool = mcp._tool_manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" - @pytest.mark.anyio async def test_context_injection(self): """Test that context is properly injected into tool calls.""" mcp = MCPServer() @@ -1051,7 +1020,6 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: assert "Request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_async_context(self): """Test that context works in async functions.""" mcp = MCPServer() @@ -1069,7 +1037,6 @@ async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str: assert "Async request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_context_logging(self): """Test that context logging methods work.""" mcp = MCPServer() @@ -1092,32 +1059,11 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: assert "Logged messages for test" in content.text assert mock_log.call_count == 4 - mock_log.assert_any_call( - level="debug", - data="Debug message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="info", - data="Info message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="warning", - data="Warning message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="error", - data="Error message", - logger=None, - related_request_id="1", - ) + mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1") - @pytest.mark.anyio async def test_optional_context(self): """Test that context is optional.""" mcp = MCPServer() @@ -1133,7 +1079,6 @@ def no_context(x: int) -> int: assert isinstance(content, TextContent) assert content.text == "42" - @pytest.mark.anyio async def test_context_resource_access(self): """Test that context can access resources.""" mcp = MCPServer() @@ -1157,7 +1102,6 @@ async def tool_with_resource(ctx: Context[ServerSession, None]) -> str: assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text - @pytest.mark.anyio async def test_resource_with_context(self): """Test that resources can receive context parameter.""" mcp = MCPServer() @@ -1175,11 +1119,6 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: assert hasattr(template, "context_kwarg") assert template.context_kwarg == "ctx" - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://context/test") - async with Client(mcp) as client: result = await client.read_resource("resource://context/test") @@ -1189,7 +1128,6 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: # Should have either request_id or indication that context was injected assert "Resource test - context injected" == content.text - @pytest.mark.anyio async def test_resource_without_context(self): """Test that resources without context work normally.""" mcp = MCPServer() @@ -1205,20 +1143,18 @@ def resource_no_context(name: str) -> str: template = templates[0] assert template.context_kwarg is None - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://nocontext/test") - async with Client(mcp) as client: result = await client.read_resource("resource://nocontext/test") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works" + ) + ] + ) + ) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert content.text == "Resource test works" - - @pytest.mark.anyio async def test_resource_context_custom_name(self): """Test resource context with custom parameter name.""" mcp = MCPServer() @@ -1235,20 +1171,18 @@ def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: template = templates[0] assert template.context_kwarg == "my_ctx" - # Test via client - - async with Client(mcp) as client: - result = await client.read_resource("resource://custom/123") - async with Client(mcp) as client: result = await client.read_resource("resource://custom/123") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context" + ) + ] + ) + ) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert "Resource 123 with context" in content.text - - @pytest.mark.anyio async def test_prompt_with_context(self): """Test that prompts can receive context parameter.""" mcp = MCPServer() @@ -1259,10 +1193,6 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert ctx is not None return f"Prompt '{text}' - context injected" - # Check if prompt has context parameter detection - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - # Test via client async with Client(mcp) as client: # Try calling without passing ctx explicitly @@ -1273,7 +1203,6 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert isinstance(content, TextContent) assert "Prompt 'test' - context injected" in content.text - @pytest.mark.anyio async def test_prompt_without_context(self): """Test that prompts without context work normally.""" mcp = MCPServer() @@ -1296,7 +1225,6 @@ def prompt_no_context(text: str) -> str: class TestServerPrompts: """Test prompt functionality in MCPServer server.""" - @pytest.mark.anyio async def test_prompt_decorator(self): """Test that the prompt decorator registers prompts correctly.""" mcp = MCPServer() @@ -1313,7 +1241,6 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" mcp = MCPServer() @@ -1329,7 +1256,6 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" mcp = MCPServer() @@ -1351,32 +1277,33 @@ def test_prompt_decorator_error(self): with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore - def fn() -> str: # pragma: no cover + def fn() -> str: return "Hello, world!" - @pytest.mark.anyio async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" mcp = MCPServer() @mcp.prompt() - def fn(name: str, optional: str = "default") -> str: # pragma: no cover - return f"Hello, {name}!" + def fn(name: str, optional: str = "default") -> str: ... async with Client(mcp) as client: result = await client.list_prompts() - assert result.prompts is not None - assert len(result.prompts) == 1 - prompt = result.prompts[0] - assert prompt.name == "fn" - assert prompt.arguments is not None - assert len(prompt.arguments) == 2 - assert prompt.arguments[0].name == "name" - assert prompt.arguments[0].required is True - assert prompt.arguments[1].name == "optional" - assert prompt.arguments[1].required is False - - @pytest.mark.anyio + assert result == snapshot( + ListPromptsResult( + prompts=[ + Prompt( + name="fn", + description="", + arguments=[ + PromptArgument(name="name", required=True), + PromptArgument(name="optional", required=False), + ], + ) + ] + ) + ) + async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" mcp = MCPServer() @@ -1387,14 +1314,13 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, TextContent) - assert content.text == "Hello, World!" + assert result == snapshot( + GetPromptResult( + description="", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_description(self): """Test getting a prompt through MCP protocol.""" mcp = MCPServer() @@ -1407,20 +1333,6 @@ def fn(name: str) -> str: result = await client.get_prompt("fn", {"name": "World"}) assert result.description == "Test prompt description" - @pytest.mark.anyio - async def test_get_prompt_without_description(self): - """Test getting a prompt without description returns empty string.""" - mcp = MCPServer() - - @mcp.prompt() - def fn(name: str) -> str: - return f"Hello, {name}!" - - async with Client(mcp) as client: - result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "" - - @pytest.mark.anyio async def test_get_prompt_with_docstring_description(self): """Test prompt uses docstring as description when not explicitly provided.""" mcp = MCPServer() @@ -1432,9 +1344,13 @@ def fn(name: str) -> str: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "This is the function docstring." + assert result == snapshot( + GetPromptResult( + description="This is the function docstring.", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" mcp = MCPServer() @@ -1444,42 +1360,42 @@ def fn() -> Message: return UserMessage( content=EmbeddedResource( type="resource", - resource=TextResourceContents( - uri="file://file.txt", - text="File contents", - mime_type="text/plain", - ), + resource=TextResourceContents(uri="file://file.txt", text="File contents", mime_type="text/plain"), ) ) async with Client(mcp) as client: result = await client.get_prompt("fn") - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, EmbeddedResource) - resource = content.resource - assert isinstance(resource, TextResourceContents) - assert resource.text == "File contents" - assert resource.mime_type == "text/plain" + assert result == snapshot( + GetPromptResult( + description="", + messages=[ + PromptMessage( + role="user", + content=EmbeddedResource( + resource=TextResourceContents( + uri="file://file.txt", mime_type="text/plain", text="File contents" + ) + ), + ) + ], + ) + ) - @pytest.mark.anyio 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"): await client.get_prompt("unknown") - @pytest.mark.anyio async def test_get_prompt_missing_args(self): """Test error when required arguments are missing.""" mcp = MCPServer() @mcp.prompt() - def prompt_fn(name: str) -> str: # pragma: no cover - return f"Hello, {name}!" + def prompt_fn(name: str) -> str: ... async with Client(mcp) as client: with pytest.raises(MCPError, match="Missing required arguments"): From 3c582655be9a864da729ce26599adf80a87b4d2f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Jan 2026 19:48:47 +0100 Subject: [PATCH 4/5] readd logger --- src/mcp/server/mcpserver/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 2bcbca3607..47d152c2d1 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -355,6 +355,8 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] except Exception as exc: + logger.exception(f"Error getting resource {uri}") + # If an exception happens when reading the resource, we should not leak the exception to the client. raise ResourceError(f"Error reading resource {uri}") from exc def add_tool( From eb3dd47110ca4561785dfa343506889168784ca2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 27 Jan 2026 10:37:03 +0100 Subject: [PATCH 5/5] push --- .../server/mcpserver/resources/resource_manager.py | 6 ++---- src/mcp/server/mcpserver/server.py | 5 +++-- tests/issues/test_141_resource_templates.py | 5 +++-- tests/server/mcpserver/test_server.py | 11 +++++------ 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index a855fb5f55..589015688f 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -82,10 +82,8 @@ def add_template( return template async def get_resource( - self, - uri: AnyUrl | str, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource | None: + self, uri: AnyUrl | str, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None + ) -> Resource: """Get resource by URI, checking concrete resources first, then templates.""" uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 47d152c2d1..fa63a4ef7e 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -347,8 +347,9 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent """Read a resource by URI.""" context = self.get_context() - resource = await self._resource_manager.get_resource(uri, context=context) - if not resource: + try: + resource = await self._resource_manager.get_resource(uri, context=context) + except ValueError: raise ResourceError(f"Unknown resource: {uri}") try: diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index 57e8040df8..f5c5081c3c 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -2,6 +2,7 @@ from mcp import Client from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ResourceError from mcp.types import ( ListResourceTemplatesResult, TextResourceContents, @@ -54,10 +55,10 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover assert result_list[0].mime_type == "text/plain" # Verify invalid parameters raise error - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts") # Missing post_id - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 9a9a2626f3..979dc580f8 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -929,7 +929,7 @@ async def test_resource_decorator_with_metadata(self): mcp = MCPServer() @mcp.resource("resource://config", meta={"ui": {"component": "file-viewer"}, "priority": "high"}) - def get_config() -> str: ... + def get_config() -> str: ... # pragma: no branch resources = await mcp.list_resources() assert resources == snapshot( @@ -950,7 +950,7 @@ async def test_resource_template_decorator_with_metadata(self): mcp = MCPServer() @mcp.resource("resource://{city}/weather", meta={"api_version": "v2", "deprecated": False}) - def get_weather(city: str) -> str: ... + def get_weather(city: str) -> str: ... # pragma: no branch templates = await mcp.list_resource_templates() assert templates == snapshot( @@ -1277,15 +1277,14 @@ def test_prompt_decorator_error(self): with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore - def fn() -> str: - return "Hello, world!" + def fn() -> str: ... # pragma: no branch async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" mcp = MCPServer() @mcp.prompt() - def fn(name: str, optional: str = "default") -> str: ... + def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch async with Client(mcp) as client: result = await client.list_prompts() @@ -1395,7 +1394,7 @@ async def test_get_prompt_missing_args(self): mcp = MCPServer() @mcp.prompt() - def prompt_fn(name: str) -> str: ... + def prompt_fn(name: str) -> str: ... # pragma: no branch async with Client(mcp) as client: with pytest.raises(MCPError, match="Missing required arguments"):