mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Merge branch 'config-dicts' of https://github.com/jlowin/fastmcp into config-dicts
This commit is contained in:
commit
885f2fc4a1
14 changed files with 572 additions and 82 deletions
4
.github/workflows/run-tests.yml
vendored
4
.github/workflows/run-tests.yml
vendored
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install FastMCP
|
||||
run: uv sync --dev --locked
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
run: uv run pytest tests
|
||||
|
|
|
|||
|
|
@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi
|
|||
# Simplified version of the actual mapping rules
|
||||
DEFAULT_ROUTE_MAPPINGS = [
|
||||
# GET with path parameters -> ResourceTemplate
|
||||
RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
|
||||
route_type=RouteType.RESOURCE_TEMPLATE),
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*\{.*\}.*",
|
||||
route_type=RouteType.RESOURCE_TEMPLATE,
|
||||
),
|
||||
|
||||
# GET without path parameters -> Resource
|
||||
RouteMap(methods=["GET"], pattern=r".*",
|
||||
route_type=RouteType.RESOURCE),
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*",
|
||||
route_type=RouteType.RESOURCE,
|
||||
),
|
||||
|
||||
# All other methods -> Tool
|
||||
RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
pattern=r".*", route_type=RouteType.TOOL),
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
route_type=RouteType.TOOL,
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Route Maps
|
||||
|
||||
Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
|
||||
|
|
@ -97,6 +105,35 @@ mcp = await FastMCP.from_openapi(
|
|||
)
|
||||
```
|
||||
|
||||
|
||||
### All Routes as Tools
|
||||
|
||||
When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool:
|
||||
|
||||
```python
|
||||
# Make all endpoints tools, regardless of HTTP method
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
all_routes_as_tools=True
|
||||
)
|
||||
```
|
||||
|
||||
This is equivalent to defining a single route map that matches all routes:
|
||||
|
||||
```python
|
||||
# Same effect as all_routes_as_tools=True
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
route_maps=[
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. FastMCP parses your OpenAPI spec to extract routes and schemas
|
||||
|
|
|
|||
|
|
@ -306,7 +306,29 @@ def create_streamable_http_app(
|
|||
async def handle_streamable_http(
|
||||
scope: Scope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
await session_manager.handle_request(scope, receive, send)
|
||||
try:
|
||||
await session_manager.handle_request(scope, receive, send)
|
||||
except RuntimeError as e:
|
||||
if str(e) == "Task group is not initialized. Make sure to use run().":
|
||||
logger.error(
|
||||
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
||||
)
|
||||
new_error_message = (
|
||||
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
||||
"This commonly occurs when the FastMCP application's lifespan is not "
|
||||
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
||||
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
||||
"parent app's constructor, where `mcp_app` is the application instance "
|
||||
"returned by `fastmcp_instance.http_app()`. \\n"
|
||||
"For more details, see the FastMCP ASGI integration documentation: "
|
||||
"https://gofastmcp.com/deployment/asgi"
|
||||
)
|
||||
# Raise a new RuntimeError that includes the original error's message
|
||||
# for full context, but leads with the more helpful guidance.
|
||||
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
||||
else:
|
||||
# Re-raise other RuntimeErrors if they don't match the specific message
|
||||
raise
|
||||
|
||||
# Get auth middleware and routes
|
||||
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class RouteType(enum.Enum):
|
|||
class RouteMap:
|
||||
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
||||
|
||||
methods: list[HttpMethod]
|
||||
methods: list[HttpMethod] | Literal["*"]
|
||||
pattern: Pattern[str] | str
|
||||
route_type: RouteType
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ def _determine_route_type(
|
|||
# Check mappings in priority order (first match wins)
|
||||
for route_map in mappings:
|
||||
# Check if the HTTP method matches
|
||||
if route.method in route_map.methods:
|
||||
if route_map.methods == "*" or route.method in route_map.methods:
|
||||
# Handle both string patterns and compiled Pattern objects
|
||||
if isinstance(route_map.pattern, Pattern):
|
||||
pattern_matches = route_map.pattern.search(route.path)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger
|
|||
if TYPE_CHECKING:
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import ClientTransport
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
|
||||
from fastmcp.server.proxy import FastMCPProxy
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
@classmethod
|
||||
def from_openapi(
|
||||
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
|
||||
cls,
|
||||
openapi_spec: dict[str, Any],
|
||||
client: httpx.AsyncClient,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
all_routes_as_tools: bool = False,
|
||||
**settings: Any,
|
||||
) -> FastMCPOpenAPI:
|
||||
"""
|
||||
Create a FastMCP server from an OpenAPI specification.
|
||||
"""
|
||||
from .openapi import FastMCPOpenAPI
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
|
||||
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
|
||||
if all_routes_as_tools and route_maps:
|
||||
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
||||
|
||||
elif all_routes_as_tools:
|
||||
route_maps = [
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
route_type=RouteType.TOOL,
|
||||
)
|
||||
]
|
||||
|
||||
return FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
**settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_fastapi(
|
||||
cls, app: Any, name: str | None = None, **settings: Any
|
||||
cls,
|
||||
app: Any,
|
||||
name: str | None = None,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
all_routes_as_tools: bool = False,
|
||||
**settings: Any,
|
||||
) -> FastMCPOpenAPI:
|
||||
"""
|
||||
Create a FastMCP server from a FastAPI application.
|
||||
"""
|
||||
|
||||
from .openapi import FastMCPOpenAPI
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
|
||||
if all_routes_as_tools and route_maps:
|
||||
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
||||
|
||||
elif all_routes_as_tools:
|
||||
route_maps = [
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
||||
]
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
||||
|
|
@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name = name or app.title
|
||||
|
||||
return FastMCPOpenAPI(
|
||||
openapi_spec=app.openapi(), client=client, name=name, **settings
|
||||
openapi_spec=app.openapi(),
|
||||
client=client,
|
||||
name=name,
|
||||
route_maps=route_maps,
|
||||
**settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|||
|
||||
@contextmanager
|
||||
def run_server_in_process(
|
||||
server_fn: Callable[[str, int], None], *args
|
||||
server_fn: Callable[..., None], *args
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Context manager that runs a Starlette app in a separate process and returns the
|
||||
|
|
@ -109,7 +109,11 @@ def run_server_in_process(
|
|||
|
||||
yield f"http://{host}:{port}"
|
||||
|
||||
proc.kill()
|
||||
proc.join(timeout=2)
|
||||
proc.terminate()
|
||||
proc.join(timeout=5)
|
||||
if proc.is_alive():
|
||||
raise RuntimeError("Server process failed to terminate")
|
||||
# If it's still alive, then force kill it
|
||||
proc.kill()
|
||||
proc.join(timeout=2)
|
||||
if proc.is_alive():
|
||||
raise RuntimeError("Server process failed to terminate even after kill")
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ ERROR_TOOL_NAME = "error_tool"
|
|||
NO_RETURN_TOOL_NAME = "no_return_tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
|
||||
"""Test single successful call via call_tool_bulk using echo_tool."""
|
||||
tool_arguments = [{"arg1": "value1"}]
|
||||
|
|
@ -98,7 +97,6 @@ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
|
|||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
|
||||
"""Test multiple successful calls via call_tool_bulk using echo_tool."""
|
||||
tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
|
||||
|
|
@ -110,7 +108,6 @@ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller)
|
|||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
|
||||
"""Test call_tool_bulk stops on first error using error_tool."""
|
||||
tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
|
||||
|
|
@ -125,7 +122,6 @@ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
|
|||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
|
||||
"""Test call_tool_bulk continues on error using error_tool and echo_tool."""
|
||||
tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
|
||||
|
|
@ -148,7 +144,6 @@ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
|
|||
assert success_result == expected_success_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
|
||||
"""Test single successful call via call_tools_bulk using echo_tool."""
|
||||
tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
|
||||
|
|
@ -161,7 +156,6 @@ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
|
|||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
|
||||
"""Test multiple successful calls via call_tools_bulk with different tools."""
|
||||
tool_calls = [
|
||||
|
|
@ -181,7 +175,6 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller
|
|||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
|
||||
"""Test call_tools_bulk stops on first error using error_tool."""
|
||||
tool_calls = [
|
||||
|
|
@ -199,7 +192,6 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
|
|||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
|
||||
"""Test call_tools_bulk continues on error using error_tool and echo_tool."""
|
||||
tool_calls = [
|
||||
|
|
|
|||
|
|
@ -341,7 +341,6 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
|
|||
|
||||
|
||||
class TestAuthEndpoints:
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
|
||||
"""Test the OAuth 2.0 metadata endpoint."""
|
||||
print("Sending request to metadata endpoint")
|
||||
|
|
@ -370,7 +369,6 @@ class TestAuthEndpoints:
|
|||
]
|
||||
assert metadata["service_documentation"] == "https://docs.example.com/"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
|
||||
"""Test token endpoint error - validation error."""
|
||||
# Missing required fields
|
||||
|
|
@ -387,7 +385,6 @@ class TestAuthEndpoints:
|
|||
"error_description" in error_response
|
||||
) # Contains validation error messages
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_auth_code(
|
||||
self, test_client, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
@ -414,7 +411,6 @@ class TestAuthEndpoints:
|
|||
"authorization code does not exist" in error_response["error_description"]
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_expired_auth_code(
|
||||
self,
|
||||
test_client,
|
||||
|
|
@ -459,7 +455,6 @@ class TestAuthEndpoints:
|
|||
"authorization code has expired" in error_response["error_description"]
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"registered_client",
|
||||
[
|
||||
|
|
@ -494,7 +489,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_request"
|
||||
assert "redirect_uri did not match" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_code_verifier_mismatch(
|
||||
self, test_client, registered_client, auth_code
|
||||
):
|
||||
|
|
@ -517,7 +511,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_grant"
|
||||
assert "incorrect code_verifier" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_refresh_token(self, test_client, registered_client):
|
||||
"""Test token endpoint error - refresh token does not exist."""
|
||||
# Try to use a non-existent refresh token
|
||||
|
|
@ -535,7 +528,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_grant"
|
||||
assert "refresh token does not exist" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_expired_refresh_token(
|
||||
self,
|
||||
test_client,
|
||||
|
|
@ -586,7 +578,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_grant"
|
||||
assert "refresh token has expired" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_scope(
|
||||
self, test_client, registered_client, auth_code, pkce_challenge
|
||||
):
|
||||
|
|
@ -624,7 +615,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_scope"
|
||||
assert "cannot request scope" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
):
|
||||
|
|
@ -652,7 +642,6 @@ class TestAuthEndpoints:
|
|||
# client_info["client_id"]
|
||||
# ) is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_missing_required_fields(
|
||||
self, test_client: httpx.AsyncClient
|
||||
):
|
||||
|
|
@ -673,7 +662,6 @@ class TestAuthEndpoints:
|
|||
assert error_data["error"] == "invalid_client_metadata"
|
||||
assert error_data["error_description"] == "redirect_uris: Field required"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_invalid_uri(
|
||||
self, test_client: httpx.AsyncClient
|
||||
):
|
||||
|
|
@ -696,7 +684,6 @@ class TestAuthEndpoints:
|
|||
"redirect_uris.0: Input should be a valid URL, relative URL without a base"
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_empty_redirect_uris(
|
||||
self, test_client: httpx.AsyncClient
|
||||
):
|
||||
|
|
@ -719,7 +706,6 @@ class TestAuthEndpoints:
|
|||
== "redirect_uris: List should have at least 1 item after validation, not 0"
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_form_post(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
|
|
@ -763,7 +749,6 @@ class TestAuthEndpoints:
|
|||
assert "code" in query_params
|
||||
assert query_params["state"][0] == "test_form_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorization_get(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
|
|
@ -878,7 +863,6 @@ class TestAuthEndpoints:
|
|||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_invalid_token(self, test_client, registered_client):
|
||||
"""Test revoking an invalid token."""
|
||||
response = await test_client.post(
|
||||
|
|
@ -892,7 +876,6 @@ class TestAuthEndpoints:
|
|||
# per RFC, this should return 200 even if the token is invalid
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_with_malformed_token(self, test_client, registered_client):
|
||||
response = await test_client.post(
|
||||
"/revoke",
|
||||
|
|
@ -908,7 +891,6 @@ class TestAuthEndpoints:
|
|||
assert error_response["error"] == "invalid_request"
|
||||
assert "token_type_hint" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_disallowed_scopes(
|
||||
self, test_client: httpx.AsyncClient
|
||||
):
|
||||
|
|
@ -930,7 +912,6 @@ class TestAuthEndpoints:
|
|||
assert "scope" in error_data["error_description"]
|
||||
assert "admin" in error_data["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_default_scopes(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
):
|
||||
|
|
@ -959,7 +940,6 @@ class TestAuthEndpoints:
|
|||
# Check that default scopes were applied
|
||||
assert registered_client.scope == "read write"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_invalid_grant_type(
|
||||
self, test_client: httpx.AsyncClient
|
||||
):
|
||||
|
|
@ -986,7 +966,6 @@ class TestAuthEndpoints:
|
|||
class TestAuthorizeEndpointErrors:
|
||||
"""Test error handling in the OAuth authorization endpoint."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_client_id(
|
||||
self, test_client: httpx.AsyncClient, pkce_challenge
|
||||
):
|
||||
|
|
@ -1012,7 +991,6 @@ class TestAuthorizeEndpointErrors:
|
|||
# The response should include an error message about missing client_id
|
||||
assert "client_id" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_client_id(
|
||||
self, test_client: httpx.AsyncClient, pkce_challenge
|
||||
):
|
||||
|
|
@ -1038,7 +1016,6 @@ class TestAuthorizeEndpointErrors:
|
|||
# The response should include an error message about invalid client_id
|
||||
assert "client" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
@ -1064,7 +1041,6 @@ class TestAuthorizeEndpointErrors:
|
|||
redirect_url = response.headers["location"]
|
||||
assert redirect_url.startswith("https://client.example.com/callback")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
@ -1092,7 +1068,6 @@ class TestAuthorizeEndpointErrors:
|
|||
# The response should include an error message about redirect_uri mismatch
|
||||
assert "redirect" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"registered_client",
|
||||
[
|
||||
|
|
@ -1130,7 +1105,6 @@ class TestAuthorizeEndpointErrors:
|
|||
# The response should include an error message about missing redirect_uri
|
||||
assert "redirect_uri" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_unsupported_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
@ -1164,7 +1138,6 @@ class TestAuthorizeEndpointErrors:
|
|||
assert "state" in query_params
|
||||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
@ -1197,7 +1170,6 @@ class TestAuthorizeEndpointErrors:
|
|||
assert "state" in query_params
|
||||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_pkce_challenge(
|
||||
self, test_client: httpx.AsyncClient, registered_client
|
||||
):
|
||||
|
|
@ -1228,7 +1200,6 @@ class TestAuthorizeEndpointErrors:
|
|||
assert "state" in query_params
|
||||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_scope(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from collections.abc import Callable
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
|
@ -51,7 +50,6 @@ async def endpoint_handler(request: Request):
|
|||
return JSONResponse({"message": "Hello, world!"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_app_with_custom_middleware():
|
||||
"""Test that custom middleware works with SSE app."""
|
||||
server = FastMCP(name="TestServer")
|
||||
|
|
@ -82,7 +80,6 @@ async def test_sse_app_with_custom_middleware():
|
|||
assert response.headers["X-Custom-Header"] == "test-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_app_with_custom_middleware():
|
||||
"""Test that custom middleware works with StreamableHTTP app."""
|
||||
server = FastMCP(name="TestServer")
|
||||
|
|
@ -113,7 +110,6 @@ async def test_streamable_http_app_with_custom_middleware():
|
|||
assert response.headers["X-Custom-Header"] == "test-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sse_app_with_custom_middleware():
|
||||
"""Test that custom middleware works with create_sse_app function."""
|
||||
server = FastMCP(name="TestServer")
|
||||
|
|
@ -149,7 +145,6 @@ async def test_create_sse_app_with_custom_middleware():
|
|||
assert data["state"]["modified_by"] == "middleware"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_streamable_http_app_with_custom_middleware():
|
||||
"""Test that custom middleware works with create_streamable_http_app function."""
|
||||
server = FastMCP(name="TestServer")
|
||||
|
|
@ -184,7 +179,6 @@ async def test_create_streamable_http_app_with_custom_middleware():
|
|||
assert data["state"]["modified_by"] == "middleware"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_middleware_ordering():
|
||||
"""Test that multiple middleware are applied in the correct order."""
|
||||
server = FastMCP(name="TestServer")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
import httpx
|
||||
import uvicorn
|
||||
from mcp.server.lowlevel.server import NotificationOptions, Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.shared.message import SessionMessage
|
||||
|
|
@ -17,11 +22,13 @@ from mcp.types import (
|
|||
JSONRPCRequest,
|
||||
)
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.utilities.tests import run_server_in_process
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lowlevel_server_lifespan():
|
||||
"""Test that lifespan works in low-level server."""
|
||||
|
||||
|
|
@ -132,7 +139,6 @@ async def test_lowlevel_server_lifespan():
|
|||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fastmcp_server_lifespan():
|
||||
"""Test that lifespan works in FastMCP server."""
|
||||
|
||||
|
|
@ -234,3 +240,157 @@ async def test_fastmcp_server_lifespan():
|
|||
|
||||
# Cancel server task
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
def run_server_with_incorrect_lifespan_setup(
|
||||
host: str, port: int, server_log_file_path: str
|
||||
) -> None:
|
||||
os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
|
||||
|
||||
CUSTOM_LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"()": "uvicorn.logging.DefaultFormatter",
|
||||
"fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
"use_colors": False,
|
||||
},
|
||||
"access": {
|
||||
"()": "uvicorn.logging.AccessFormatter",
|
||||
"fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
"use_colors": False,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"file_default": {
|
||||
"formatter": "default",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": server_log_file_path,
|
||||
"mode": "w",
|
||||
},
|
||||
"file_access": {
|
||||
"formatter": "access",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": server_log_file_path,
|
||||
"mode": "a",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": { # Catches uvicorn root logs
|
||||
"handlers": ["file_default"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"handlers": ["file_default"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": ["file_access"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["file_default"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool("ping_tool", "A simple ping tool for the test server")
|
||||
def ping_tool() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp_asgi_app = mcp.http_app(transport="streamable-http")
|
||||
|
||||
parent_app = Starlette(
|
||||
routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
|
||||
)
|
||||
|
||||
uvicorn.run(
|
||||
parent_app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=CUSTOM_LOGGING_CONFIG,
|
||||
log_level=None,
|
||||
)
|
||||
sys.exit(0)
|
||||
except Exception as e_outer:
|
||||
with open(server_log_file_path, "a") as f_fallback:
|
||||
f_fallback.write(
|
||||
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
|
||||
)
|
||||
f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
|
||||
f_fallback.write(traceback.format_exc())
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
|
||||
server_log_file = tmp_path / "server.log"
|
||||
|
||||
with run_server_in_process(
|
||||
run_server_with_incorrect_lifespan_setup, str(server_log_file)
|
||||
) as server_url:
|
||||
full_mcp_path = server_url + "/mounted_mcp/mcp/"
|
||||
|
||||
client_triggered_error = False
|
||||
response_status = -1
|
||||
response_body = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(
|
||||
full_mcp_path,
|
||||
json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
|
||||
)
|
||||
response_status = response.status_code
|
||||
response_body = response.text
|
||||
if response.status_code == 500:
|
||||
client_triggered_error = True
|
||||
else:
|
||||
print(
|
||||
f"Client received unexpected status code: {response.status_code} "
|
||||
f"Response: {response_body[:500]}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
print(f"Client request failed with RequestError: {e}")
|
||||
client_triggered_error = True
|
||||
|
||||
assert client_triggered_error, (
|
||||
f"Client request did not result in a 500 error or a request error. "
|
||||
f"Status: {response_status}, Body: {response_body[:500]}"
|
||||
)
|
||||
|
||||
assert server_log_file.exists(), (
|
||||
f"Server log file was not created at {server_log_file}"
|
||||
)
|
||||
log_content = server_log_file.read_text()
|
||||
|
||||
print(f"--- Captured Server Log Content ({server_log_file}) ---")
|
||||
print(log_content)
|
||||
print("--- End Server Log Content ---")
|
||||
|
||||
# Core assertions for the enhanced error message
|
||||
assert (
|
||||
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
|
||||
in log_content
|
||||
)
|
||||
assert "lifespan=mcp_app.lifespan" in log_content
|
||||
assert "gofastmcp.com/deployment/asgi" in log_content
|
||||
assert "Original error: Task group is not initialized" in log_content
|
||||
|
||||
# Check for Uvicorn's own error logging wrapper for the request
|
||||
assert "ERROR" in log_content # General check for ERROR level logs
|
||||
assert "Exception in ASGI application" in log_content
|
||||
|
||||
# Sanity checks for server operation and logging setup
|
||||
assert "Uvicorn running on" in log_content
|
||||
assert (
|
||||
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1890,3 +1890,287 @@ class TestEnumHandling:
|
|||
assert "enum" in enum_def
|
||||
assert enum_def["enum"] == ["foo", "bar", "baz"]
|
||||
assert enum_def["type"] == "string"
|
||||
|
||||
|
||||
class TestRouteMapWildcard:
|
||||
"""Tests for wildcard RouteMap methods functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def basic_openapi_spec(self) -> dict:
|
||||
"""Create a minimal OpenAPI spec with different HTTP methods."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "getUsers",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createUser",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
"/posts": {
|
||||
"get": {
|
||||
"operationId": "getPosts",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPost",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_basic_client(self) -> httpx.AsyncClient:
|
||||
"""Create a simple mock client."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(_responder)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
async def test_wildcard_matches_all_methods(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
|
||||
# Create a single route map with wildcard method
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# All operations should be mapped to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
# Check that all operations were mapped as tools
|
||||
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
|
||||
assert tool_names == expected_tools
|
||||
|
||||
# No resources or templates should be created
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
templates = mcp._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
|
||||
async def test_priority_specific_over_wildcard(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test that specific method maps take priority over wildcard."""
|
||||
# Create route maps with specific method first, then wildcard
|
||||
route_maps = [
|
||||
# GET operations should be mapped to resources
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
||||
# All other operations should be mapped to tools
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# Check GET operations went to resources
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
assert "getUsers" in resource_names
|
||||
assert "getPosts" in resource_names
|
||||
assert len(resources) == 2
|
||||
|
||||
# Check other operations went to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "createUser" in tool_names
|
||||
assert "createPost" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
|
||||
"""Test that when wildcard is first, it matches everything."""
|
||||
# Create route maps with wildcard first, then specific methods
|
||||
route_maps = [
|
||||
# Wildcard first matches everything
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
||||
# This should never be reached
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# All operations should be tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
assert len(tools) == 4
|
||||
|
||||
# No resources should be created
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
assert len(resources) == 0
|
||||
|
||||
async def test_wildcard_with_specific_paths(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test wildcard methods combined with specific path patterns."""
|
||||
route_maps = [
|
||||
# All methods on /users path -> Resources
|
||||
RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
|
||||
# All methods on /posts path -> Tools
|
||||
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# Check /users operations went to resources
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
assert "getUsers" in resource_names
|
||||
assert "createUser" in resource_names
|
||||
assert len(resources) == 2
|
||||
|
||||
# Check /posts operations went to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "getPosts" in tool_names
|
||||
assert "createPost" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
|
||||
class TestAllRoutesAsTools:
|
||||
"""Tests for the all_routes_as_tools parameter in FastMCP class methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_api_spec(self) -> dict:
|
||||
"""A simple OpenAPI spec with both GET and POST methods."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items": {
|
||||
"get": {
|
||||
"operationId": "getItems",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createItem",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_client(self) -> httpx.AsyncClient:
|
||||
"""Simple mock client for testing."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"result": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(_responder)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
|
||||
"""Test FastMCP.from_openapi with all_routes_as_tools=True."""
|
||||
# Create server with all routes as tools
|
||||
server = FastMCP.from_openapi(
|
||||
openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
|
||||
)
|
||||
|
||||
# All operations (GET and POST) should be mapped to tools
|
||||
tools = server._tool_manager.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
|
||||
assert "getItems" in tool_names
|
||||
assert "createItem" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
# No resources or templates should be created
|
||||
resources = server._resource_manager.get_resources()
|
||||
templates = server._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
|
||||
async def test_from_openapi_all_routes_as_tools_conflicting_args(
|
||||
self, simple_api_spec, mock_client
|
||||
):
|
||||
"""Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
|
||||
# Try to create server with conflicting args
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
|
||||
):
|
||||
FastMCP.from_openapi(
|
||||
openapi_spec=simple_api_spec,
|
||||
client=mock_client,
|
||||
all_routes_as_tools=True,
|
||||
route_maps=[
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def test_from_fastapi_all_routes_as_tools(self):
|
||||
"""Test FastMCP.from_fastapi with all_routes_as_tools=True."""
|
||||
# Create a simple FastAPI app
|
||||
app = FastAPI(title="Test FastAPI")
|
||||
|
||||
@app.get("/items")
|
||||
async def get_items():
|
||||
return [{"id": 1, "name": "Item 1"}]
|
||||
|
||||
@app.post("/items")
|
||||
async def create_item(item: dict):
|
||||
return {"id": 2, **item}
|
||||
|
||||
# Create server with all routes as tools
|
||||
server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
|
||||
|
||||
# Both GET and POST operations should be mapped to tools
|
||||
tools = server._tool_manager.list_tools()
|
||||
|
||||
# Get tool names from the generated operation IDs
|
||||
tool_names = {t.name for t in tools}
|
||||
|
||||
# Check that both routes were mapped to tools
|
||||
# The exact names depend on FastAPI's operation ID generation
|
||||
assert len(tools) == 2
|
||||
assert any("get" in name.lower() for name in tool_names)
|
||||
assert any("post" in name.lower() for name in tool_names)
|
||||
|
||||
# No resources or templates should be created
|
||||
resources = server._resource_manager.get_resources()
|
||||
templates = server._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
|
||||
async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
|
||||
"""Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
|
||||
app = FastAPI(title="Test FastAPI")
|
||||
|
||||
# Try to create server with conflicting args
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
|
||||
):
|
||||
FastMCP.from_fastapi(
|
||||
app=app,
|
||||
all_routes_as_tools=True,
|
||||
route_maps=[
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client):
|
|||
assert "test-operation" in tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_path_parameter_handling(mock_client):
|
||||
"""Test how array path parameters are handled."""
|
||||
# Create a simple route with array path parameter
|
||||
|
|
@ -158,7 +157,6 @@ async def test_array_path_parameter_handling(mock_client):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integration_array_path_parameter(array_path_spec, mock_client):
|
||||
"""Integration test for array path parameters."""
|
||||
# Create FastMCP from the spec
|
||||
|
|
@ -192,7 +190,6 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complex_nested_array_path_parameter(mock_client):
|
||||
"""Test handling of complex nested array path parameters."""
|
||||
# Create a route with a path parameter that contains nested objects in an array
|
||||
|
|
@ -262,7 +259,6 @@ async def test_complex_nested_array_path_parameter(mock_client):
|
|||
assert "{" not in called_url, "The URL should not contain Python object syntax"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_query_param_with_fastapi():
|
||||
"""Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
|
||||
# Create a FastAPI app with a route that has an array query parameter
|
||||
|
|
@ -323,7 +319,6 @@ async def test_array_query_param_with_fastapi():
|
|||
assert result_data == {"selected": ["monday", "tuesday"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_query_parameter_format(mock_client):
|
||||
"""Test that array query parameters are formatted as comma-separated values when explode=False."""
|
||||
# Create a route with array query parameter
|
||||
|
|
@ -394,7 +389,6 @@ async def test_array_query_parameter_format(mock_client):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_query_parameter_exploded_format(mock_client):
|
||||
"""Test that array query parameters are formatted as separate parameters when explode=True."""
|
||||
# Create a route with array query parameter with explode=True (default)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ def test_streamable_http_app_deprecation_warning():
|
|||
assert isinstance(app, Starlette)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_sse_async_deprecation_warning():
|
||||
"""Test that run_sse_async raises a deprecation warning."""
|
||||
server = FastMCP("TestServer")
|
||||
|
|
@ -58,7 +57,6 @@ async def test_run_sse_async_deprecation_warning():
|
|||
assert call_kwargs.get("transport") == "sse"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_streamable_http_async_deprecation_warning():
|
||||
"""Test that run_streamable_http_async raises a deprecation warning."""
|
||||
server = FastMCP("TestServer")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for example servers"""
|
||||
|
||||
import pytest
|
||||
from mcp.types import (
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
|
|
@ -11,7 +10,6 @@ from pydantic import AnyUrl
|
|||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_simple_echo():
|
||||
"""Test the simple echo server"""
|
||||
from examples.simple_echo import mcp
|
||||
|
|
@ -23,7 +21,6 @@ async def test_simple_echo():
|
|||
assert result[0].text == "hello"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_complex_inputs():
|
||||
"""Test the complex inputs server"""
|
||||
from examples.complex_inputs import mcp
|
||||
|
|
@ -38,7 +35,6 @@ async def test_complex_inputs():
|
|||
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_desktop(monkeypatch):
|
||||
"""Test the desktop server"""
|
||||
from examples.desktop import mcp
|
||||
|
|
@ -58,7 +54,6 @@ async def test_desktop(monkeypatch):
|
|||
assert result[0].text == "Hello, rooter12!"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_echo():
|
||||
"""Test the echo server"""
|
||||
from examples.echo import mcp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue