refactor: drop test classes (#1924)
This commit is contained in:
parent
7b728a243e
commit
d77292fb06
4 changed files with 1611 additions and 1562 deletions
|
|
@ -3,59 +3,58 @@
|
|||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
|
||||
class TestOAuthMetadata:
|
||||
"""Tests for OAuthMetadata parsing."""
|
||||
def test_oauth():
|
||||
"""Should not throw when parsing OAuth metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_oauth(self):
|
||||
"""Should not throw when parsing OAuth metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_oidc(self):
|
||||
"""Should not throw when parsing OIDC metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"end_session_endpoint": "https://example.com/logout",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"jwks_uri": "https://example.com/.well-known/jwks.json",
|
||||
"response_types_supported": ["code", "token"],
|
||||
"revocation_endpoint": "https://example.com/oauth2/revoke",
|
||||
"scopes_supported": ["openid", "read", "write"],
|
||||
"subject_types_supported": ["public"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
"userinfo_endpoint": "https://example.com/oauth2/userInfo",
|
||||
}
|
||||
)
|
||||
def test_oidc():
|
||||
"""Should not throw when parsing OIDC metadata."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"end_session_endpoint": "https://example.com/logout",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"jwks_uri": "https://example.com/.well-known/jwks.json",
|
||||
"response_types_supported": ["code", "token"],
|
||||
"revocation_endpoint": "https://example.com/oauth2/revoke",
|
||||
"scopes_supported": ["openid", "read", "write"],
|
||||
"subject_types_supported": ["public"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
"userinfo_endpoint": "https://example.com/oauth2/userInfo",
|
||||
}
|
||||
)
|
||||
|
||||
def test_oauth_with_jarm(self):
|
||||
"""Should not throw when parsing OAuth metadata that includes JARM response modes."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post",
|
||||
"query.jwt",
|
||||
"fragment.jwt",
|
||||
"form_post.jwt",
|
||||
"jwt",
|
||||
],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_oauth_with_jarm():
|
||||
"""Should not throw when parsing OAuth metadata that includes JARM response modes."""
|
||||
OAuthMetadata.model_validate(
|
||||
{
|
||||
"issuer": "https://example.com",
|
||||
"authorization_endpoint": "https://example.com/oauth2/authorize",
|
||||
"token_endpoint": "https://example.com/oauth2/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code", "token"],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post",
|
||||
"query.jwt",
|
||||
"fragment.jwt",
|
||||
"form_post.jwt",
|
||||
"jwt",
|
||||
],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,109 +4,120 @@ from pydantic import HttpUrl
|
|||
|
||||
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
|
||||
|
||||
|
||||
class TestResourceUrlFromServerUrl:
|
||||
"""Tests for resource_url_from_server_url function."""
|
||||
|
||||
def test_removes_fragment(self):
|
||||
"""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/#fragment") == "https://example.com/"
|
||||
|
||||
def test_preserves_path(self):
|
||||
"""Path should be preserved."""
|
||||
assert (
|
||||
resource_url_from_server_url("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"
|
||||
|
||||
def test_preserves_query(self):
|
||||
"""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/?key=value") == "https://example.com/?key=value"
|
||||
|
||||
def test_preserves_port(self):
|
||||
"""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("http://example.com:8080/") == "http://example.com:8080/"
|
||||
|
||||
def test_lowercase_scheme_and_host(self):
|
||||
"""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("Http://Example.Com:8080/") == "http://example.com:8080/"
|
||||
|
||||
def test_handles_pydantic_urls(self):
|
||||
"""Should handle Pydantic URL types."""
|
||||
url = HttpUrl("https://example.com/path")
|
||||
assert resource_url_from_server_url(url) == "https://example.com/path"
|
||||
# Tests for resource_url_from_server_url function
|
||||
|
||||
|
||||
class TestCheckResourceAllowed:
|
||||
"""Tests for check_resource_allowed function."""
|
||||
def test_resource_url_from_server_url_removes_fragment():
|
||||
"""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/#fragment") == "https://example.com/"
|
||||
|
||||
def test_identical_urls(self):
|
||||
"""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/", "https://example.com/") is True
|
||||
assert check_resource_allowed("https://example.com", "https://example.com") is True
|
||||
|
||||
def test_different_schemes(self):
|
||||
"""Different schemes should not match."""
|
||||
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
|
||||
def test_resource_url_from_server_url_preserves_path():
|
||||
"""Path should be preserved."""
|
||||
assert (
|
||||
resource_url_from_server_url("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"
|
||||
|
||||
def test_different_domains(self):
|
||||
"""Different domains should not match."""
|
||||
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
|
||||
|
||||
def test_different_ports(self):
|
||||
"""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:8080/", "https://example.com:8443/") is False
|
||||
def test_resource_url_from_server_url_preserves_query():
|
||||
"""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/?key=value") == "https://example.com/?key=value"
|
||||
|
||||
def test_hierarchical_matching(self):
|
||||
"""Child paths should match parent paths."""
|
||||
# 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", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True
|
||||
|
||||
# Exact match
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True
|
||||
def test_resource_url_from_server_url_preserves_port():
|
||||
"""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("http://example.com:8080/") == "http://example.com:8080/"
|
||||
|
||||
# Parent cannot use child's token
|
||||
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
|
||||
|
||||
def test_path_boundary_matching(self):
|
||||
"""Path matching should respect boundaries."""
|
||||
# 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/api123", "https://example.com/api") is False
|
||||
def test_resource_url_from_server_url_lowercase_scheme_and_host():
|
||||
"""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("Http://Example.Com:8080/") == "http://example.com:8080/"
|
||||
|
||||
# Should match with trailing slash
|
||||
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
|
||||
|
||||
def test_trailing_slash_handling(self):
|
||||
"""Trailing slashes should be handled correctly."""
|
||||
# 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 False
|
||||
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_resource_url_from_server_url_handles_pydantic_urls():
|
||||
"""Should handle Pydantic URL types."""
|
||||
url = HttpUrl("https://example.com/path")
|
||||
assert resource_url_from_server_url(url) == "https://example.com/path"
|
||||
|
||||
def test_case_insensitive_origin(self):
|
||||
"""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:8080/api", "https://example.com:8080/api") is True
|
||||
|
||||
def test_empty_paths(self):
|
||||
"""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/api", "https://example.com") is True
|
||||
# Tests for check_resource_allowed function
|
||||
|
||||
|
||||
def test_check_resource_allowed_identical_urls():
|
||||
"""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/", "https://example.com/") is True
|
||||
assert check_resource_allowed("https://example.com", "https://example.com") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_schemes():
|
||||
"""Different schemes should not match."""
|
||||
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
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_domains():
|
||||
"""Different domains should not match."""
|
||||
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
|
||||
|
||||
|
||||
def test_check_resource_allowed_different_ports():
|
||||
"""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:8080/", "https://example.com:8443/") is False
|
||||
|
||||
|
||||
def test_check_resource_allowed_hierarchical_matching():
|
||||
"""Child paths should match parent paths."""
|
||||
# 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", "https://example.com/api") is True
|
||||
assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True
|
||||
|
||||
# Exact match
|
||||
assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True
|
||||
|
||||
# Parent cannot use child's token
|
||||
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
|
||||
|
||||
|
||||
def test_check_resource_allowed_path_boundary_matching():
|
||||
"""Path matching should respect boundaries."""
|
||||
# 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/api123", "https://example.com/api") is False
|
||||
|
||||
# Should match with trailing slash
|
||||
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
|
||||
|
||||
|
||||
def test_check_resource_allowed_trailing_slash_handling():
|
||||
"""Trailing slashes should be handled correctly."""
|
||||
# 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 False
|
||||
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_check_resource_allowed_case_insensitive_origin():
|
||||
"""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:8080/api", "https://example.com:8080/api") is True
|
||||
|
||||
|
||||
def test_check_resource_allowed_empty_paths():
|
||||
"""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/api", "https://example.com") is True
|
||||
|
|
|
|||
|
|
@ -6,154 +6,159 @@ from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
|
|||
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
|
||||
|
||||
|
||||
class TestUrlElicitationRequiredError:
|
||||
"""Tests for UrlElicitationRequiredError exception class."""
|
||||
def test_url_elicitation_required_error_create_with_single_elicitation() -> None:
|
||||
"""Test creating error with a single elicitation."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
def test_create_with_single_elicitation(self) -> None:
|
||||
"""Test creating error with a single elicitation."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
assert error.error.code == URL_ELICITATION_REQUIRED
|
||||
assert error.error.message == "URL elicitation required"
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_create_with_multiple_elicitations() -> None:
|
||||
"""Test creating error with multiple elicitations uses plural message."""
|
||||
elicitations = [
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert error.error.code == URL_ELICITATION_REQUIRED
|
||||
assert error.error.message == "URL elicitation required"
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
|
||||
def test_create_with_multiple_elicitations(self) -> None:
|
||||
"""Test creating error with multiple elicitations uses plural message."""
|
||||
elicitations = [
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth 1",
|
||||
url="https://example.com/auth1",
|
||||
elicitation_id="test-1",
|
||||
),
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth 2",
|
||||
url="https://example.com/auth2",
|
||||
elicitation_id="test-2",
|
||||
),
|
||||
]
|
||||
error = UrlElicitationRequiredError(elicitations)
|
||||
|
||||
assert error.error.message == "URL elicitations required" # Plural
|
||||
assert len(error.elicitations) == 2
|
||||
|
||||
def test_custom_message(self) -> None:
|
||||
"""Test creating error with a custom message."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
message="Auth 1",
|
||||
url="https://example.com/auth1",
|
||||
elicitation_id="test-1",
|
||||
),
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation], message="Custom message")
|
||||
message="Auth 2",
|
||||
url="https://example.com/auth2",
|
||||
elicitation_id="test-2",
|
||||
),
|
||||
]
|
||||
error = UrlElicitationRequiredError(elicitations)
|
||||
|
||||
assert error.error.message == "Custom message"
|
||||
assert error.error.message == "URL elicitations required" # Plural
|
||||
assert len(error.elicitations) == 2
|
||||
|
||||
def test_from_error_data(self) -> None:
|
||||
"""Test reconstructing error from ErrorData."""
|
||||
error_data = ErrorData(
|
||||
code=URL_ELICITATION_REQUIRED,
|
||||
message="URL elicitation required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"message": "Auth required",
|
||||
"url": "https://example.com/auth",
|
||||
"elicitationId": "test-123",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
error = UrlElicitationRequiredError.from_error(error_data)
|
||||
def test_url_elicitation_required_error_custom_message() -> None:
|
||||
"""Test creating error with a custom message."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation], message="Custom message")
|
||||
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
assert error.elicitations[0].url == "https://example.com/auth"
|
||||
assert error.error.message == "Custom message"
|
||||
|
||||
def test_from_error_data_wrong_code(self) -> None:
|
||||
"""Test that from_error raises ValueError for wrong error code."""
|
||||
error_data = ErrorData(
|
||||
code=-32600, # Wrong code
|
||||
message="Some other error",
|
||||
data={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected error code"):
|
||||
UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
def test_serialization_roundtrip(self) -> None:
|
||||
"""Test that error can be serialized and reconstructed."""
|
||||
original = UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
def test_url_elicitation_required_error_from_error_data() -> None:
|
||||
"""Test reconstructing error from ErrorData."""
|
||||
error_data = ErrorData(
|
||||
code=URL_ELICITATION_REQUIRED,
|
||||
message="URL elicitation required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"message": "Auth required",
|
||||
"url": "https://example.com/auth",
|
||||
"elicitationId": "test-123",
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate serialization over wire
|
||||
error_data = original.error
|
||||
error = UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
# Reconstruct
|
||||
reconstructed = UrlElicitationRequiredError.from_error(error_data)
|
||||
assert len(error.elicitations) == 1
|
||||
assert error.elicitations[0].elicitation_id == "test-123"
|
||||
assert error.elicitations[0].url == "https://example.com/auth"
|
||||
|
||||
assert reconstructed.elicitations[0].elicitation_id == original.elicitations[0].elicitation_id
|
||||
assert reconstructed.elicitations[0].url == original.elicitations[0].url
|
||||
assert reconstructed.elicitations[0].message == original.elicitations[0].message
|
||||
|
||||
def test_error_data_contains_elicitations(self) -> None:
|
||||
"""Test that error data contains properly serialized elicitations."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Please authenticate",
|
||||
url="https://example.com/oauth",
|
||||
elicitation_id="oauth-flow-1",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
def test_url_elicitation_required_error_from_error_data_wrong_code() -> None:
|
||||
"""Test that from_error raises ValueError for wrong error code."""
|
||||
error_data = ErrorData(
|
||||
code=-32600, # Wrong code
|
||||
message="Some other error",
|
||||
data={},
|
||||
)
|
||||
|
||||
assert error.error.data is not None
|
||||
assert "elicitations" in error.error.data
|
||||
elicit_data = error.error.data["elicitations"][0]
|
||||
assert elicit_data["mode"] == "url"
|
||||
assert elicit_data["message"] == "Please authenticate"
|
||||
assert elicit_data["url"] == "https://example.com/oauth"
|
||||
assert elicit_data["elicitationId"] == "oauth-flow-1"
|
||||
with pytest.raises(ValueError, match="Expected error code"):
|
||||
UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
def test_inherits_from_mcp_error(self) -> None:
|
||||
"""Test that UrlElicitationRequiredError inherits from McpError."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert isinstance(error, McpError)
|
||||
assert isinstance(error, Exception)
|
||||
def test_url_elicitation_required_error_serialization_roundtrip() -> None:
|
||||
"""Test that error can be serialized and reconstructed."""
|
||||
original = UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def test_exception_message(self) -> None:
|
||||
"""Test that exception message is set correctly."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
# Simulate serialization over wire
|
||||
error_data = original.error
|
||||
|
||||
# The exception's string representation should match the message
|
||||
assert str(error) == "URL elicitation required"
|
||||
# Reconstruct
|
||||
reconstructed = UrlElicitationRequiredError.from_error(error_data)
|
||||
|
||||
assert reconstructed.elicitations[0].elicitation_id == original.elicitations[0].elicitation_id
|
||||
assert reconstructed.elicitations[0].url == original.elicitations[0].url
|
||||
assert reconstructed.elicitations[0].message == original.elicitations[0].message
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_data_contains_elicitations() -> None:
|
||||
"""Test that error data contains properly serialized elicitations."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Please authenticate",
|
||||
url="https://example.com/oauth",
|
||||
elicitation_id="oauth-flow-1",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert error.error.data is not None
|
||||
assert "elicitations" in error.error.data
|
||||
elicit_data = error.error.data["elicitations"][0]
|
||||
assert elicit_data["mode"] == "url"
|
||||
assert elicit_data["message"] == "Please authenticate"
|
||||
assert elicit_data["url"] == "https://example.com/oauth"
|
||||
assert elicit_data["elicitationId"] == "oauth-flow-1"
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_inherits_from_mcp_error() -> None:
|
||||
"""Test that UrlElicitationRequiredError inherits from McpError."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
assert isinstance(error, McpError)
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_exception_message() -> None:
|
||||
"""Test that exception message is set correctly."""
|
||||
elicitation = ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Auth required",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
error = UrlElicitationRequiredError([elicitation])
|
||||
|
||||
# The exception's string representation should match the message
|
||||
assert str(error) == "URL elicitation required"
|
||||
|
|
|
|||
|
|
@ -10,190 +10,199 @@ from mcp.shared.tool_name_validation import (
|
|||
validate_tool_name,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateToolName:
|
||||
"""Tests for validate_tool_name function."""
|
||||
|
||||
class TestValidNames:
|
||||
"""Test cases for valid tool names."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name",
|
||||
[
|
||||
"getUser",
|
||||
"get_user_profile",
|
||||
"user-profile-update",
|
||||
"admin.tools.list",
|
||||
"DATA_EXPORT_v2.1",
|
||||
"a",
|
||||
"a" * 128,
|
||||
],
|
||||
ids=[
|
||||
"simple_alphanumeric",
|
||||
"with_underscores",
|
||||
"with_dashes",
|
||||
"with_dots",
|
||||
"mixed_characters",
|
||||
"single_character",
|
||||
"max_length_128",
|
||||
],
|
||||
)
|
||||
def test_accepts_valid_names(self, tool_name: str) -> None:
|
||||
"""Valid tool names should pass validation with no warnings."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is True
|
||||
assert result.warnings == []
|
||||
|
||||
class TestInvalidNames:
|
||||
"""Test cases for invalid tool names."""
|
||||
|
||||
def test_rejects_empty_name(self) -> None:
|
||||
"""Empty names should be rejected."""
|
||||
result = validate_tool_name("")
|
||||
assert result.is_valid is False
|
||||
assert "Tool name cannot be empty" in result.warnings
|
||||
|
||||
def test_rejects_name_exceeding_max_length(self) -> None:
|
||||
"""Names exceeding 128 characters should be rejected."""
|
||||
result = validate_tool_name("a" * 129)
|
||||
assert result.is_valid is False
|
||||
assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,expected_char",
|
||||
[
|
||||
("get user profile", "' '"),
|
||||
("get,user,profile", "','"),
|
||||
("user/profile/update", "'/'"),
|
||||
("user@domain.com", "'@'"),
|
||||
],
|
||||
ids=[
|
||||
"with_spaces",
|
||||
"with_commas",
|
||||
"with_slashes",
|
||||
"with_at_symbol",
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None:
|
||||
"""Names with invalid characters should be rejected."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is False
|
||||
assert any("invalid characters" in w and expected_char in w for w in result.warnings)
|
||||
|
||||
def test_rejects_multiple_invalid_chars(self) -> None:
|
||||
"""Names with multiple invalid chars should list all of them."""
|
||||
result = validate_tool_name("user name@domain,com")
|
||||
assert result.is_valid is False
|
||||
warning = next(w for w in result.warnings if "invalid characters" in w)
|
||||
assert "' '" in warning
|
||||
assert "'@'" in warning
|
||||
assert "','" in warning
|
||||
|
||||
def test_rejects_unicode_characters(self) -> None:
|
||||
"""Names with unicode characters should be rejected."""
|
||||
result = validate_tool_name("user-\u00f1ame") # n with tilde
|
||||
assert result.is_valid is False
|
||||
|
||||
class TestWarningsForProblematicPatterns:
|
||||
"""Test cases for valid names that generate warnings."""
|
||||
|
||||
def test_warns_on_leading_dash(self) -> None:
|
||||
"""Names starting with dash should generate warning but be valid."""
|
||||
result = validate_tool_name("-get-user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
def test_warns_on_trailing_dash(self) -> None:
|
||||
"""Names ending with dash should generate warning but be valid."""
|
||||
result = validate_tool_name("get-user-")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
def test_warns_on_leading_dot(self) -> None:
|
||||
"""Names starting with dot should generate warning but be valid."""
|
||||
result = validate_tool_name(".get.user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
|
||||
def test_warns_on_trailing_dot(self) -> None:
|
||||
"""Names ending with dot should generate warning but be valid."""
|
||||
result = validate_tool_name("get.user.")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
# Tests for validate_tool_name function - valid names
|
||||
|
||||
|
||||
class TestIssueToolNameWarning:
|
||||
"""Tests for issue_tool_name_warning function."""
|
||||
|
||||
def test_logs_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Warnings should be logged at WARNING level."""
|
||||
warnings = ["Warning 1", "Warning 2"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", warnings)
|
||||
|
||||
assert 'Tool name validation warning for "test-tool"' in caplog.text
|
||||
assert "- Warning 1" in caplog.text
|
||||
assert "- Warning 2" in caplog.text
|
||||
assert "Tool registration will proceed" in caplog.text
|
||||
assert "SEP-986" in caplog.text
|
||||
|
||||
def test_no_logging_for_empty_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Empty warnings list should not produce any log output."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", [])
|
||||
|
||||
assert caplog.text == ""
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name",
|
||||
[
|
||||
"getUser",
|
||||
"get_user_profile",
|
||||
"user-profile-update",
|
||||
"admin.tools.list",
|
||||
"DATA_EXPORT_v2.1",
|
||||
"a",
|
||||
"a" * 128,
|
||||
],
|
||||
ids=[
|
||||
"simple_alphanumeric",
|
||||
"with_underscores",
|
||||
"with_dashes",
|
||||
"with_dots",
|
||||
"mixed_characters",
|
||||
"single_character",
|
||||
"max_length_128",
|
||||
],
|
||||
)
|
||||
def test_validate_tool_name_accepts_valid_names(tool_name: str) -> None:
|
||||
"""Valid tool names should pass validation with no warnings."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is True
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
class TestValidateAndWarnToolName:
|
||||
"""Tests for validate_and_warn_tool_name function."""
|
||||
|
||||
def test_returns_true_for_valid_name(self) -> None:
|
||||
"""Valid names should return True."""
|
||||
assert validate_and_warn_tool_name("valid-tool-name") is True
|
||||
|
||||
def test_returns_false_for_invalid_name(self) -> None:
|
||||
"""Invalid names should return 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("invalid name") is False
|
||||
|
||||
def test_logs_warnings_for_invalid_name(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Invalid names should trigger warning logs."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
validate_and_warn_tool_name("invalid name")
|
||||
|
||||
assert "Tool name validation warning" in caplog.text
|
||||
|
||||
def test_no_warnings_for_clean_valid_name(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Clean valid names should not produce any log output."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = validate_and_warn_tool_name("clean-tool-name")
|
||||
|
||||
assert result is True
|
||||
assert caplog.text == ""
|
||||
# Tests for validate_tool_name function - invalid names
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and robustness."""
|
||||
def test_validate_tool_name_rejects_empty_name() -> None:
|
||||
"""Empty names should be rejected."""
|
||||
result = validate_tool_name("")
|
||||
assert result.is_valid is False
|
||||
assert "Tool name cannot be empty" in result.warnings
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,is_valid,expected_warning_fragment",
|
||||
[
|
||||
("...", True, "starts or ends with a dot"),
|
||||
("---", True, "starts or ends with a dash"),
|
||||
("///", False, "invalid characters"),
|
||||
("user@name123", False, "invalid characters"),
|
||||
],
|
||||
ids=[
|
||||
"only_dots",
|
||||
"only_dashes",
|
||||
"only_slashes",
|
||||
"mixed_valid_invalid",
|
||||
],
|
||||
)
|
||||
def test_edge_cases(self, tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None:
|
||||
"""Various edge cases should be handled correctly."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is is_valid
|
||||
assert any(expected_warning_fragment in w for w in result.warnings)
|
||||
|
||||
def test_validate_tool_name_rejects_name_exceeding_max_length() -> None:
|
||||
"""Names exceeding 128 characters should be rejected."""
|
||||
result = validate_tool_name("a" * 129)
|
||||
assert result.is_valid is False
|
||||
assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,expected_char",
|
||||
[
|
||||
("get user profile", "' '"),
|
||||
("get,user,profile", "','"),
|
||||
("user/profile/update", "'/'"),
|
||||
("user@domain.com", "'@'"),
|
||||
],
|
||||
ids=[
|
||||
"with_spaces",
|
||||
"with_commas",
|
||||
"with_slashes",
|
||||
"with_at_symbol",
|
||||
],
|
||||
)
|
||||
def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_char: str) -> None:
|
||||
"""Names with invalid characters should be rejected."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is False
|
||||
assert any("invalid characters" in w and expected_char in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_multiple_invalid_chars() -> None:
|
||||
"""Names with multiple invalid chars should list all of them."""
|
||||
result = validate_tool_name("user name@domain,com")
|
||||
assert result.is_valid is False
|
||||
warning = next(w for w in result.warnings if "invalid characters" in w)
|
||||
assert "' '" in warning
|
||||
assert "'@'" in warning
|
||||
assert "','" in warning
|
||||
|
||||
|
||||
def test_validate_tool_name_rejects_unicode_characters() -> None:
|
||||
"""Names with unicode characters should be rejected."""
|
||||
result = validate_tool_name("user-\u00f1ame") # n with tilde
|
||||
assert result.is_valid is False
|
||||
|
||||
|
||||
# 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."""
|
||||
result = validate_tool_name("-get-user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_trailing_dash() -> None:
|
||||
"""Names ending with dash should generate warning but be valid."""
|
||||
result = validate_tool_name("get-user-")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_leading_dot() -> None:
|
||||
"""Names starting with dot should generate warning but be valid."""
|
||||
result = validate_tool_name(".get.user")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_validate_tool_name_warns_on_trailing_dot() -> None:
|
||||
"""Names ending with dot should generate warning but be valid."""
|
||||
result = validate_tool_name("get.user.")
|
||||
assert result.is_valid is True
|
||||
assert any("starts or ends with a dot" in w for w in result.warnings)
|
||||
|
||||
|
||||
# Tests for issue_tool_name_warning function
|
||||
|
||||
|
||||
def test_issue_tool_name_warning_logs_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Warnings should be logged at WARNING level."""
|
||||
warnings = ["Warning 1", "Warning 2"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", warnings)
|
||||
|
||||
assert 'Tool name validation warning for "test-tool"' in caplog.text
|
||||
assert "- Warning 1" in caplog.text
|
||||
assert "- Warning 2" in caplog.text
|
||||
assert "Tool registration will proceed" in caplog.text
|
||||
assert "SEP-986" in caplog.text
|
||||
|
||||
|
||||
def test_issue_tool_name_warning_no_logging_for_empty_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Empty warnings list should not produce any log output."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
issue_tool_name_warning("test-tool", [])
|
||||
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
# Tests for validate_and_warn_tool_name function
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_returns_true_for_valid_name() -> None:
|
||||
"""Valid names should return True."""
|
||||
assert validate_and_warn_tool_name("valid-tool-name") is True
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_returns_false_for_invalid_name() -> None:
|
||||
"""Invalid names should return 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("invalid name") is False
|
||||
|
||||
|
||||
def test_validate_and_warn_tool_name_logs_warnings_for_invalid_name(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Invalid names should trigger warning logs."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
validate_and_warn_tool_name("invalid name")
|
||||
|
||||
assert "Tool name validation warning" in caplog.text
|
||||
|
||||
|
||||
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."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = validate_and_warn_tool_name("clean-tool-name")
|
||||
|
||||
assert result is True
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
# Tests for edge cases
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name,is_valid,expected_warning_fragment",
|
||||
[
|
||||
("...", True, "starts or ends with a dot"),
|
||||
("---", True, "starts or ends with a dash"),
|
||||
("///", False, "invalid characters"),
|
||||
("user@name123", False, "invalid characters"),
|
||||
],
|
||||
ids=[
|
||||
"only_dots",
|
||||
"only_dashes",
|
||||
"only_slashes",
|
||||
"mixed_valid_invalid",
|
||||
],
|
||||
)
|
||||
def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None:
|
||||
"""Various edge cases should be handled correctly."""
|
||||
result = validate_tool_name(tool_name)
|
||||
assert result.is_valid is is_valid
|
||||
assert any(expected_warning_fragment in w for w in result.warnings)
|
||||
|
|
|
|||
Loading…
Reference in a new issue