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 d0a550280e..56662a0097 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -44,7 +44,16 @@ 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, ContentBlock, GetPromptResult, Icon, ToolAnnotations +from mcp.shared.exceptions import McpError +from mcp.types import ( + INVALID_PARAMS, + Annotations, + 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 @@ -964,7 +973,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()) @@ -972,6 +981,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 6bea4126ff..3a0d119e01 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -600,6 +600,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/__init__.py b/src/mcp/types/__init__.py index 00bf839923..6ec03634d9 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -192,6 +192,7 @@ METHOD_NOT_FOUND, PARSE_ERROR, REQUEST_TIMEOUT, + RESOURCE_NOT_FOUND, URL_ELICITATION_REQUIRED, ErrorData, JSONRPCError, @@ -404,6 +405,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 86066d80dc..379ad077e3 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 be99e75837..1f6e723f63 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -2,7 +2,9 @@ from mcp import Client from mcp.server.fastmcp import FastMCP +from mcp.shared.exceptions import McpError from mcp.types import ( + RESOURCE_NOT_FOUND, ListResourceTemplatesResult, TextResourceContents, ) @@ -53,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(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 5fd4bc8529..8674329100 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 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 6d1cee58ef..40b2801b98 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -18,6 +18,7 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import McpError from mcp.types import ( + INVALID_PARAMS, AudioContent, BlobResourceContents, ContentBlock, @@ -239,8 +240,8 @@ async def test_call_tool(self): mcp = FastMCP() 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 @pytest.mark.anyio @@ -664,7 +665,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) @@ -679,13 +680,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/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index b09ae7de15..214ad65d95 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -12,7 +12,8 @@ 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.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): @@ -893,9 +896,10 @@ 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") 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 ed86f9860e..54f698d492 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, JSONRPCRequest, TextContent, @@ -145,7 +147,7 @@ async def handle_read_resource(uri: str) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {parsed.netloc}" - 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]: @@ -1034,7 +1036,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