Skip to content

Commit 56fe6a3

Browse files
committed
[minimcp] Improve test coverage for edge cases and error handling
- Add tests for error scenarios across all managers and transports - Add edge case coverage for MiniMCP core orchestrator - Add validation and error handling tests - Clean up test imports
1 parent 36c3166 commit 56fe6a3

File tree

9 files changed

+322
-12
lines changed

9 files changed

+322
-12
lines changed

tests/server/minimcp/unit/managers/test_prompt_manager.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mcp.server.lowlevel.server import Server
99
from mcp.server.minimcp.exceptions import InvalidArgumentsError, MCPFuncError, MCPRuntimeError, PrimitiveError
1010
from mcp.server.minimcp.managers.prompt_manager import PromptDefinition, PromptManager
11+
from mcp.server.minimcp.utils.mcp_func import MCPFunc
1112

1213
pytestmark = pytest.mark.anyio
1314

@@ -916,3 +917,29 @@ def no_args_prompt() -> str:
916917
# Should be callable with empty args
917918
get_result = await prompt_manager.get("no_args_prompt", None)
918919
assert len(get_result.messages) == 1
920+
921+
def test_get_arguments_without_properties(self, prompt_manager: PromptManager):
922+
"""Test _get_arguments when input_schema has no properties.
923+
924+
A function with no parameters will naturally have an input_schema
925+
without a 'properties' key, which tests the branch where
926+
'properties' is not in input_schema.
927+
"""
928+
929+
def simple_func() -> str:
930+
"""A simple function with no parameters"""
931+
return "result"
932+
933+
mcp_func = MCPFunc(simple_func)
934+
935+
# Verify the schema doesn't have properties (or has empty properties)
936+
# This tests the code path where "properties" not in input_schema
937+
arguments = prompt_manager._get_arguments(mcp_func)
938+
assert arguments == []
939+
940+
async def test_validate_args_with_none(self, prompt_manager: PromptManager):
941+
"""Test _validate_args when prompt_arguments is None."""
942+
# This should return early without raising an error
943+
prompt_manager._validate_args(None, {"some": "args"})
944+
prompt_manager._validate_args(None, None)
945+
# If we get here without exception, the test passes

tests/server/minimcp/unit/managers/test_tool_manager.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
from typing import Any
2-
from unittest.mock import Mock
2+
from unittest.mock import Mock, patch
33

44
import anyio
55
import pytest
66
from pydantic import BaseModel, Field
77

88
import mcp.types as types
9+
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata
910
from mcp.server.lowlevel.server import Server
10-
from mcp.server.minimcp.exceptions import InvalidArgumentsError, MCPFuncError, MCPRuntimeError, PrimitiveError
11+
from mcp.server.minimcp.exceptions import (
12+
InvalidArgumentsError,
13+
MCPFuncError,
14+
MCPRuntimeError,
15+
PrimitiveError,
16+
ToolMCPRuntimeError,
17+
)
1118
from mcp.server.minimcp.managers.tool_manager import ToolDefinition, ToolManager
1219

1320
pytestmark = pytest.mark.anyio
@@ -827,3 +834,31 @@ def error_tool(trigger: bool) -> str:
827834
tools = tool_manager.list()
828835
assert len(tools) == 1
829836
assert tools[0].name == "error_tool"
837+
838+
async def test_tool_convert_result_exception(self, tool_manager: ToolManager):
839+
"""Test that exceptions during result conversion are handled properly."""
840+
841+
def simple_tool(value: str) -> str:
842+
"""A simple tool"""
843+
return value
844+
845+
tool_manager.add(simple_tool)
846+
847+
# Patch the convert_result method on FuncMetadata class
848+
with patch.object(FuncMetadata, "convert_result", side_effect=ValueError("Conversion failed")):
849+
with pytest.raises(ToolMCPRuntimeError, match="Error calling tool simple_tool"):
850+
await tool_manager._call("simple_tool", {"value": "test"})
851+
852+
async def test_tool_call_wrapper_mcp_runtime_error(self, tool_manager: ToolManager):
853+
"""Test that call() wrapper properly converts ToolMCPRuntimeError to MCPRuntimeError."""
854+
855+
def simple_tool(value: str) -> str:
856+
"""A simple tool"""
857+
return value
858+
859+
tool_manager.add(simple_tool)
860+
861+
# Patch the convert_result method on FuncMetadata class
862+
with patch.object(FuncMetadata, "convert_result", side_effect=RuntimeError("Runtime error")):
863+
with pytest.raises(MCPRuntimeError, match="Error calling tool simple_tool"):
864+
await tool_manager.call("simple_tool", {"value": "test"})

tests/server/minimcp/unit/test_minimcp.py

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from collections.abc import Coroutine
3+
from datetime import datetime
34
from typing import Any
45
from unittest.mock import AsyncMock, Mock, patch
56

@@ -10,10 +11,15 @@
1011
from mcp.server.lowlevel.server import NotificationOptions, Server
1112
from mcp.server.minimcp.exceptions import (
1213
ContextError,
14+
InternalMCPError,
15+
InvalidArgumentsError,
1316
InvalidJSONError,
1417
InvalidJSONRPCMessageError,
1518
InvalidMessageError,
19+
MCPRuntimeError,
20+
PrimitiveError,
1621
RequestHandlerNotFoundError,
22+
ResourceNotFoundError,
1723
UnsupportedMessageTypeError,
1824
)
1925
from mcp.server.minimcp.managers.context_manager import Context, ContextManager
@@ -711,13 +717,10 @@ async def test_error_metadata_in_response(self):
711717
assert "isoTimestamp" in error_data
712718

713719
# Verify timestamp is valid ISO format
714-
from datetime import datetime
715-
716720
datetime.fromisoformat(error_data["isoTimestamp"])
717721

718722
async def test_process_error_with_internal_mcp_error(self):
719723
"""Test _process_error method with InternalMCPError that has data."""
720-
from mcp.server.minimcp.exceptions import ResourceNotFoundError
721724

722725
server: MiniMCP[Any] = MiniMCP(name="process-error-test")
723726

@@ -736,11 +739,6 @@ async def test_process_error_with_internal_mcp_error(self):
736739

737740
async def test_handle_with_different_error_types(self):
738741
"""Test handling different types of MiniMCP errors."""
739-
from mcp.server.minimcp.exceptions import (
740-
InvalidArgumentsError,
741-
MCPRuntimeError,
742-
PrimitiveError,
743-
)
744742

745743
server: MiniMCP[Any] = MiniMCP(name="error-types-test")
746744

@@ -838,3 +836,91 @@ async def test_limiter_integration_with_errors(self):
838836
for i, response in enumerate(results):
839837
assert response["id"] == i
840838
assert "error" in response
839+
840+
async def test_unsupported_message_type_error(self):
841+
"""Test handling of UnsupportedMessageTypeError."""
842+
server: MiniMCP[Any] = MiniMCP(name="test-server")
843+
844+
# Create a message that will trigger UnsupportedMessageTypeError
845+
# This happens when the message is valid JSON-RPC but not a request or notification
846+
with patch("mcp.server.minimcp.minimcp.MiniMCP._handle_rpc_msg") as mock_handle:
847+
from mcp.server.minimcp.exceptions import UnsupportedMessageTypeError
848+
849+
mock_handle.side_effect = UnsupportedMessageTypeError("Unsupported message type")
850+
851+
message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "test", "params": {}})
852+
result = await server.handle(message)
853+
854+
# Should return an error response
855+
response = json.loads(result) # type: ignore
856+
assert "error" in response
857+
assert response["id"] == 1
858+
859+
async def test_request_handler_not_found_error(self):
860+
"""Test handling of RequestHandlerNotFoundError."""
861+
server: MiniMCP[Any] = MiniMCP(name="test-server")
862+
863+
# Create a message with a method that doesn't exist in MiniMCP
864+
message = json.dumps(
865+
{"jsonrpc": "2.0", "id": 1, "method": "resources/subscribe", "params": {"uri": "file:///config.json"}}
866+
)
867+
result = await server.handle(message)
868+
869+
# Should return a METHOD_NOT_FOUND error
870+
response = json.loads(result) # type: ignore
871+
assert "error" in response
872+
assert response["error"]["code"] == types.METHOD_NOT_FOUND
873+
assert response["id"] == 1
874+
875+
async def test_resource_not_found_error(self):
876+
"""Test handling of ResourceNotFoundError."""
877+
server: MiniMCP[Any] = MiniMCP(name="test-server")
878+
879+
# Try to read a resource that doesn't exist
880+
message = json.dumps(
881+
{"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "nonexistent://resource"}}
882+
)
883+
result = await server.handle(message)
884+
885+
# Should return a RESOURCE_NOT_FOUND error
886+
response = json.loads(result) # type: ignore
887+
assert "error" in response
888+
assert response["error"]["code"] == types.RESOURCE_NOT_FOUND
889+
assert response["id"] == 1
890+
891+
async def test_internal_mcp_error(self):
892+
"""Test handling of InternalMCPError."""
893+
894+
server: MiniMCP[Any] = MiniMCP(name="test-server")
895+
896+
# Mock _handle_rpc_msg to raise InternalMCPError
897+
with patch("mcp.server.minimcp.minimcp.MiniMCP._handle_rpc_msg") as mock_handle:
898+
mock_handle.side_effect = InternalMCPError("Internal error")
899+
900+
message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "test", "params": {}})
901+
result = await server.handle(message)
902+
903+
# Should return an INTERNAL_ERROR response
904+
response = json.loads(result) # type: ignore
905+
assert "error" in response
906+
assert response["error"]["code"] == types.INTERNAL_ERROR
907+
assert response["id"] == 1
908+
909+
async def test_error_type_checking_branches(self):
910+
"""Test the error type checking branches in _parse_message."""
911+
server: MiniMCP[Any] = MiniMCP(name="test-server")
912+
913+
# Test with invalid JSON that triggers json_invalid error
914+
invalid_json = "{invalid json"
915+
with pytest.raises(InvalidMessageError):
916+
await server.handle(invalid_json)
917+
918+
# Test with valid JSON but invalid JSON-RPC (missing jsonrpc field)
919+
invalid_jsonrpc = json.dumps({"id": 1, "method": "test"})
920+
with pytest.raises(InvalidMessageError):
921+
await server.handle(invalid_jsonrpc)
922+
923+
# Test with wrong jsonrpc version
924+
wrong_version = json.dumps({"jsonrpc": "1.0", "id": 1, "method": "test", "params": {}})
925+
with pytest.raises(InvalidMessageError):
926+
await server.handle(wrong_version)

tests/server/minimcp/unit/transports/test_base_http_transport.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from collections.abc import Mapping
33
from http import HTTPStatus
44
from typing import Any, NamedTuple
5-
from unittest.mock import ANY, AsyncMock
5+
from unittest.mock import ANY, AsyncMock, patch
66

77
import anyio
88
import pytest
@@ -11,6 +11,7 @@
1111
from starlette.responses import Response
1212
from typing_extensions import override
1313

14+
from mcp.server.minimcp.exceptions import InvalidMessageError
1415
from mcp.server.minimcp.minimcp import MiniMCP
1516
from mcp.server.minimcp.transports.base_http import MEDIA_TYPE_JSON, BaseHTTPTransport, RequestValidationError
1617
from mcp.shared.version import LATEST_PROTOCOL_VERSION
@@ -126,6 +127,18 @@ class TestBaseHTTPTransportHeaderValidation:
126127
def transport(self) -> BaseHTTPTransport[Any]:
127128
return MockHTTPTransport(AsyncMock(spec=MiniMCP[Any]))
128129

130+
@pytest.fixture
131+
def accept_content_types(self) -> str:
132+
return "application/json"
133+
134+
@pytest.fixture
135+
def request_headers(self, accept_content_types: str) -> dict[str, str]:
136+
return {
137+
"Content-Type": "application/json",
138+
"Accept": accept_content_types,
139+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION,
140+
}
141+
129142
def test_validate_accept_headers_valid(self, transport: BaseHTTPTransport[Any]):
130143
"""Test validate accept headers with valid headers."""
131144
headers = {"Accept": "application/json, text/plain"}
@@ -279,3 +292,20 @@ def test_whitespace_in_headers(self, transport: BaseHTTPTransport[Any]):
279292

280293
# Content-Type header test
281294
transport._validate_content_type(headers)
295+
296+
async def test_handle_post_request_with_invalid_message_error(self, request_headers: dict[str, str]):
297+
"""Test _handle_post_request when InvalidMessageError is raised."""
298+
299+
server = MiniMCP[Any](name="test-server", version="1.0.0")
300+
transport = MockHTTPTransport(server)
301+
302+
# Create an invalid message that will trigger InvalidMessageError
303+
invalid_message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "test"})
304+
305+
# Mock handle to raise InvalidMessageError with a response
306+
error_response = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": 1}'
307+
with patch.object(server, "handle", side_effect=InvalidMessageError("Invalid", error_response)):
308+
result = await transport._handle_post_request(request_headers, invalid_message, None)
309+
310+
assert result.status_code == HTTPStatus.BAD_REQUEST
311+
assert result.content == error_response

tests/server/minimcp/unit/transports/test_http_transport.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import anyio
77
import pytest
8+
from starlette.requests import Request
89

910
from mcp.server.minimcp.minimcp import MiniMCP
1011
from mcp.server.minimcp.transports.base_http import MEDIA_TYPE_JSON, RequestValidationError
@@ -374,6 +375,18 @@ class TestHTTPTransportHeaderValidation:
374375
def transport(self) -> HTTPTransport[Any]:
375376
return HTTPTransport[Any](AsyncMock(spec=MiniMCP[Any]))
376377

378+
@pytest.fixture
379+
def accept_content_types(self) -> str:
380+
return "application/json"
381+
382+
@pytest.fixture
383+
def request_headers(self, accept_content_types: str) -> dict[str, str]:
384+
return {
385+
"Content-Type": "application/json",
386+
"Accept": accept_content_types,
387+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION,
388+
}
389+
377390
def test_validate_accept_headers_valid(self, transport: HTTPTransport[Any]):
378391
"""Test validate accept headers with valid headers."""
379392
headers = {"Accept": "application/json, text/plain"}
@@ -527,3 +540,48 @@ def test_whitespace_in_headers(self, transport: HTTPTransport[Any]):
527540

528541
# Content-Type header test
529542
transport._validate_content_type(headers)
543+
544+
async def test_starlette_dispatch(self, request_headers: dict[str, str]):
545+
"""Test starlette_dispatch method."""
546+
547+
server = MiniMCP[Any](name="test-server", version="1.0.0")
548+
transport = HTTPTransport(server)
549+
550+
# Create a mock request
551+
init_message = json.dumps(
552+
{
553+
"jsonrpc": "2.0",
554+
"id": 1,
555+
"method": "initialize",
556+
"params": {
557+
"protocolVersion": LATEST_PROTOCOL_VERSION,
558+
"capabilities": {},
559+
"clientInfo": {"name": "test", "version": "1.0"},
560+
},
561+
}
562+
)
563+
564+
# Mock Starlette request
565+
scope = {
566+
"type": "http",
567+
"method": "POST",
568+
"headers": [(k.lower().encode(), v.encode()) for k, v in request_headers.items()],
569+
}
570+
request = Request(scope)
571+
request._body = init_message.encode()
572+
573+
response = await transport.starlette_dispatch(request)
574+
575+
assert response.status_code == HTTPStatus.OK
576+
assert response.media_type == MEDIA_TYPE_JSON
577+
578+
async def test_as_starlette(self, request_headers: dict[str, str]):
579+
"""Test as_starlette method."""
580+
server = MiniMCP[Any](name="test-server", version="1.0.0")
581+
transport = HTTPTransport(server)
582+
583+
app = transport.as_starlette(path="/mcp", debug=True)
584+
585+
# Verify app is created
586+
assert app is not None
587+
assert len(app.routes) == 1

0 commit comments

Comments
 (0)