Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/development/upgrade-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 3 additions & 6 deletions src/fastmcp/server/openapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -111,7 +109,6 @@ def __init__(
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
timeout=timeout,
)

self.add_provider(provider)
Expand Down
18 changes: 8 additions & 10 deletions src/fastmcp/server/providers/openapi/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand All @@ -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})"
Expand Down Expand Up @@ -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):
Expand All @@ -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),
Expand All @@ -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})"
Expand Down Expand Up @@ -232,7 +231,6 @@ async def read(self) -> ResourceResult:
method=self._route.method,
url=path,
headers=headers,
timeout=self._timeout,
)
response.raise_for_status()

Expand Down Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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})"
Expand All @@ -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,
)
42 changes: 33 additions & 9 deletions src/fastmcp/server/providers/openapi/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Comment on lines +99 to 101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close default AsyncClient on provider shutdown

When client is omitted, the provider constructs its own httpx.AsyncClient, but OpenAPIProvider does not override lifespan() to close it, so servers created with the default client will leave connections open on shutdown (often emitting ResourceWarning and leaking sockets in long-lived processes). Consider tracking ownership of the created client and calling await client.aclose() in a provider lifespan teardown.

Useful? React with 👍 / 👎.

self._timeout = timeout
self._mcp_component_fn = mcp_component_fn

# Keep track of names to detect collisions
Expand Down Expand Up @@ -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)

Comment on lines +162 to +173
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

httpx.AsyncClient base_url absolute relative URL requirements

💡 Result:

  • base_url on httpx.AsyncClient is used only to resolve request URLs that are relative (no scheme/host). It’s “a URL to use as the base when building request URLs”, and the request url is “merged with any base_url set on the client.” [1]
  • If you pass an absolute request URL (e.g. https://api.example.com/v1/users), it does not need base_url and will effectively ignore it (no “merging” needed). [1]
  • If you pass a relative request URL (e.g. "/v1/users" or "v1/users"), you must set base_url (common example: in ASGI tests, base_url="http://testserver"), otherwise relative URLs like "/" won’t work. [2]
  • Practically, base_url should itself be an absolute URL (include scheme + host, like https://api.example.com), because it’s the base used to construct the final absolute request URL. [1], [2]

Sources: HTTPX API reference for AsyncClient/build_request and base_url merging behavior [1]; Starlette docs noting you must set base_url for relative URLs with httpx.AsyncClient [2].


🌐 Web query:

httpx base_url template variables behavior

💡 Result:

httpx does not support “URL template variables” (like {version} / {id}) in base_url (or request URLs). It treats { and } as literal characters in the URL, and—because they’re not generally safe URL characters—clients commonly percent-encode them to %7B and %7D rather than interpreting them as placeholders. [2], [3]

What base_url does do is standard base + relative URL resolution: it prepends a base to outgoing requests when you pass a relative URL (e.g. client.get("/headers")). [1], [2]

So, instead of:

httpx.Client(base_url="https://api.example.com/{version}")
client.get("/users/{id}")

you should fill variables yourself first, e.g.:

version = "v1"
user_id = 123
with httpx.Client(base_url=f"https://api.example.com/{version}") as client:
    r = client.get(f"/users/{user_id}")

Sources: HTTPX base_url client behavior [1], HTTPX URL parsing/encoding behavior [2], curly braces are typically percent-encoded in URLs [3].

[1] https://www.python-httpx.org/advanced/clients/
[2] https://www.encode.io/httpnext/urls
[3] https://stackoverflow.com/questions/53766725/curl-does-not-work-with-urls-with-curly-braces-in-parameters


Handle templated and relative server URLs before constructing the default client.

OpenAPI server URLs can be templated (e.g., "{protocol}://api.example.com/{version}") or relative (e.g., "/api"). The httpx.AsyncClient requires base_url to be absolute and does not support URL template variables—passing templated URLs will cause them to be percent-encoded as literal characters, resulting in invalid requests. Relative URLs will also cause failures.

Detect and resolve server URL variables against the OpenAPI variables object, validate that the result is absolute, and raise a clear error if not possible so users know to pass a client explicitly.

🔧 Suggested hardening for default client creation
+from string import Formatter
@@
     `@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"]
+        if not servers or not servers[0].get("url"):
+            raise ValueError("Missing OpenAPI server URL; provide an AsyncClient.")
+        server = servers[0]
+        base_url = server["url"]
+        if "{" in base_url:
+            variables = server.get("variables") or {}
+            formatter = Formatter()
+            parts: list[str] = []
+            for literal, field_name, *_ in formatter.parse(base_url):
+                parts.append(literal)
+                if field_name:
+                    default = variables.get(field_name, {}).get("default")
+                    if default is None:
+                        raise ValueError(
+                            f"Server URL variable '{field_name}' has no default; "
+                            "provide an AsyncClient explicitly."
+                        )
+                    parts.append(str(default))
+            base_url = "".join(parts)
+        if not base_url.startswith(("http://", "https://")):
+            raise ValueError(
+                "Server URL must be absolute (http/https); provide an AsyncClient."
+            )
         return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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)
`@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("Missing OpenAPI server URL; provide an AsyncClient.")
server = servers[0]
base_url = server["url"]
if "{" in base_url:
variables = server.get("variables") or {}
formatter = Formatter()
parts: list[str] = []
for literal, field_name, *_ in formatter.parse(base_url):
parts.append(literal)
if field_name:
default = variables.get(field_name, {}).get("default")
if default is None:
raise ValueError(
f"Server URL variable '{field_name}' has no default; "
"provide an AsyncClient explicitly."
)
parts.append(str(default))
base_url = "".join(parts)
if not base_url.startswith(("http://", "https://")):
raise ValueError(
"Server URL must be absolute (http/https); provide an AsyncClient."
)
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 167-170: Avoid specifying long messages outside the exception class

(TRY003)

@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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 6 additions & 9 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1969,29 +1969,29 @@ 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:
"""
Create a FastMCP server from an OpenAPI specification.

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:
Expand All @@ -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)

Expand All @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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)

Expand Down
24 changes: 24 additions & 0 deletions tests/server/providers/openapi/test_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading