refactor: drop test classes (#1924)

This commit is contained in:
Marcelo Trylesinski 2026-01-22 12:37:52 +01:00 • committed by GitHub
commit d77292fb06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1611 additions and 1562 deletions

View file

@ -38,11 +38,8 @@ def bypass_server_output_validation():
yield yield
class TestClientOutputSchemaValidation:
"""Test client-side validation of structured output from tools"""
@pytest.mark.anyio @pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_basemodel(self): async def test_tool_structured_output_client_side_validation_basemodel():
"""Test that client validates structured content against schema for BaseModel outputs""" """Test that client validates structured content against schema for BaseModel outputs"""
# Create a malicious low-level server that returns invalid structured content # Create a malicious low-level server that returns invalid structured content
server = Server("test-server") server = Server("test-server")
@ -81,8 +78,9 @@ class TestClientOutputSchemaValidation:
# Verify it's a validation error # Verify it's a validation error
assert "Invalid structured content returned by tool get_user" in str(exc_info.value) assert "Invalid structured content returned by tool get_user" in str(exc_info.value)
@pytest.mark.anyio @pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_primitive(self): async def test_tool_structured_output_client_side_validation_primitive():
"""Test that client validates structured content for primitive outputs""" """Test that client validates structured content for primitive outputs"""
server = Server("test-server") server = Server("test-server")
@ -117,8 +115,9 @@ class TestClientOutputSchemaValidation:
await client.call_tool("calculate", {}) await client.call_tool("calculate", {})
assert "Invalid structured content returned by tool calculate" in str(exc_info.value) assert "Invalid structured content returned by tool calculate" in str(exc_info.value)
@pytest.mark.anyio @pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_dict_typed(self): async def test_tool_structured_output_client_side_validation_dict_typed():
"""Test that client validates dict[str, T] structured content""" """Test that client validates dict[str, T] structured content"""
server = Server("test-server") server = Server("test-server")
@ -148,8 +147,9 @@ class TestClientOutputSchemaValidation:
await client.call_tool("get_scores", {}) await client.call_tool("get_scores", {})
assert "Invalid structured content returned by tool get_scores" in str(exc_info.value) assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)
@pytest.mark.anyio @pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_missing_required(self): async def test_tool_structured_output_client_side_validation_missing_required():
"""Test that client validates missing required fields""" """Test that client validates missing required fields"""
server = Server("test-server") server = Server("test-server")
@ -183,8 +183,9 @@ class TestClientOutputSchemaValidation:
await client.call_tool("get_person", {}) await client.call_tool("get_person", {})
assert "Invalid structured content returned by tool get_person" in str(exc_info.value) assert "Invalid structured content returned by tool get_person" in str(exc_info.value)
@pytest.mark.anyio @pytest.mark.anyio
async def test_tool_not_listed_warning(self, caplog: pytest.LogCaptureFixture): async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture):
"""Test that client logs warning when tool is not in list_tools but has output_schema""" """Test that client logs warning when tool is not in list_tools but has output_schema"""
server = Server("test-server") server = Server("test-server")

View file

@ -25,16 +25,15 @@ def mock_exit_stack():
return mock.MagicMock(spec=contextlib.AsyncExitStack) return mock.MagicMock(spec=contextlib.AsyncExitStack)
@pytest.mark.anyio def test_client_session_group_init():
class TestClientSessionGroup:
def test_init(self):
mcp_session_group = ClientSessionGroup() mcp_session_group = ClientSessionGroup()
assert not mcp_session_group._tools assert not mcp_session_group._tools
assert not mcp_session_group._resources assert not mcp_session_group._resources
assert not mcp_session_group._prompts assert not mcp_session_group._prompts
assert not mcp_session_group._tool_to_session assert not mcp_session_group._tool_to_session
def test_component_properties(self):
def test_client_session_group_component_properties():
# --- Mock Dependencies --- # --- Mock Dependencies ---
mock_prompt = mock.Mock() mock_prompt = mock.Mock()
mock_resource = mock.Mock() mock_resource = mock.Mock()
@ -51,7 +50,9 @@ class TestClientSessionGroup:
assert mcp_session_group.resources == {"my_resource": mock_resource} assert mcp_session_group.resources == {"my_resource": mock_resource}
assert mcp_session_group.tools == {"my_tool": mock_tool} assert mcp_session_group.tools == {"my_tool": mock_tool}
async def test_call_tool(self):
@pytest.mark.anyio
async def test_client_session_group_call_tool():
# --- Mock Dependencies --- # --- Mock Dependencies ---
mock_session = mock.AsyncMock() mock_session = mock.AsyncMock()
@ -84,7 +85,9 @@ class TestClientSessionGroup:
meta=None, meta=None,
) )
async def test_connect_to_server(self, mock_exit_stack: contextlib.AsyncExitStack):
@pytest.mark.anyio
async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting to a server and aggregating components.""" """Test connecting to a server and aggregating components."""
# --- Mock Dependencies --- # --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation) mock_server_info = mock.Mock(spec=types.Implementation)
@ -121,7 +124,9 @@ class TestClientSessionGroup:
mock_session.list_resources.assert_awaited_once() mock_session.list_resources.assert_awaited_once()
mock_session.list_prompts.assert_awaited_once() mock_session.list_prompts.assert_awaited_once()
async def test_connect_to_server_with_name_hook(self, mock_exit_stack: contextlib.AsyncExitStack):
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting with a component name hook.""" """Test connecting with a component name hook."""
# --- Mock Dependencies --- # --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation) mock_server_info = mock.Mock(spec=types.Implementation)
@ -150,7 +155,9 @@ class TestClientSessionGroup:
assert group.tools[expected_tool_name] == mock_tool assert group.tools[expected_tool_name] == mock_tool
assert group._tool_to_session[expected_tool_name] == mock_session assert group._tool_to_session[expected_tool_name] == mock_session
async def test_disconnect_from_server(self): # No mock arguments needed
@pytest.mark.anyio
async def test_client_session_group_disconnect_from_server():
"""Test disconnecting from a server.""" """Test disconnecting from a server."""
# --- Test Setup --- # --- Test Setup ---
group = ClientSessionGroup() group = ClientSessionGroup()
@ -213,7 +220,11 @@ class TestClientSessionGroup:
assert "res1" not in group._resources assert "res1" not in group._resources
assert "prm1" not in group._prompts assert "prm1" not in group._prompts
async def test_connect_to_server_duplicate_tool_raises_error(self, mock_exit_stack: contextlib.AsyncExitStack):
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_duplicate_tool_raises_error(
mock_exit_stack: contextlib.AsyncExitStack,
):
"""Test McpError raised when connecting a server with a dup name.""" """Test McpError raised when connecting a server with a dup name."""
# --- Setup Pre-existing State --- # --- Setup Pre-existing State ---
group = ClientSessionGroup(exit_stack=mock_exit_stack) group = ClientSessionGroup(exit_stack=mock_exit_stack)
@ -257,14 +268,17 @@ class TestClientSessionGroup:
assert len(group._tools) == 1 # Should still only have the original assert len(group._tools) == 1 # Should still only have the original
assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock
# No patching needed here
async def test_disconnect_non_existent_server(self): @pytest.mark.anyio
async def test_client_session_group_disconnect_non_existent_server():
"""Test disconnecting a server that isn't connected.""" """Test disconnecting a server that isn't connected."""
session = mock.Mock(spec=mcp.ClientSession) session = mock.Mock(spec=mcp.ClientSession)
group = ClientSessionGroup() group = ClientSessionGroup()
with pytest.raises(McpError): with pytest.raises(McpError):
await group.disconnect_from_server(session) await group.disconnect_from_server(session)
@pytest.mark.anyio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"server_params_instance, client_type_name, patch_target_for_client_func", "server_params_instance, client_type_name, patch_target_for_client_func",
[ [
@ -285,8 +299,7 @@ class TestClientSessionGroup:
), # url, headers, timeout, sse_read_timeout, terminate_on_close ), # url, headers, timeout, sse_read_timeout, terminate_on_close
], ],
) )
async def test_establish_session_parameterized( async def test_client_session_group_establish_session_parameterized(
self,
server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters,
client_type_name: str, # Just for clarity or conditional logic if needed client_type_name: str, # Just for clarity or conditional logic if needed
patch_target_for_client_func: str, patch_target_for_client_func: str,

View file

@ -45,10 +45,7 @@ def valid_access_token() -> AccessToken:
@pytest.mark.anyio @pytest.mark.anyio
class TestAuthContextMiddleware: async def test_auth_context_middleware_with_authenticated_user(valid_access_token: AccessToken):
"""Tests for the AuthContextMiddleware class."""
async def test_with_authenticated_user(self, valid_access_token: AccessToken):
"""Test middleware with an authenticated user in scope.""" """Test middleware with an authenticated user in scope."""
app = MockApp() app = MockApp()
middleware = AuthContextMiddleware(app) middleware = AuthContextMiddleware(app)
@ -85,7 +82,9 @@ class TestAuthContextMiddleware:
assert auth_context_var.get() is None assert auth_context_var.get() is None
assert get_access_token() is None assert get_access_token() is None
async def test_with_no_user(self):
@pytest.mark.anyio
async def test_auth_context_middleware_with_no_user():
"""Test middleware with no user in scope.""" """Test middleware with no user in scope."""
app = MockApp() app = MockApp()
middleware = AuthContextMiddleware(app) middleware = AuthContextMiddleware(app)

View file

@ -83,9 +83,8 @@ async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
return client_info return client_info
class TestRegistrationErrorHandling:
@pytest.mark.anyio @pytest.mark.anyio
async def test_registration_error_handling(self, client: httpx.AsyncClient, oauth_provider: MockOAuthProvider): async def test_registration_error_handling(client: httpx.AsyncClient, oauth_provider: MockOAuthProvider):
# Mock the register_client method to raise a registration error # Mock the register_client method to raise a registration error
with unittest.mock.patch.object( with unittest.mock.patch.object(
oauth_provider, oauth_provider,
@ -117,10 +116,8 @@ class TestRegistrationErrorHandling:
assert data["error_description"] == "The redirect URI is invalid" assert data["error_description"] == "The redirect URI is invalid"
class TestAuthorizeErrorHandling:
@pytest.mark.anyio @pytest.mark.anyio
async def test_authorize_error_handling( async def test_authorize_error_handling(
self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider, oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any], registered_client: dict[str, Any],
@ -160,10 +157,8 @@ class TestAuthorizeErrorHandling:
assert query_params["state"][0] == "test_state" assert query_params["state"][0] == "test_state"
class TestTokenErrorHandling:
@pytest.mark.anyio @pytest.mark.anyio
async def test_token_error_handling_auth_code( async def test_token_error_handling_auth_code(
self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider, oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any], registered_client: dict[str, Any],
@ -220,9 +215,9 @@ class TestTokenErrorHandling:
assert data["error"] == "invalid_grant" assert data["error"] == "invalid_grant"
assert data["error_description"] == "The authorization code is invalid" assert data["error_description"] == "The authorization code is invalid"
@pytest.mark.anyio @pytest.mark.anyio
async def test_token_error_handling_refresh_token( async def test_token_error_handling_refresh_token(
self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider, oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any], registered_client: dict[str, Any],

View file

@ -105,28 +105,31 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
) )
class TestMetadataUrlConstruction: # Tests for URL construction utility function
"""Test URL construction utility function."""
def test_url_without_path(self):
def test_metadata_url_construction_url_without_path():
"""Test URL construction for resource without path component.""" """Test URL construction for resource without path component."""
resource_url = AnyHttpUrl("https://example.com") resource_url = AnyHttpUrl("https://example.com")
result = build_resource_metadata_url(resource_url) result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource" assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
def test_url_with_path_component(self):
def test_metadata_url_construction_url_with_path_component():
"""Test URL construction for resource with path component.""" """Test URL construction for resource with path component."""
resource_url = AnyHttpUrl("https://example.com/mcp") resource_url = AnyHttpUrl("https://example.com/mcp")
result = build_resource_metadata_url(resource_url) result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp" assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
def test_url_with_trailing_slash_only(self):
def test_metadata_url_construction_url_with_trailing_slash_only():
"""Test URL construction for resource with trailing slash only.""" """Test URL construction for resource with trailing slash only."""
resource_url = AnyHttpUrl("https://example.com/") resource_url = AnyHttpUrl("https://example.com/")
result = build_resource_metadata_url(resource_url) result = build_resource_metadata_url(resource_url)
# Trailing slash should be treated as empty path # Trailing slash should be treated as empty path
assert str(result) == "https://example.com/.well-known/oauth-protected-resource" assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"resource_url,expected_url", "resource_url,expected_url",
[ [
@ -136,16 +139,16 @@ class TestMetadataUrlConstruction:
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"), ("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
], ],
) )
def test_various_resource_configurations(self, resource_url: str, expected_url: str): def test_metadata_url_construction_various_resource_configurations(resource_url: str, expected_url: str):
"""Test URL construction with various resource configurations.""" """Test URL construction with various resource configurations."""
result = build_resource_metadata_url(AnyHttpUrl(resource_url)) result = build_resource_metadata_url(AnyHttpUrl(resource_url))
assert str(result) == expected_url assert str(result) == expected_url
class TestRouteConsistency: # Tests for consistency between URL generation and route registration
"""Test consistency between URL generation and route registration."""
def test_route_path_matches_metadata_url(self):
def test_route_consistency_route_path_matches_metadata_url():
"""Test that route path matches the generated metadata URL.""" """Test that route path matches the generated metadata URL."""
resource_url = AnyHttpUrl("https://example.com/mcp") resource_url = AnyHttpUrl("https://example.com/mcp")
@ -165,6 +168,7 @@ class TestRouteConsistency:
assert len(routes) == 1 assert len(routes) == 1
assert routes[0].path == metadata_path assert routes[0].path == metadata_path
@pytest.mark.parametrize( @pytest.mark.parametrize(
"resource_url,expected_path", "resource_url,expected_path",
[ [
@ -173,7 +177,7 @@ class TestRouteConsistency:
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"), ("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
], ],
) )
def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str): def test_route_consistency_consistent_paths_for_various_resources(resource_url: str, expected_path: str):
"""Test that URL generation and route creation are consistent.""" """Test that URL generation and route creation are consistent."""
resource_url_obj = AnyHttpUrl(resource_url) resource_url_obj = AnyHttpUrl(resource_url)

View file

@ -3,17 +3,15 @@
from mcp.server.auth.provider import construct_redirect_uri from mcp.server.auth.provider import construct_redirect_uri
class TestConstructRedirectUri: def test_construct_redirect_uri_no_existing_params():
"""Tests for the construct_redirect_uri function."""
def test_construct_redirect_uri_no_existing_params(self):
"""Test construct_redirect_uri with no existing query parameters.""" """Test construct_redirect_uri with no existing query parameters."""
base_uri = "http://localhost:8000/callback" base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result
def test_construct_redirect_uri_with_existing_params(self):
def test_construct_redirect_uri_with_existing_params():
"""Test construct_redirect_uri with existing query parameters (regression test for #1279).""" """Test construct_redirect_uri with existing query parameters (regression test for #1279)."""
base_uri = "http://localhost:8000/callback?session_id=1234" base_uri = "http://localhost:8000/callback?session_id=1234"
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
@ -24,7 +22,8 @@ class TestConstructRedirectUri:
assert "state=test_state" in result assert "state=test_state" in result
assert result.startswith("http://localhost:8000/callback?") assert result.startswith("http://localhost:8000/callback?")
def test_construct_redirect_uri_multiple_existing_params(self):
def test_construct_redirect_uri_multiple_existing_params():
"""Test construct_redirect_uri with multiple existing query parameters.""" """Test construct_redirect_uri with multiple existing query parameters."""
base_uri = "http://localhost:8000/callback?session_id=1234&user=test" base_uri = "http://localhost:8000/callback?session_id=1234&user=test"
result = construct_redirect_uri(base_uri, code="auth_code") result = construct_redirect_uri(base_uri, code="auth_code")
@ -33,7 +32,8 @@ class TestConstructRedirectUri:
assert "user=test" in result assert "user=test" in result
assert "code=auth_code" in result assert "code=auth_code" in result
def test_construct_redirect_uri_with_none_values(self):
def test_construct_redirect_uri_with_none_values():
"""Test construct_redirect_uri filters out None values.""" """Test construct_redirect_uri filters out None values."""
base_uri = "http://localhost:8000/callback" base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, code="auth_code", state=None) result = construct_redirect_uri(base_uri, code="auth_code", state=None)
@ -41,14 +41,16 @@ class TestConstructRedirectUri:
assert result == "http://localhost:8000/callback?code=auth_code" assert result == "http://localhost:8000/callback?code=auth_code"
assert "state" not in result assert "state" not in result
def test_construct_redirect_uri_empty_params(self):
def test_construct_redirect_uri_empty_params():
"""Test construct_redirect_uri with no additional parameters.""" """Test construct_redirect_uri with no additional parameters."""
base_uri = "http://localhost:8000/callback?existing=param" base_uri = "http://localhost:8000/callback?existing=param"
result = construct_redirect_uri(base_uri) result = construct_redirect_uri(base_uri)
assert result == "http://localhost:8000/callback?existing=param" assert result == "http://localhost:8000/callback?existing=param"
def test_construct_redirect_uri_duplicate_param_names(self):
def test_construct_redirect_uri_duplicate_param_names():
"""Test construct_redirect_uri when adding param that already exists.""" """Test construct_redirect_uri when adding param that already exists."""
base_uri = "http://localhost:8000/callback?code=existing" base_uri = "http://localhost:8000/callback?code=existing"
result = construct_redirect_uri(base_uri, code="new_code") result = construct_redirect_uri(base_uri, code="new_code")
@ -57,7 +59,8 @@ class TestConstructRedirectUri:
assert "code=existing" in result assert "code=existing" in result
assert "code=new_code" in result assert "code=new_code" in result
def test_construct_redirect_uri_multivalued_existing_params(self):
def test_construct_redirect_uri_multivalued_existing_params():
"""Test construct_redirect_uri with existing multi-valued parameters.""" """Test construct_redirect_uri with existing multi-valued parameters."""
base_uri = "http://localhost:8000/callback?scope=read&scope=write" base_uri = "http://localhost:8000/callback?scope=read&scope=write"
result = construct_redirect_uri(base_uri, code="auth_code") result = construct_redirect_uri(base_uri, code="auth_code")
@ -66,7 +69,8 @@ class TestConstructRedirectUri:
assert "scope=write" in result assert "scope=write" in result
assert "code=auth_code" in result assert "code=auth_code" in result
def test_construct_redirect_uri_encoded_values(self):
def test_construct_redirect_uri_encoded_values():
"""Test construct_redirect_uri handles URL encoding properly.""" """Test construct_redirect_uri handles URL encoding properly."""
base_uri = "http://localhost:8000/callback" base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, state="test state with spaces") result = construct_redirect_uri(base_uri, state="test state with spaces")

View file

@ -10,17 +10,14 @@ by the low-level server to package resource content before sending it over the M
from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.helper_types import ReadResourceContents
class TestReadResourceContentsMetadata: def test_read_resource_contents_with_metadata():
"""Test ReadResourceContents meta field. """Test that ReadResourceContents accepts meta parameter.
ReadResourceContents is an internal helper type used by the low-level MCP server. ReadResourceContents is an internal helper type used by the low-level MCP server.
When a resource is read, the server creates a ReadResourceContents instance that When a resource is read, the server creates a ReadResourceContents instance that
contains the content, mime type, and now metadata. The low-level server then contains the content, mime type, and now metadata. The low-level server then
extracts the meta field and includes it in the protocol response as _meta. extracts the meta field and includes it in the protocol response as _meta.
""" """
def test_read_resource_contents_with_metadata(self):
"""Test that ReadResourceContents accepts meta parameter."""
# Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11) # Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11)
metadata = {"version": "1.0", "cached": True} metadata = {"version": "1.0", "cached": True}
@ -35,7 +32,8 @@ class TestReadResourceContentsMetadata:
assert contents.meta["version"] == "1.0" assert contents.meta["version"] == "1.0"
assert contents.meta["cached"] is True assert contents.meta["cached"] is True
def test_read_resource_contents_without_metadata(self):
def test_read_resource_contents_without_metadata():
"""Test that ReadResourceContents meta defaults to None.""" """Test that ReadResourceContents meta defaults to None."""
# Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11) # Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11)
contents = ReadResourceContents( contents = ReadResourceContents(
@ -45,7 +43,8 @@ class TestReadResourceContentsMetadata:
assert contents.meta is None assert contents.meta is None
def test_read_resource_contents_with_bytes(self):
def test_read_resource_contents_with_bytes():
"""Test that ReadResourceContents works with bytes content and meta.""" """Test that ReadResourceContents works with bytes content and meta."""
# Verifies meta works with both str and bytes content (binary resources like images, PDFs) # Verifies meta works with both str and bytes content (binary resources like images, PDFs)
metadata = {"encoding": "utf-8"} metadata = {"encoding": "utf-8"}

View file

@ -20,65 +20,71 @@ from mcp.types import (
ToolUseContent, ToolUseContent,
) )
# Tests for check_sampling_tools_capability function
class TestCheckSamplingToolsCapability:
"""Tests for check_sampling_tools_capability function."""
def test_returns_false_when_caps_none(self) -> None: def test_check_sampling_tools_capability_returns_false_when_caps_none() -> None:
"""Returns False when client_caps is None.""" """Returns False when client_caps is None."""
assert check_sampling_tools_capability(None) is False assert check_sampling_tools_capability(None) is False
def test_returns_false_when_sampling_none(self) -> None:
def test_check_sampling_tools_capability_returns_false_when_sampling_none() -> None:
"""Returns False when client_caps.sampling is None.""" """Returns False when client_caps.sampling is None."""
caps = ClientCapabilities() caps = ClientCapabilities()
assert check_sampling_tools_capability(caps) is False assert check_sampling_tools_capability(caps) is False
def test_returns_false_when_tools_none(self) -> None:
def test_check_sampling_tools_capability_returns_false_when_tools_none() -> None:
"""Returns False when client_caps.sampling.tools is None.""" """Returns False when client_caps.sampling.tools is None."""
caps = ClientCapabilities(sampling=SamplingCapability()) caps = ClientCapabilities(sampling=SamplingCapability())
assert check_sampling_tools_capability(caps) is False assert check_sampling_tools_capability(caps) is False
def test_returns_true_when_tools_present(self) -> None:
def test_check_sampling_tools_capability_returns_true_when_tools_present() -> None:
"""Returns True when sampling.tools is present.""" """Returns True when sampling.tools is present."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
assert check_sampling_tools_capability(caps) is True assert check_sampling_tools_capability(caps) is True
class TestValidateSamplingTools: # Tests for validate_sampling_tools function
"""Tests for validate_sampling_tools function."""
def test_no_error_when_tools_none(self) -> None:
def test_validate_sampling_tools_no_error_when_tools_none() -> None:
"""No error when tools and tool_choice are None.""" """No error when tools and tool_choice are None."""
validate_sampling_tools(None, None, None) # Should not raise validate_sampling_tools(None, None, None) # Should not raise
def test_raises_when_tools_provided_but_no_capability(self) -> None:
def test_validate_sampling_tools_raises_when_tools_provided_but_no_capability() -> None:
"""Raises McpError when tools provided but client doesn't support.""" """Raises McpError when tools provided but client doesn't support."""
tool = Tool(name="test", input_schema={"type": "object"}) tool = Tool(name="test", input_schema={"type": "object"})
with pytest.raises(McpError) as exc_info: with pytest.raises(McpError) as exc_info:
validate_sampling_tools(None, [tool], None) validate_sampling_tools(None, [tool], None)
assert "sampling tools capability" in str(exc_info.value) assert "sampling tools capability" in str(exc_info.value)
def test_raises_when_tool_choice_provided_but_no_capability(self) -> None:
def test_validate_sampling_tools_raises_when_tool_choice_provided_but_no_capability() -> None:
"""Raises McpError when tool_choice provided but client doesn't support.""" """Raises McpError when tool_choice provided but client doesn't support."""
with pytest.raises(McpError) as exc_info: with pytest.raises(McpError) as exc_info:
validate_sampling_tools(None, None, ToolChoice(mode="auto")) validate_sampling_tools(None, None, ToolChoice(mode="auto"))
assert "sampling tools capability" in str(exc_info.value) assert "sampling tools capability" in str(exc_info.value)
def test_no_error_when_capability_present(self) -> None:
def test_validate_sampling_tools_no_error_when_capability_present() -> None:
"""No error when client has sampling.tools capability.""" """No error when client has sampling.tools capability."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
tool = Tool(name="test", input_schema={"type": "object"}) tool = Tool(name="test", input_schema={"type": "object"})
validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise
class TestValidateToolUseResultMessages: # Tests for validate_tool_use_result_messages function
"""Tests for validate_tool_use_result_messages function."""
def test_no_error_for_empty_messages(self) -> None:
def test_validate_tool_use_result_messages_no_error_for_empty_messages() -> None:
"""No error when messages list is empty.""" """No error when messages list is empty."""
validate_tool_use_result_messages([]) # Should not raise validate_tool_use_result_messages([]) # Should not raise
def test_no_error_for_simple_text_messages(self) -> None:
def test_validate_tool_use_result_messages_no_error_for_simple_text_messages() -> None:
"""No error for simple text messages.""" """No error for simple text messages."""
messages = [ messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="Hello")), SamplingMessage(role="user", content=TextContent(type="text", text="Hello")),
@ -86,7 +92,8 @@ class TestValidateToolUseResultMessages:
] ]
validate_tool_use_result_messages(messages) # Should not raise validate_tool_use_result_messages(messages) # Should not raise
def test_raises_when_tool_result_mixed_with_other_content(self) -> None:
def test_validate_tool_use_result_messages_raises_when_tool_result_mixed_with_other_content() -> None:
"""Raises when tool_result is mixed with other content types.""" """Raises when tool_result is mixed with other content types."""
messages = [ messages = [
SamplingMessage( SamplingMessage(
@ -100,7 +107,8 @@ class TestValidateToolUseResultMessages:
with pytest.raises(ValueError, match="only tool_result content"): with pytest.raises(ValueError, match="only tool_result content"):
validate_tool_use_result_messages(messages) validate_tool_use_result_messages(messages)
def test_raises_when_tool_result_without_previous_tool_use(self) -> None:
def test_validate_tool_use_result_messages_raises_when_tool_result_without_previous_tool_use() -> None:
"""Raises when tool_result appears without preceding tool_use.""" """Raises when tool_result appears without preceding tool_use."""
messages = [ messages = [
SamplingMessage( SamplingMessage(
@ -111,7 +119,8 @@ class TestValidateToolUseResultMessages:
with pytest.raises(ValueError, match="previous message containing tool_use"): with pytest.raises(ValueError, match="previous message containing tool_use"):
validate_tool_use_result_messages(messages) validate_tool_use_result_messages(messages)
def test_raises_when_tool_result_ids_dont_match_tool_use(self) -> None:
def test_validate_tool_use_result_messages_raises_when_tool_result_ids_dont_match_tool_use() -> None:
"""Raises when tool_result IDs don't match tool_use IDs.""" """Raises when tool_result IDs don't match tool_use IDs."""
messages = [ messages = [
SamplingMessage( SamplingMessage(
@ -126,7 +135,8 @@ class TestValidateToolUseResultMessages:
with pytest.raises(ValueError, match="do not match"): with pytest.raises(ValueError, match="do not match"):
validate_tool_use_result_messages(messages) validate_tool_use_result_messages(messages)
def test_no_error_when_tool_result_matches_tool_use(self) -> None:
def test_validate_tool_use_result_messages_no_error_when_tool_result_matches_tool_use() -> None:
"""No error when tool_result IDs match tool_use IDs.""" """No error when tool_result IDs match tool_use IDs."""
messages = [ messages = [
SamplingMessage( SamplingMessage(

View file

@ -3,10 +3,7 @@
from mcp.shared.auth import OAuthMetadata from mcp.shared.auth import OAuthMetadata
class TestOAuthMetadata: def test_oauth():
"""Tests for OAuthMetadata parsing."""
def test_oauth(self):
"""Should not throw when parsing OAuth metadata.""" """Should not throw when parsing OAuth metadata."""
OAuthMetadata.model_validate( OAuthMetadata.model_validate(
{ {
@ -19,7 +16,8 @@ class TestOAuthMetadata:
} }
) )
def test_oidc(self):
def test_oidc():
"""Should not throw when parsing OIDC metadata.""" """Should not throw when parsing OIDC metadata."""
OAuthMetadata.model_validate( OAuthMetadata.model_validate(
{ {
@ -38,7 +36,8 @@ class TestOAuthMetadata:
} }
) )
def test_oauth_with_jarm(self):
def test_oauth_with_jarm():
"""Should not throw when parsing OAuth metadata that includes JARM response modes.""" """Should not throw when parsing OAuth metadata that includes JARM response modes."""
OAuthMetadata.model_validate( OAuthMetadata.model_validate(
{ {

View file

@ -4,70 +4,77 @@ from pydantic import HttpUrl
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
# Tests for resource_url_from_server_url function
class TestResourceUrlFromServerUrl:
"""Tests for resource_url_from_server_url function."""
def test_removes_fragment(self): def test_resource_url_from_server_url_removes_fragment():
"""Fragment should be removed per RFC 8707.""" """Fragment should be removed per RFC 8707."""
assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path" assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path"
assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/" assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/"
def test_preserves_path(self):
def test_resource_url_from_server_url_preserves_path():
"""Path should be preserved.""" """Path should be preserved."""
assert ( assert (
resource_url_from_server_url("https://example.com/path/to/resource") resource_url_from_server_url("https://example.com/path/to/resource") == "https://example.com/path/to/resource"
== "https://example.com/path/to/resource"
) )
assert resource_url_from_server_url("https://example.com/") == "https://example.com/" assert resource_url_from_server_url("https://example.com/") == "https://example.com/"
assert resource_url_from_server_url("https://example.com") == "https://example.com" assert resource_url_from_server_url("https://example.com") == "https://example.com"
def test_preserves_query(self):
def test_resource_url_from_server_url_preserves_query():
"""Query parameters should be preserved.""" """Query parameters should be preserved."""
assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar" assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar"
assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value" assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value"
def test_preserves_port(self):
def test_resource_url_from_server_url_preserves_port():
"""Non-default ports should be preserved.""" """Non-default ports should be preserved."""
assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path" assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path"
assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/" assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/"
def test_lowercase_scheme_and_host(self):
def test_resource_url_from_server_url_lowercase_scheme_and_host():
"""Scheme and host should be lowercase for canonical form.""" """Scheme and host should be lowercase for canonical form."""
assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path" assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path"
assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/" assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/"
def test_handles_pydantic_urls(self):
def test_resource_url_from_server_url_handles_pydantic_urls():
"""Should handle Pydantic URL types.""" """Should handle Pydantic URL types."""
url = HttpUrl("https://example.com/path") url = HttpUrl("https://example.com/path")
assert resource_url_from_server_url(url) == "https://example.com/path" assert resource_url_from_server_url(url) == "https://example.com/path"
class TestCheckResourceAllowed: # Tests for check_resource_allowed function
"""Tests for check_resource_allowed function."""
def test_identical_urls(self):
def test_check_resource_allowed_identical_urls():
"""Identical URLs should match.""" """Identical URLs should match."""
assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True
assert check_resource_allowed("https://example.com/", "https://example.com/") is True assert check_resource_allowed("https://example.com/", "https://example.com/") is True
assert check_resource_allowed("https://example.com", "https://example.com") is True assert check_resource_allowed("https://example.com", "https://example.com") is True
def test_different_schemes(self):
def test_check_resource_allowed_different_schemes():
"""Different schemes should not match.""" """Different schemes should not match."""
assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False
assert check_resource_allowed("http://example.com/", "https://example.com/") is False assert check_resource_allowed("http://example.com/", "https://example.com/") is False
def test_different_domains(self):
def test_check_resource_allowed_different_domains():
"""Different domains should not match.""" """Different domains should not match."""
assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False
assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False
def test_different_ports(self):
def test_check_resource_allowed_different_ports():
"""Different ports should not match.""" """Different ports should not match."""
assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False
assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False
def test_hierarchical_matching(self):
def test_check_resource_allowed_hierarchical_matching():
"""Child paths should match parent paths.""" """Child paths should match parent paths."""
# Parent resource allows child resources # Parent resource allows child resources
assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True
@ -81,7 +88,8 @@ class TestCheckResourceAllowed:
assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False
assert check_resource_allowed("https://example.com/", "https://example.com/api") is False assert check_resource_allowed("https://example.com/", "https://example.com/api") is False
def test_path_boundary_matching(self):
def test_check_resource_allowed_path_boundary_matching():
"""Path matching should respect boundaries.""" """Path matching should respect boundaries."""
# Should not match partial path segments # Should not match partial path segments
assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False
@ -91,7 +99,8 @@ class TestCheckResourceAllowed:
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
def test_trailing_slash_handling(self):
def test_check_resource_allowed_trailing_slash_handling():
"""Trailing slashes should be handled correctly.""" """Trailing slashes should be handled correctly."""
# With and without trailing slashes # With and without trailing slashes
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
@ -99,13 +108,15 @@ class TestCheckResourceAllowed:
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
def test_case_insensitive_origin(self):
def test_check_resource_allowed_case_insensitive_origin():
"""Origin comparison should be case-insensitive.""" """Origin comparison should be case-insensitive."""
assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True
assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True
assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True
def test_empty_paths(self):
def test_check_resource_allowed_empty_paths():
"""Empty paths should be handled correctly.""" """Empty paths should be handled correctly."""
assert check_resource_allowed("https://example.com", "https://example.com") is True assert check_resource_allowed("https://example.com", "https://example.com") is True
assert check_resource_allowed("https://example.com/", "https://example.com") is True assert check_resource_allowed("https://example.com/", "https://example.com") is True

View file

@ -6,10 +6,7 @@ from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
class TestUrlElicitationRequiredError: def test_url_elicitation_required_error_create_with_single_elicitation() -> None:
"""Tests for UrlElicitationRequiredError exception class."""
def test_create_with_single_elicitation(self) -> None:
"""Test creating error with a single elicitation.""" """Test creating error with a single elicitation."""
elicitation = ElicitRequestURLParams( elicitation = ElicitRequestURLParams(
mode="url", mode="url",
@ -24,7 +21,8 @@ class TestUrlElicitationRequiredError:
assert len(error.elicitations) == 1 assert len(error.elicitations) == 1
assert error.elicitations[0].elicitation_id == "test-123" assert error.elicitations[0].elicitation_id == "test-123"
def test_create_with_multiple_elicitations(self) -> None:
def test_url_elicitation_required_error_create_with_multiple_elicitations() -> None:
"""Test creating error with multiple elicitations uses plural message.""" """Test creating error with multiple elicitations uses plural message."""
elicitations = [ elicitations = [
ElicitRequestURLParams( ElicitRequestURLParams(
@ -45,7 +43,8 @@ class TestUrlElicitationRequiredError:
assert error.error.message == "URL elicitations required" # Plural assert error.error.message == "URL elicitations required" # Plural
assert len(error.elicitations) == 2 assert len(error.elicitations) == 2
def test_custom_message(self) -> None:
def test_url_elicitation_required_error_custom_message() -> None:
"""Test creating error with a custom message.""" """Test creating error with a custom message."""
elicitation = ElicitRequestURLParams( elicitation = ElicitRequestURLParams(
mode="url", mode="url",
@ -57,7 +56,8 @@ class TestUrlElicitationRequiredError:
assert error.error.message == "Custom message" assert error.error.message == "Custom message"
def test_from_error_data(self) -> None:
def test_url_elicitation_required_error_from_error_data() -> None:
"""Test reconstructing error from ErrorData.""" """Test reconstructing error from ErrorData."""
error_data = ErrorData( error_data = ErrorData(
code=URL_ELICITATION_REQUIRED, code=URL_ELICITATION_REQUIRED,
@ -80,7 +80,8 @@ class TestUrlElicitationRequiredError:
assert error.elicitations[0].elicitation_id == "test-123" assert error.elicitations[0].elicitation_id == "test-123"
assert error.elicitations[0].url == "https://example.com/auth" assert error.elicitations[0].url == "https://example.com/auth"
def test_from_error_data_wrong_code(self) -> None:
def test_url_elicitation_required_error_from_error_data_wrong_code() -> None:
"""Test that from_error raises ValueError for wrong error code.""" """Test that from_error raises ValueError for wrong error code."""
error_data = ErrorData( error_data = ErrorData(
code=-32600, # Wrong code code=-32600, # Wrong code
@ -91,7 +92,8 @@ class TestUrlElicitationRequiredError:
with pytest.raises(ValueError, match="Expected error code"): with pytest.raises(ValueError, match="Expected error code"):
UrlElicitationRequiredError.from_error(error_data) UrlElicitationRequiredError.from_error(error_data)
def test_serialization_roundtrip(self) -> None:
def test_url_elicitation_required_error_serialization_roundtrip() -> None:
"""Test that error can be serialized and reconstructed.""" """Test that error can be serialized and reconstructed."""
original = UrlElicitationRequiredError( original = UrlElicitationRequiredError(
[ [
@ -114,7 +116,8 @@ class TestUrlElicitationRequiredError:
assert reconstructed.elicitations[0].url == original.elicitations[0].url assert reconstructed.elicitations[0].url == original.elicitations[0].url
assert reconstructed.elicitations[0].message == original.elicitations[0].message assert reconstructed.elicitations[0].message == original.elicitations[0].message
def test_error_data_contains_elicitations(self) -> None:
def test_url_elicitation_required_error_data_contains_elicitations() -> None:
"""Test that error data contains properly serialized elicitations.""" """Test that error data contains properly serialized elicitations."""
elicitation = ElicitRequestURLParams( elicitation = ElicitRequestURLParams(
mode="url", mode="url",
@ -132,7 +135,8 @@ class TestUrlElicitationRequiredError:
assert elicit_data["url"] == "https://example.com/oauth" assert elicit_data["url"] == "https://example.com/oauth"
assert elicit_data["elicitationId"] == "oauth-flow-1" assert elicit_data["elicitationId"] == "oauth-flow-1"
def test_inherits_from_mcp_error(self) -> None:
def test_url_elicitation_required_error_inherits_from_mcp_error() -> None:
"""Test that UrlElicitationRequiredError inherits from McpError.""" """Test that UrlElicitationRequiredError inherits from McpError."""
elicitation = ElicitRequestURLParams( elicitation = ElicitRequestURLParams(
mode="url", mode="url",
@ -145,7 +149,8 @@ class TestUrlElicitationRequiredError:
assert isinstance(error, McpError) assert isinstance(error, McpError)
assert isinstance(error, Exception) assert isinstance(error, Exception)
def test_exception_message(self) -> None:
def test_url_elicitation_required_error_exception_message() -> None:
"""Test that exception message is set correctly.""" """Test that exception message is set correctly."""
elicitation = ElicitRequestURLParams( elicitation = ElicitRequestURLParams(
mode="url", mode="url",

View file

@ -10,12 +10,8 @@ from mcp.shared.tool_name_validation import (
validate_tool_name, validate_tool_name,
) )
# Tests for validate_tool_name function - valid names
class TestValidateToolName:
"""Tests for validate_tool_name function."""
class TestValidNames:
"""Test cases for valid tool names."""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"tool_name", "tool_name",
@ -38,27 +34,30 @@ class TestValidateToolName:
"max_length_128", "max_length_128",
], ],
) )
def test_accepts_valid_names(self, tool_name: str) -> None: def test_validate_tool_name_accepts_valid_names(tool_name: str) -> None:
"""Valid tool names should pass validation with no warnings.""" """Valid tool names should pass validation with no warnings."""
result = validate_tool_name(tool_name) result = validate_tool_name(tool_name)
assert result.is_valid is True assert result.is_valid is True
assert result.warnings == [] assert result.warnings == []
class TestInvalidNames:
"""Test cases for invalid tool names."""
def test_rejects_empty_name(self) -> None: # Tests for validate_tool_name function - invalid names
def test_validate_tool_name_rejects_empty_name() -> None:
"""Empty names should be rejected.""" """Empty names should be rejected."""
result = validate_tool_name("") result = validate_tool_name("")
assert result.is_valid is False assert result.is_valid is False
assert "Tool name cannot be empty" in result.warnings assert "Tool name cannot be empty" in result.warnings
def test_rejects_name_exceeding_max_length(self) -> None:
def test_validate_tool_name_rejects_name_exceeding_max_length() -> None:
"""Names exceeding 128 characters should be rejected.""" """Names exceeding 128 characters should be rejected."""
result = validate_tool_name("a" * 129) result = validate_tool_name("a" * 129)
assert result.is_valid is False assert result.is_valid is False
assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings) assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"tool_name,expected_char", "tool_name,expected_char",
[ [
@ -74,13 +73,14 @@ class TestValidateToolName:
"with_at_symbol", "with_at_symbol",
], ],
) )
def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None: def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_char: str) -> None:
"""Names with invalid characters should be rejected.""" """Names with invalid characters should be rejected."""
result = validate_tool_name(tool_name) result = validate_tool_name(tool_name)
assert result.is_valid is False assert result.is_valid is False
assert any("invalid characters" in w and expected_char in w for w in result.warnings) assert any("invalid characters" in w and expected_char in w for w in result.warnings)
def test_rejects_multiple_invalid_chars(self) -> None:
def test_validate_tool_name_rejects_multiple_invalid_chars() -> None:
"""Names with multiple invalid chars should list all of them.""" """Names with multiple invalid chars should list all of them."""
result = validate_tool_name("user name@domain,com") result = validate_tool_name("user name@domain,com")
assert result.is_valid is False assert result.is_valid is False
@ -89,43 +89,48 @@ class TestValidateToolName:
assert "'@'" in warning assert "'@'" in warning
assert "','" in warning assert "','" in warning
def test_rejects_unicode_characters(self) -> None:
def test_validate_tool_name_rejects_unicode_characters() -> None:
"""Names with unicode characters should be rejected.""" """Names with unicode characters should be rejected."""
result = validate_tool_name("user-\u00f1ame") # n with tilde result = validate_tool_name("user-\u00f1ame") # n with tilde
assert result.is_valid is False assert result.is_valid is False
class TestWarningsForProblematicPatterns:
"""Test cases for valid names that generate warnings."""
def test_warns_on_leading_dash(self) -> None: # Tests for validate_tool_name function - warnings for problematic patterns
def test_validate_tool_name_warns_on_leading_dash() -> None:
"""Names starting with dash should generate warning but be valid.""" """Names starting with dash should generate warning but be valid."""
result = validate_tool_name("-get-user") result = validate_tool_name("-get-user")
assert result.is_valid is True assert result.is_valid is True
assert any("starts or ends with a dash" in w for w in result.warnings) assert any("starts or ends with a dash" in w for w in result.warnings)
def test_warns_on_trailing_dash(self) -> None:
def test_validate_tool_name_warns_on_trailing_dash() -> None:
"""Names ending with dash should generate warning but be valid.""" """Names ending with dash should generate warning but be valid."""
result = validate_tool_name("get-user-") result = validate_tool_name("get-user-")
assert result.is_valid is True assert result.is_valid is True
assert any("starts or ends with a dash" in w for w in result.warnings) assert any("starts or ends with a dash" in w for w in result.warnings)
def test_warns_on_leading_dot(self) -> None:
def test_validate_tool_name_warns_on_leading_dot() -> None:
"""Names starting with dot should generate warning but be valid.""" """Names starting with dot should generate warning but be valid."""
result = validate_tool_name(".get.user") result = validate_tool_name(".get.user")
assert result.is_valid is True assert result.is_valid is True
assert any("starts or ends with a dot" in w for w in result.warnings) assert any("starts or ends with a dot" in w for w in result.warnings)
def test_warns_on_trailing_dot(self) -> None:
def test_validate_tool_name_warns_on_trailing_dot() -> None:
"""Names ending with dot should generate warning but be valid.""" """Names ending with dot should generate warning but be valid."""
result = validate_tool_name("get.user.") result = validate_tool_name("get.user.")
assert result.is_valid is True assert result.is_valid is True
assert any("starts or ends with a dot" in w for w in result.warnings) assert any("starts or ends with a dot" in w for w in result.warnings)
class TestIssueToolNameWarning: # Tests for issue_tool_name_warning function
"""Tests for issue_tool_name_warning function."""
def test_logs_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
def test_issue_tool_name_warning_logs_warnings(caplog: pytest.LogCaptureFixture) -> None:
"""Warnings should be logged at WARNING level.""" """Warnings should be logged at WARNING level."""
warnings = ["Warning 1", "Warning 2"] warnings = ["Warning 1", "Warning 2"]
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
@ -137,7 +142,8 @@ class TestIssueToolNameWarning:
assert "Tool registration will proceed" in caplog.text assert "Tool registration will proceed" in caplog.text
assert "SEP-986" in caplog.text assert "SEP-986" in caplog.text
def test_no_logging_for_empty_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
def test_issue_tool_name_warning_no_logging_for_empty_warnings(caplog: pytest.LogCaptureFixture) -> None:
"""Empty warnings list should not produce any log output.""" """Empty warnings list should not produce any log output."""
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
issue_tool_name_warning("test-tool", []) issue_tool_name_warning("test-tool", [])
@ -145,27 +151,30 @@ class TestIssueToolNameWarning:
assert caplog.text == "" assert caplog.text == ""
class TestValidateAndWarnToolName: # Tests for validate_and_warn_tool_name function
"""Tests for validate_and_warn_tool_name function."""
def test_returns_true_for_valid_name(self) -> None:
def test_validate_and_warn_tool_name_returns_true_for_valid_name() -> None:
"""Valid names should return True.""" """Valid names should return True."""
assert validate_and_warn_tool_name("valid-tool-name") is True assert validate_and_warn_tool_name("valid-tool-name") is True
def test_returns_false_for_invalid_name(self) -> None:
def test_validate_and_warn_tool_name_returns_false_for_invalid_name() -> None:
"""Invalid names should return False.""" """Invalid names should return False."""
assert validate_and_warn_tool_name("") is False assert validate_and_warn_tool_name("") is False
assert validate_and_warn_tool_name("a" * 129) is False assert validate_and_warn_tool_name("a" * 129) is False
assert validate_and_warn_tool_name("invalid name") is False assert validate_and_warn_tool_name("invalid name") is False
def test_logs_warnings_for_invalid_name(self, caplog: pytest.LogCaptureFixture) -> None:
def test_validate_and_warn_tool_name_logs_warnings_for_invalid_name(caplog: pytest.LogCaptureFixture) -> None:
"""Invalid names should trigger warning logs.""" """Invalid names should trigger warning logs."""
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
validate_and_warn_tool_name("invalid name") validate_and_warn_tool_name("invalid name")
assert "Tool name validation warning" in caplog.text assert "Tool name validation warning" in caplog.text
def test_no_warnings_for_clean_valid_name(self, caplog: pytest.LogCaptureFixture) -> None:
def test_validate_and_warn_tool_name_no_warnings_for_clean_valid_name(caplog: pytest.LogCaptureFixture) -> None:
"""Clean valid names should not produce any log output.""" """Clean valid names should not produce any log output."""
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
result = validate_and_warn_tool_name("clean-tool-name") result = validate_and_warn_tool_name("clean-tool-name")
@ -174,8 +183,8 @@ class TestValidateAndWarnToolName:
assert caplog.text == "" assert caplog.text == ""
class TestEdgeCases: # Tests for edge cases
"""Test edge cases and robustness."""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"tool_name,is_valid,expected_warning_fragment", "tool_name,is_valid,expected_warning_fragment",
@ -192,7 +201,7 @@ class TestEdgeCases:
"mixed_valid_invalid", "mixed_valid_invalid",
], ],
) )
def test_edge_cases(self, tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None: def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None:
"""Various edge cases should be handled correctly.""" """Various edge cases should be handled correctly."""
result = validate_tool_name(tool_name) result = validate_tool_name(tool_name)
assert result.is_valid is is_valid assert result.is_valid is is_valid