diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/fastmcp/prompts/manager.py index 6d032c73a0..2b8045ecf3 100644 --- a/src/mcp/server/fastmcp/prompts/manager.py +++ b/src/mcp/server/fastmcp/prompts/manager.py @@ -6,6 +6,8 @@ from mcp.server.fastmcp.prompts.base import Message, Prompt from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.shared.exceptions import McpError +from mcp.types import INVALID_PARAMS, ErrorData if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -55,6 +57,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(ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")) return await prompt.render(arguments, context=context) diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index 20f67bbe42..223988a18d 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -10,7 +10,8 @@ from mcp.server.fastmcp.resources.base import Resource from mcp.server.fastmcp.resources.templates import ResourceTemplate from mcp.server.fastmcp.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, ErrorData, Icon if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -85,8 +86,12 @@ async def get_resource( self, uri: AnyUrl | str, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource | None: - """Get resource by URI, checking concrete resources first, then templates.""" + ) -> Resource: + """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}) @@ -102,7 +107,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(ErrorData(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/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 460dedb970..7ee0a6ed1b 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -65,7 +65,17 @@ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.context import LifespanContextT, RequestContext, RequestT -from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations +from mcp.shared.exceptions import McpError +from mcp.types import ( + INVALID_PARAMS, + Annotations, + AnyFunction, + ContentBlock, + ErrorData, + GetPromptResult, + Icon, + ToolAnnotations, +) from mcp.types import Prompt as MCPPrompt from mcp.types import PromptArgument as MCPPromptArgument from mcp.types import Resource as MCPResource @@ -1088,7 +1098,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(ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}")) messages = await prompt.render(arguments, context=self.get_context()) @@ -1096,6 +1106,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/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 0d3d9d52a4..b420c3a544 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -7,7 +7,8 @@ from mcp.server.fastmcp.tools.base import Tool from mcp.server.fastmcp.utilities.logging import get_logger from mcp.shared.context import LifespanContextT, RequestT -from mcp.types import Icon, ToolAnnotations +from mcp.shared.exceptions import McpError +from mcp.types import INVALID_PARAMS, ErrorData, Icon, ToolAnnotations if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -88,6 +89,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(ErrorData(code=INVALID_PARAMS, message=f"Unknown tool: {name}")) return await tool.run(arguments, context=context, convert_result=convert_result) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 26f6148c48..8ed880dd00 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -586,6 +586,10 @@ async def handler(req: types.CallToolRequest): # Re-raise UrlElicitationRequiredError so it can be properly handled # by _handle_request, which converts it to an error response with code -32042 raise + except McpError: + # Re-raise McpError as protocol error + # (e.g., unknown tool returns INVALID_PARAMS per MCP spec) + raise except Exception as e: return self._make_error_result(str(e)) diff --git a/src/mcp/types.py b/src/mcp/types.py index 2671eb3f7f..46c8c20eee 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -164,6 +164,8 @@ class JSONRPCResponse(BaseModel): # SDK error codes CONNECTION_CLOSED = -32000 # REQUEST_TIMEOUT = -32001 # the typescript sdk uses this +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 0a0484d894..42bcfd7922 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -2,10 +2,12 @@ from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP +from mcp.shared.exceptions import McpError from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) from mcp.types import ( + RESOURCE_NOT_FOUND, ListResourceTemplatesResult, TextResourceContents, ) @@ -56,12 +58,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(ValueError, 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(ValueError, 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/fastmcp/prompts/test_manager.py b/tests/server/fastmcp/prompts/test_manager.py index 950ffddd1a..df31ceebb9 100644 --- a/tests/server/fastmcp/prompts/test_manager.py +++ b/tests/server/fastmcp/prompts/test_manager.py @@ -2,6 +2,8 @@ from mcp.server.fastmcp.prompts.base import Prompt, TextContent, UserMessage from mcp.server.fastmcp.prompts.manager import PromptManager +from mcp.shared.exceptions import McpError +from mcp.types import INVALID_PARAMS class TestPromptManager: @@ -89,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/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index 565c816f18..ef9beb7355 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -5,6 +5,8 @@ from pydantic import AnyUrl, FileUrl from mcp.server.fastmcp.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/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index b6cd0d5dfa..3445c4e43c 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -285,8 +285,8 @@ async def test_call_tool(self): mcp = FastMCP() mcp.add_tool(tool_fn) async with client_session(mcp._mcp_server) 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.isError assert len(result.content) > 0 @pytest.mark.anyio @@ -711,7 +711,7 @@ async def test_remove_tool_and_list(self): @pytest.mark.anyio 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 = FastMCP() mcp.add_tool(tool_fn) @@ -726,13 +726,11 @@ 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_session(mcp._mcp_server) as client: - result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert result.isError - 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) class TestServerResources: diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index d83d484744..173c5c1812 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -12,6 +12,7 @@ from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.server.session import ServerSessionT from mcp.shared.context import LifespanContextT, RequestT +from mcp.shared.exceptions import McpError from mcp.types import TextContent, ToolAnnotations @@ -255,7 +256,8 @@ 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): await manager.call_tool("unknown", {"a": 1}) @pytest.mark.anyio @@ -893,8 +895,8 @@ def greet(name: str) -> str: # pragma: no cover # 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"): await manager.call_tool("greet", {"name": "World"}) def test_remove_tool_case_sensitive(self): diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 0ed4250533..8055792b3a 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -48,6 +48,8 @@ from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + RESOURCE_NOT_FOUND, + ErrorData, InitializeResult, JSONRPCMessage, JSONRPCRequest, @@ -145,7 +147,7 @@ async def handle_read_resource(uri: AnyUrl) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {uri.host}" - raise ValueError(f"Unknown resource: {uri}") + raise McpError(ErrorData(code=RESOURCE_NOT_FOUND, message=f"Unknown resource: {uri}")) @self.list_tools() async def handle_list_tools() -> list[Tool]: @@ -1036,7 +1038,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=AnyUrl("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