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:
Jeremiah Lowin 2026-02-03 21:08:09 -05:00 committed by GitHub
commit 5fd41b2e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 102 additions and 43 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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,
)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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."""