diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 21b974131..e876e4795 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -6,6 +6,8 @@ from mcp.server.mcpserver.prompts.base import Message, Prompt from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT @@ -54,6 +56,7 @@ async def render_prompt( """Render a prompt by name with arguments.""" prompt = self.get_prompt(name) if not prompt: - raise ValueError(f"Unknown prompt: {name}") + # Unknown prompt is a protocol error per MCP spec + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") return await prompt.render(arguments, context=context) diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index ed5b74123..55c254948 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -10,7 +10,8 @@ from mcp.server.mcpserver.resources.base import Resource from mcp.server.mcpserver.resources.templates import ResourceTemplate from mcp.server.mcpserver.utilities.logging import get_logger -from mcp.types import Annotations, Icon +from mcp.shared.exceptions import MCPError +from mcp.types import RESOURCE_NOT_FOUND, Annotations, Icon if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT @@ -83,7 +84,11 @@ def add_template( async def get_resource( self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None ) -> Resource: - """Get resource by URI, checking concrete resources first, then templates.""" + """Get resource by URI, checking concrete resources first, then templates. + + Raises: + MCPError: If the resource is not found (RESOURCE_NOT_FOUND error code). + """ uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) @@ -99,7 +104,8 @@ async def get_resource( except Exception as e: # pragma: no cover raise ValueError(f"Error creating resource from template: {e}") - raise ValueError(f"Unknown resource: {uri}") + # Resource not found is a protocol error per MCP spec + raise MCPError(code=RESOURCE_NOT_FOUND, message=f"Unknown resource: {uri}") def list_resources(self) -> list[Resource]: """List all registered resources.""" diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index f26944a2d..1509b7851 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -46,6 +46,7 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( + INVALID_PARAMS, Annotations, BlobResourceContents, CallToolRequestParams, @@ -440,10 +441,7 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent """Read a resource by URI.""" context = self.get_context() - try: - resource = await self._resource_manager.get_resource(uri, context=context) - except ValueError: - raise ResourceError(f"Unknown resource: {uri}") + resource = await self._resource_manager.get_resource(uri, context=context) try: content = await resource.read() @@ -1069,7 +1067,7 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) - try: prompt = self._prompt_manager.get_prompt(name) if not prompt: - raise ValueError(f"Unknown prompt: {name}") + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") messages = await prompt.render(arguments, context=self.get_context()) @@ -1077,6 +1075,8 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) - description=prompt.description, messages=pydantic_core.to_jsonable_python(messages), ) + except MCPError: + raise except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) diff --git a/src/mcp/server/mcpserver/tools/tool_manager.py b/src/mcp/server/mcpserver/tools/tool_manager.py index c6f8384bd..959406317 100644 --- a/src/mcp/server/mcpserver/tools/tool_manager.py +++ b/src/mcp/server/mcpserver/tools/tool_manager.py @@ -6,7 +6,8 @@ from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools.base import Tool from mcp.server.mcpserver.utilities.logging import get_logger -from mcp.types import Icon, ToolAnnotations +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, Icon, ToolAnnotations if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT @@ -87,6 +88,7 @@ async def call_tool( """Call a tool by name with arguments.""" tool = self.get_tool(name) if not tool: - raise ToolError(f"Unknown tool: {name}") + # Unknown tool is a protocol error per MCP spec + raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}") return await tool.run(arguments, context=context, convert_result=convert_result) diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index b44230393..244112894 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -193,6 +193,7 @@ METHOD_NOT_FOUND, PARSE_ERROR, REQUEST_TIMEOUT, + RESOURCE_NOT_FOUND, URL_ELICITATION_REQUIRED, ErrorData, JSONRPCError, @@ -402,6 +403,7 @@ "METHOD_NOT_FOUND", "PARSE_ERROR", "REQUEST_TIMEOUT", + "RESOURCE_NOT_FOUND", "URL_ELICITATION_REQUIRED", "ErrorData", "JSONRPCError", diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index 0cfdc993a..3e6aef4cc 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -43,6 +43,8 @@ class JSONRPCResponse(BaseModel): # SDK error codes CONNECTION_CLOSED = -32000 REQUEST_TIMEOUT = -32001 +RESOURCE_NOT_FOUND = -32002 +"""Error code indicating that a requested resource was not found.""" # Standard JSON-RPC error codes PARSE_ERROR = -32700 diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index f5c5081c3..9ec1970bd 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -2,8 +2,9 @@ from mcp import Client from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.exceptions import ResourceError +from mcp.shared.exceptions import MCPError from mcp.types import ( + RESOURCE_NOT_FOUND, ListResourceTemplatesResult, TextResourceContents, ) @@ -54,12 +55,14 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover assert result_list[0].content == "Post 456 by user 123" assert result_list[0].mime_type == "text/plain" - # Verify invalid parameters raise error - with pytest.raises(ResourceError, match="Unknown resource"): + # Verify invalid parameters raise protocol error + with pytest.raises(MCPError, match="Unknown resource") as exc_info: await mcp.read_resource("resource://users/123/posts") # Missing post_id + assert exc_info.value.error.code == RESOURCE_NOT_FOUND - with pytest.raises(ResourceError, match="Unknown resource"): + with pytest.raises(MCPError, match="Unknown resource") as exc_info: await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component + assert exc_info.value.error.code == RESOURCE_NOT_FOUND @pytest.mark.anyio diff --git a/tests/server/mcpserver/prompts/test_manager.py b/tests/server/mcpserver/prompts/test_manager.py index 02f91c680..804a0385f 100644 --- a/tests/server/mcpserver/prompts/test_manager.py +++ b/tests/server/mcpserver/prompts/test_manager.py @@ -2,7 +2,8 @@ from mcp.server.mcpserver.prompts.base import Prompt, UserMessage from mcp.server.mcpserver.prompts.manager import PromptManager -from mcp.types import TextContent +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, TextContent class TestPromptManager: @@ -90,10 +91,11 @@ def fn(name: str) -> str: @pytest.mark.anyio async def test_render_unknown_prompt(self): - """Test rendering a non-existent prompt.""" + """Test rendering a non-existent prompt raises protocol error.""" manager = PromptManager() - with pytest.raises(ValueError, match="Unknown prompt: unknown"): + with pytest.raises(MCPError, match="Unknown prompt: unknown") as exc_info: await manager.render_prompt("unknown") + assert exc_info.value.error.code == INVALID_PARAMS @pytest.mark.anyio async def test_render_prompt_with_missing_args(self): diff --git a/tests/server/mcpserver/resources/test_resource_manager.py b/tests/server/mcpserver/resources/test_resource_manager.py index eb9b355aa..e3d217fae 100644 --- a/tests/server/mcpserver/resources/test_resource_manager.py +++ b/tests/server/mcpserver/resources/test_resource_manager.py @@ -5,6 +5,8 @@ from pydantic import AnyUrl from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate +from mcp.shared.exceptions import MCPError +from mcp.types import RESOURCE_NOT_FOUND @pytest.fixture @@ -111,10 +113,11 @@ def greet(name: str) -> str: @pytest.mark.anyio async def test_get_unknown_resource(self): - """Test getting a non-existent resource.""" + """Test getting a non-existent resource raises protocol error.""" manager = ResourceManager() - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(MCPError, match="Unknown resource") as exc_info: await manager.get_resource(AnyUrl("unknown://test")) + assert exc_info.value.error.code == RESOURCE_NOT_FOUND def test_list_resources(self, temp_file: Path): """Test listing all resources.""" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3f253baa8..8fff6aaf0 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -19,6 +19,7 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( + INVALID_PARAMS, AudioContent, BlobResourceContents, Completion, @@ -244,8 +245,8 @@ async def test_call_tool(self): mcp = MCPServer() mcp.add_tool(tool_fn) async with Client(mcp) as client: - result = await client.call_tool("my_tool", {"arg1": "value"}) - assert not hasattr(result, "error") + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert not result.is_error assert len(result.content) > 0 async def test_tool_exception_handling(self): @@ -650,7 +651,7 @@ async def test_remove_tool_and_list(self): assert tools.tools[0].name == "error_tool_fn" async def test_remove_tool_and_call(self): - """Test that calling a removed tool fails appropriately.""" + """Test that calling a removed tool raises a protocol error.""" mcp = MCPServer() mcp.add_tool(tool_fn) @@ -665,13 +666,12 @@ async def test_remove_tool_and_call(self): # Remove the tool mcp.remove_tool("tool_fn") - # Verify calling removed tool returns an error + # Verify calling removed tool raises a protocol error (per MCP spec) async with Client(mcp) as client: - result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert result.is_error - content = result.content[0] - assert isinstance(content, TextContent) - assert "Unknown tool" in content.text + with pytest.raises(MCPError) as exc_info: + await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert "Unknown tool" in str(exc_info.value) + assert exc_info.value.error.code == INVALID_PARAMS class TestServerResources: diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 550bba50a..f50dd44cf 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -12,7 +12,8 @@ from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.server.session import ServerSessionT -from mcp.types import TextContent, ToolAnnotations +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, TextContent, ToolAnnotations class TestAddTools: @@ -255,8 +256,10 @@ def sum(a: int, b: int) -> int: # pragma: no cover @pytest.mark.anyio async def test_call_unknown_tool(self): manager = ToolManager() - with pytest.raises(ToolError): + # Unknown tool raises MCPError (protocol error) per MCP spec + with pytest.raises(MCPError, match="Unknown tool: unknown") as exc_info: await manager.call_tool("unknown", {"a": 1}) + assert exc_info.value.error.code == INVALID_PARAMS @pytest.mark.anyio async def test_call_tool_with_list_int_input(self): @@ -891,9 +894,10 @@ def greet(name: str) -> str: # Remove the tool manager.remove_tool("greet") - # Verify calling removed tool raises error - with pytest.raises(ToolError, match="Unknown tool: greet"): + # Verify calling removed tool raises MCPError (protocol error per MCP spec) + with pytest.raises(MCPError, match="Unknown tool: greet") as exc_info: await manager.call_tool("greet", {"name": "World"}) + assert exc_info.value.error.code == INVALID_PARAMS def test_remove_tool_case_sensitive(self): """Test that tool removal is case-sensitive.""" diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 42b1a3698..fc63ea304 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -53,6 +53,7 @@ from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + RESOURCE_NOT_FOUND, CallToolRequestParams, CallToolResult, InitializeResult, @@ -159,7 +160,7 @@ async def _handle_read_resource( # pragma: no cover await anyio.sleep(2.0) text = f"Slow response from {parsed.netloc}" else: - raise ValueError(f"Unknown resource: {uri}") + raise MCPError(code=RESOURCE_NOT_FOUND, message=f"Unknown resource: {uri}") return ReadResourceResult(contents=[TextResourceContents(uri=uri, text=text, mime_type="text/plain")]) @@ -1024,7 +1025,7 @@ async def test_streamable_http_client_error_handling(initialized_client_session: """Test error handling in client.""" 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 exc_info.value.error.code == RESOURCE_NOT_FOUND assert "Unknown resource: unknown://test-error" in exc_info.value.error.message