Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/mcp/server/fastmcp/prompts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
14 changes: 10 additions & 4 deletions src/mcp/server/fastmcp/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand All @@ -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."""
Expand Down
16 changes: 14 additions & 2 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1088,14 +1098,16 @@ 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())

return GetPromptResult(
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))
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/server/fastmcp/tools/tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
2 changes: 2 additions & 0 deletions src/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions tests/issues/test_141_resource_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions tests/server/fastmcp/prompts/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions tests/server/fastmcp/resources/test_resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
16 changes: 7 additions & 9 deletions tests/server/fastmcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions tests/server/fastmcp/test_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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


Expand Down