Merge main into sep-1330-enum-schemas

This commit is contained in:
Jeremiah Lowin 2025-12-04 11:33:57 -05:00
commit 5edcee4513
8 changed files with 279 additions and 91 deletions

View file

@ -4,7 +4,7 @@ from typing import Literal
import pytest
from mcp.types import ElicitRequestParams
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from fastmcp import Context, FastMCP
@ -939,3 +939,134 @@ async def test_validation_rejects_primitive_arrays():
}
with pytest.raises(TypeError, match="arrays are only allowed"):
validate_elicitation_json_schema(schema)
class TestElicitationDefaults:
"""Test suite for default values in elicitation schemas."""
def test_string_default_preserved(self):
"""Test that string defaults are preserved in the schema."""
class Model(BaseModel):
email: str = Field(default="[email protected]")
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "email" in props
assert "default" in props["email"]
assert props["email"]["default"] == "[email protected]"
assert props["email"]["type"] == "string"
def test_integer_default_preserved(self):
"""Test that integer defaults are preserved in the schema."""
class Model(BaseModel):
count: int = Field(default=50)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "count" in props
assert "default" in props["count"]
assert props["count"]["default"] == 50
assert props["count"]["type"] == "integer"
def test_number_default_preserved(self):
"""Test that number defaults are preserved in the schema."""
class Model(BaseModel):
price: float = Field(default=3.14)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "price" in props
assert "default" in props["price"]
assert props["price"]["default"] == 3.14
assert props["price"]["type"] == "number"
def test_boolean_default_preserved(self):
"""Test that boolean defaults are preserved in the schema."""
class Model(BaseModel):
enabled: bool = Field(default=False)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "enabled" in props
assert "default" in props["enabled"]
assert props["enabled"]["default"] is False
assert props["enabled"]["type"] == "boolean"
def test_enum_default_preserved(self):
"""Test that enum defaults are preserved in the schema."""
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class Model(BaseModel):
choice: Priority = Field(default=Priority.MEDIUM)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "choice" in props
assert "default" in props["choice"]
assert props["choice"]["default"] == "medium"
assert "enum" in props["choice"]
assert props["choice"]["type"] == "string"
def test_all_defaults_preserved_together(self):
"""Test that all default types are preserved when used together."""
class Priority(Enum):
A = "A"
B = "B"
class Model(BaseModel):
string_field: str = Field(default="[email protected]")
integer_field: int = Field(default=50)
number_field: float = Field(default=3.14)
boolean_field: bool = Field(default=False)
enum_field: Priority = Field(default=Priority.A)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert props["string_field"]["default"] == "[email protected]"
assert props["integer_field"]["default"] == 50
assert props["number_field"]["default"] == 3.14
assert props["boolean_field"]["default"] is False
assert props["enum_field"]["default"] == "A"
def test_mixed_defaults_and_required(self):
"""Test that fields with defaults are not in required list."""
class Model(BaseModel):
required_field: str = Field(description="Required field")
optional_with_default: int = Field(default=42)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
required = schema.get("required", [])
assert "required_field" in required
assert "optional_with_default" not in required
assert props["optional_with_default"]["default"] == 42
def test_compress_schema_preserves_defaults(self):
"""Test that compress_schema() doesn't strip default values."""
class Model(BaseModel):
string_field: str = Field(default="test")
integer_field: int = Field(default=42)
schema = get_elicitation_schema(Model)
props = schema.get("properties", {})
assert "default" in props["string_field"]
assert "default" in props["integer_field"]

View file

@ -3,9 +3,37 @@ import os
import pytest
def _is_rate_limit_error(excinfo) -> bool:
"""Check if an exception indicates a rate limit error from GitHub API."""
if excinfo is None:
return False
exc = excinfo.value
exc_type = excinfo.typename
exc_str = str(exc).lower()
# BrokenResourceError typically indicates connection closed due to rate limit
if exc_type == "BrokenResourceError":
return True
# httpx.HTTPStatusError with 429 status
if exc_type == "HTTPStatusError":
try:
if hasattr(exc, "response") and exc.response.status_code == 429:
return True
except Exception:
pass
# Check for rate limit indicators in exception message
if "429" in exc_str or "rate limit" in exc_str or "too many requests" in exc_str:
return True
return False
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""Convert BrokenResourceError failures to skips only for GitHub rate limits"""
"""Convert rate limit failures to skips for GitHub integration tests."""
outcome = yield
report = outcome.get_result()
@ -14,12 +42,9 @@ def pytest_runtest_makereport(item, call):
report.when == "call"
and report.failed
and not hasattr(report, "wasxfail")
and call.excinfo
and call.excinfo.typename == "BrokenResourceError"
and item.module.__name__ == "tests.integration_tests.test_github_mcp_remote"
and _is_rate_limit_error(call.excinfo)
):
# Only skip if the test is in the GitHub remote test module
# This prevents catching unrelated BrokenResourceErrors
report.outcome = "skipped"
report.longrepr = (
os.path.abspath(__file__),

View file

@ -34,7 +34,6 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]:
)
@pytest.mark.flaky(retries=2, delay=1)
class TestGithubMCPRemote:
async def test_connect_disconnect(
self,
@ -94,7 +93,7 @@ class TestGithubMCPRemote:
"""Test calling a non-existing tool"""
async with streamable_http_client:
assert streamable_http_client.is_connected()
with pytest.raises(McpError, match="tool not found"):
with pytest.raises(McpError, match=r"unknown tool|tool not found"):
await streamable_http_client.call_tool("foo")
async def test_call_tool_list_commits(

View file

@ -1254,7 +1254,7 @@ class TestTokenHandlerErrorTransformation:
from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler
from fastmcp.server.auth.oauth_proxy import TokenHandler
from fastmcp.server.auth.auth import TokenHandler
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
@ -1283,7 +1283,7 @@ class TestTokenHandlerErrorTransformation:
"""Test that grant type authorization errors stay as unauthorized_client with 400."""
from mcp.server.auth.handlers.token import TokenErrorResponse
from fastmcp.server.auth.oauth_proxy import TokenHandler
from fastmcp.server.auth.auth import TokenHandler
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
@ -1303,7 +1303,7 @@ class TestTokenHandlerErrorTransformation:
"""Test that other error types pass through unchanged."""
from mcp.server.auth.handlers.token import TokenErrorResponse
from fastmcp.server.auth.oauth_proxy import TokenHandler
from fastmcp.server.auth.auth import TokenHandler
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())