mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Remove OpenAPI timeout parameter, make client optional, surface timeout errors (#3067)
* Remove OpenAPI timeout param, make client optional, surface timeout errors * Close auto-created httpx client via provider lifespan
This commit is contained in:
parent
fe432de156
commit
5fd41b2e15
7 changed files with 102 additions and 43 deletions
|
|
@ -95,6 +95,19 @@ value = await ctx.get_state("key")
|
|||
|
||||
`FASTMCP_SHOW_CLI_BANNER` is now `FASTMCP_SHOW_SERVER_BANNER`.
|
||||
|
||||
#### OpenAPI `timeout` Parameter Removed
|
||||
|
||||
Configure timeout on the httpx client directly. The `client` parameter is now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout.
|
||||
|
||||
```python
|
||||
# Before
|
||||
provider = OpenAPIProvider(spec, client, timeout=60)
|
||||
|
||||
# After
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60)
|
||||
provider = OpenAPIProvider(spec, client)
|
||||
```
|
||||
|
||||
#### Metadata Namespace Renamed
|
||||
|
||||
The FastMCP metadata namespace changed from `_fastmcp` to `fastmcp`, and metadata is now always included. The `include_fastmcp_meta` parameter has been removed from `FastMCP()` and `to_mcp_tool()`—remove any usage of this parameter.
|
||||
|
|
|
|||
|
|
@ -60,14 +60,13 @@ class FastMCPOpenAPI(FastMCP):
|
|||
def __init__(
|
||||
self,
|
||||
openapi_spec: dict[str, Any],
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
name: str | None = None,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
route_map_fn: RouteMapFn | None = None,
|
||||
mcp_component_fn: ComponentFn | None = None,
|
||||
mcp_names: dict[str, str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
**settings: Any,
|
||||
):
|
||||
"""Initialize a FastMCP server from an OpenAPI schema.
|
||||
|
|
@ -77,14 +76,14 @@ class FastMCPOpenAPI(FastMCP):
|
|||
|
||||
Args:
|
||||
openapi_spec: OpenAPI schema as a dictionary
|
||||
client: httpx AsyncClient for making HTTP requests
|
||||
client: Optional httpx AsyncClient for making HTTP requests.
|
||||
If not provided, a default client is created from the spec.
|
||||
name: Optional name for the server
|
||||
route_maps: Optional list of RouteMap objects defining route mappings
|
||||
route_map_fn: Optional callable for advanced route type mapping
|
||||
mcp_component_fn: Optional callable for component customization
|
||||
mcp_names: Optional dictionary mapping operationId to component names
|
||||
tags: Optional set of tags to add to all components
|
||||
timeout: Optional timeout (in seconds) for all requests
|
||||
**settings: Additional settings for FastMCP
|
||||
"""
|
||||
warnings.warn(
|
||||
|
|
@ -99,7 +98,6 @@ class FastMCPOpenAPI(FastMCP):
|
|||
|
||||
# Store references for backwards compatibility
|
||||
self._client = client
|
||||
self._timeout = timeout
|
||||
self._mcp_component_fn = mcp_component_fn
|
||||
|
||||
# Create provider with the client
|
||||
|
|
@ -111,7 +109,6 @@ class FastMCPOpenAPI(FastMCP):
|
|||
mcp_component_fn=mcp_component_fn,
|
||||
mcp_names=mcp_names,
|
||||
tags=tags,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
self.add_provider(provider)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ class OpenAPITool(Tool):
|
|||
parameters: dict[str, Any],
|
||||
output_schema: dict[str, Any] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
annotations: ToolAnnotations | None = None,
|
||||
serializer: Callable[[Any], str] | None = None, # Deprecated
|
||||
):
|
||||
|
|
@ -100,7 +99,6 @@ class OpenAPITool(Tool):
|
|||
self._client = client
|
||||
self._route = route
|
||||
self._director = director
|
||||
self._timeout = timeout
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
||||
|
|
@ -160,8 +158,11 @@ class OpenAPITool(Tool):
|
|||
error_message += f" - {e.response.text}"
|
||||
raise ValueError(error_message) from e
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise ValueError(f"Request error: {e!s}") from e
|
||||
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
|
||||
|
||||
|
||||
class OpenAPIResource(Resource):
|
||||
|
|
@ -179,7 +180,6 @@ class OpenAPIResource(Resource):
|
|||
description: str,
|
||||
mime_type: str = "application/json",
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
uri=AnyUrl(uri),
|
||||
|
|
@ -191,7 +191,6 @@ class OpenAPIResource(Resource):
|
|||
self._client = client
|
||||
self._route = route
|
||||
self._director = director
|
||||
self._timeout = timeout
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
|
||||
|
|
@ -232,7 +231,6 @@ class OpenAPIResource(Resource):
|
|||
method=self._route.method,
|
||||
url=path,
|
||||
headers=headers,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -274,8 +272,11 @@ class OpenAPIResource(Resource):
|
|||
error_message += f" - {e.response.text}"
|
||||
raise ValueError(error_message) from e
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise ValueError(f"Request error: {e!s}") from e
|
||||
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
|
||||
|
||||
|
||||
class OpenAPIResourceTemplate(ResourceTemplate):
|
||||
|
|
@ -293,7 +294,6 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|||
description: str,
|
||||
parameters: dict[str, Any],
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
uri_template=uri_template,
|
||||
|
|
@ -305,7 +305,6 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|||
self._client = client
|
||||
self._route = route
|
||||
self._director = director
|
||||
self._timeout = timeout
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
|
||||
|
|
@ -328,5 +327,4 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|||
description=self.description or f"Resource for {self._route.path}",
|
||||
mime_type="application/json",
|
||||
tags=set(self._route.tags or []),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -44,6 +45,8 @@ __all__ = [
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
|
||||
class OpenAPIProvider(Provider):
|
||||
"""Provider that creates MCP components from an OpenAPI specification.
|
||||
|
|
@ -68,31 +71,34 @@ class OpenAPIProvider(Provider):
|
|||
def __init__(
|
||||
self,
|
||||
openapi_spec: dict[str, Any],
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
*,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
route_map_fn: RouteMapFn | None = None,
|
||||
mcp_component_fn: ComponentFn | None = None,
|
||||
mcp_names: dict[str, str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
):
|
||||
"""Initialize provider by parsing OpenAPI spec and creating components.
|
||||
|
||||
Args:
|
||||
openapi_spec: OpenAPI schema as a dictionary
|
||||
client: httpx AsyncClient for making HTTP requests
|
||||
client: Optional httpx AsyncClient for making HTTP requests.
|
||||
If not provided, a default client is created using the first
|
||||
server URL from the OpenAPI spec with a 30-second timeout.
|
||||
To customize timeout or other settings, pass your own client.
|
||||
route_maps: Optional list of RouteMap objects defining route mappings
|
||||
route_map_fn: Optional callable for advanced route type mapping
|
||||
mcp_component_fn: Optional callable for component customization
|
||||
mcp_names: Optional dictionary mapping operationId to component names
|
||||
tags: Optional set of tags to add to all components
|
||||
timeout: Optional timeout (in seconds) for all requests
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self._owns_client = client is None
|
||||
if client is None:
|
||||
client = self._create_default_client(openapi_spec)
|
||||
self._client = client
|
||||
self._timeout = timeout
|
||||
self._mcp_component_fn = mcp_component_fn
|
||||
|
||||
# Keep track of names to detect collisions
|
||||
|
|
@ -153,6 +159,27 @@ class OpenAPIProvider(Provider):
|
|||
|
||||
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
|
||||
|
||||
@classmethod
|
||||
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
|
||||
"""Create a default httpx client from the OpenAPI spec's server URL."""
|
||||
servers = openapi_spec.get("servers", [])
|
||||
if not servers or not servers[0].get("url"):
|
||||
raise ValueError(
|
||||
"No server URL found in OpenAPI spec. Either add a 'servers' "
|
||||
"entry to the spec or provide an httpx.AsyncClient explicitly."
|
||||
)
|
||||
base_url = servers[0]["url"]
|
||||
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self) -> AsyncIterator[None]:
|
||||
"""Manage the lifecycle of the auto-created httpx client."""
|
||||
if self._owns_client:
|
||||
async with self._client:
|
||||
yield
|
||||
else:
|
||||
yield
|
||||
|
||||
def _generate_default_name(
|
||||
self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None
|
||||
) -> str:
|
||||
|
|
@ -225,7 +252,6 @@ class OpenAPIProvider(Provider):
|
|||
parameters=combined_schema,
|
||||
output_schema=output_schema,
|
||||
tags=set(route.tags or []) | tags,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
if self._mcp_component_fn is not None:
|
||||
|
|
@ -263,7 +289,6 @@ class OpenAPIProvider(Provider):
|
|||
name=resource_name,
|
||||
description=enhanced_description,
|
||||
tags=set(route.tags or []) | tags,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
if self._mcp_component_fn is not None:
|
||||
|
|
@ -331,7 +356,6 @@ class OpenAPIProvider(Provider):
|
|||
description=enhanced_description,
|
||||
parameters=template_params_schema,
|
||||
tags=set(route.tags or []) | tags,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
if self._mcp_component_fn is not None:
|
||||
|
|
|
|||
|
|
@ -1969,14 +1969,13 @@ class FastMCP(
|
|||
def from_openapi(
|
||||
cls,
|
||||
openapi_spec: dict[str, Any],
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
name: str = "OpenAPI Server",
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
route_map_fn: OpenAPIRouteMapFn | None = None,
|
||||
mcp_component_fn: OpenAPIComponentFn | None = None,
|
||||
mcp_names: dict[str, str] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
**settings: Any,
|
||||
) -> Self:
|
||||
"""
|
||||
|
|
@ -1984,14 +1983,15 @@ class FastMCP(
|
|||
|
||||
Args:
|
||||
openapi_spec: OpenAPI schema as a dictionary
|
||||
client: httpx AsyncClient for making HTTP requests
|
||||
client: Optional httpx AsyncClient for making HTTP requests.
|
||||
If not provided, a default client is created using the first
|
||||
server URL from the OpenAPI spec with a 30-second timeout.
|
||||
name: Name for the MCP server
|
||||
route_maps: Optional list of RouteMap objects defining route mappings
|
||||
route_map_fn: Optional callable for advanced route type mapping
|
||||
mcp_component_fn: Optional callable for component customization
|
||||
mcp_names: Optional dictionary mapping operationId to component names
|
||||
tags: Optional set of tags to add to all components
|
||||
timeout: Optional timeout (in seconds) for all requests
|
||||
**settings: Additional settings passed to FastMCP
|
||||
|
||||
Returns:
|
||||
|
|
@ -2007,7 +2007,6 @@ class FastMCP(
|
|||
mcp_component_fn=mcp_component_fn,
|
||||
mcp_names=mcp_names,
|
||||
tags=tags,
|
||||
timeout=timeout,
|
||||
)
|
||||
return cls(name=name, providers=[provider], **settings)
|
||||
|
||||
|
|
@ -2022,7 +2021,6 @@ class FastMCP(
|
|||
mcp_names: dict[str, str] | None = None,
|
||||
httpx_client_kwargs: dict[str, Any] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
timeout: float | None = None,
|
||||
**settings: Any,
|
||||
) -> Self:
|
||||
"""
|
||||
|
|
@ -2035,9 +2033,9 @@ class FastMCP(
|
|||
route_map_fn: Optional callable for advanced route type mapping
|
||||
mcp_component_fn: Optional callable for component customization
|
||||
mcp_names: Optional dictionary mapping operationId to component names
|
||||
httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient
|
||||
httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient.
|
||||
Use this to configure timeout and other client settings.
|
||||
tags: Optional set of tags to add to all components
|
||||
timeout: Optional timeout (in seconds) for all requests
|
||||
**settings: Additional settings passed to FastMCP
|
||||
|
||||
Returns:
|
||||
|
|
@ -2064,7 +2062,6 @@ class FastMCP(
|
|||
mcp_component_fn=mcp_component_fn,
|
||||
mcp_names=mcp_names,
|
||||
tags=tags,
|
||||
timeout=timeout,
|
||||
)
|
||||
return cls(name=server_name, providers=[provider], **settings)
|
||||
|
||||
|
|
|
|||
|
|
@ -739,3 +739,27 @@ class TestOpenAPIComprehensive:
|
|||
assert provider is not None
|
||||
assert hasattr(provider, "_director")
|
||||
assert hasattr(provider, "_spec")
|
||||
|
||||
async def test_timeout_error_produces_useful_message(
|
||||
self, comprehensive_openapi_spec
|
||||
):
|
||||
"""ReadTimeout should surface a clear error, not an empty string."""
|
||||
mock_client = Mock(spec=httpx.AsyncClient)
|
||||
mock_client.base_url = "https://api.example.com"
|
||||
mock_client.headers = None
|
||||
|
||||
# httpx internally raises ReadTimeout with an empty message
|
||||
mock_client.send = AsyncMock(side_effect=httpx.ReadTimeout(""))
|
||||
|
||||
server = create_openapi_server(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await mcp_client.call_tool("get_user", {"id": 1})
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "timed out" in error_message
|
||||
assert "ReadTimeout" in error_message
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.server.providers.openapi import OpenAPIProvider
|
||||
from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
class TestOpenAPIProviderBasicFunctionality:
|
||||
|
|
@ -146,16 +147,21 @@ class TestOpenAPIProviderBasicFunctionality:
|
|||
assert get_user_tool is not None
|
||||
assert get_user_tool.description is not None
|
||||
|
||||
def test_provider_with_timeout(self, simple_openapi_spec):
|
||||
"""Test provider initialization with timeout setting."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=simple_openapi_spec,
|
||||
client=client,
|
||||
timeout=30.0,
|
||||
)
|
||||
def test_provider_creates_default_client_from_spec(self, simple_openapi_spec):
|
||||
"""Test that omitting client creates one from the spec's servers URL."""
|
||||
provider = OpenAPIProvider(openapi_spec=simple_openapi_spec)
|
||||
assert str(provider._client.base_url).rstrip("/") == "https://api.example.com"
|
||||
assert provider._client.timeout == httpx.Timeout(DEFAULT_TIMEOUT)
|
||||
|
||||
assert provider._timeout == 30.0
|
||||
def test_provider_default_client_requires_servers(self):
|
||||
"""Test that omitting client without servers in spec raises."""
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "No Servers", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
with pytest.raises(ValueError, match="No server URL"):
|
||||
OpenAPIProvider(openapi_spec=spec)
|
||||
|
||||
def test_provider_with_empty_spec(self):
|
||||
"""Test provider with minimal OpenAPI spec."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue