diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index 247e03a2bf..8bfe713af2 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -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. diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py index 2a6b4a8b4c..3171ca763b 100644 --- a/src/fastmcp/server/openapi/server.py +++ b/src/fastmcp/server/openapi/server.py @@ -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 @@ def __init__( 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 @@ def __init__( # 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 @@ def __init__( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, - timeout=timeout, ) self.add_provider(provider) diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index a671ff69fc..43c58b9563 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -76,7 +76,6 @@ def __init__( 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 @@ def __init__( 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 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult: 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 @@ def __init__( 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 @@ def __init__( 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 @@ async def read(self) -> ResourceResult: method=self._route.method, url=path, headers=headers, - timeout=self._timeout, ) response.raise_for_status() @@ -274,8 +272,11 @@ async def read(self) -> ResourceResult: 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 @@ def __init__( 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 @@ def __init__( 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 @@ async def create_resource( description=self.description or f"Resource for {self._route.path}", mime_type="application/json", tags=set(self._route.tags or []), - timeout=self._timeout, ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index 7f13fb4da1..a93be1a414 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -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 @@ 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 @@ def __init__( 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 @@ def _create_openapi_tool( 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 @@ def _create_openapi_resource( 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 @@ def _create_openapi_template( description=enhanced_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, - timeout=self._timeout, ) if self._mcp_component_fn is not None: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a09b58a6e1..1172793c9c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1969,14 +1969,13 @@ def add_resource_prefix(uri: str, prefix: str) -> str: 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 @@ def from_openapi( 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 @@ def from_openapi( 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 @@ def from_fastapi( 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 @@ def from_fastapi( 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 @@ def from_fastapi( mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, - timeout=timeout, ) return cls(name=server_name, providers=[provider], **settings) diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index 62b137af77..f55786e65d 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -739,3 +739,27 @@ async def test_server_performance_no_latency(self, comprehensive_openapi_spec): 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 diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/providers/openapi/test_server.py index 59d037ba80..0d7447eab5 100644 --- a/tests/server/providers/openapi/test_server.py +++ b/tests/server/providers/openapi/test_server.py @@ -6,6 +6,7 @@ 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 @@ async def test_provider_tool_execution(self, simple_openapi_spec): 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."""