mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Merge main into sep-1330-enum-schemas
This commit is contained in:
commit
5edcee4513
8 changed files with 279 additions and 91 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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__),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue