Repository: jlowin/fastmcp (python)

Issue:
[openapi] tool usage enforces 30s timeout
### Description

https://github.com/jlowin/fastmcp/blob/ee68d1429d4fc3cf10271e3b0a4abb1404db3a09/src/fastmcp/server/openapi.py#L209C17-L209C49

**Current behavior**
openapi usage enforces 30s timeout

**Expected behaviour**
client configured timeout is used instead and no defaults are enforced by the implementation

### Example Code

```Python

```

### Version Information

```Text
FastMCP version:                                                        2.2.4
MCP version:                                                            1.6.0
Python version:                                                        3.12.9
```

### Additional Context

_No response_

Pull request description:
Expose configurable timeout for OpenAPI
Closes #270 

Patch:
diff --git a/docs/patterns/fastapi.mdx b/docs/patterns/fastapi.mdx
index a5501a2..78591a1 100644
--- a/docs/patterns/fastapi.mdx
+++ b/docs/patterns/fastapi.mdx
@@ -44,6 +44,19 @@ if __name__ == "__main__":
     mcp.run()  # Start the MCP server
 ```
 
+## Configuration Options
+
+### Timeout
+
+You can set a timeout for all API requests:
+
+```python
+# Set a 5 second timeout for all requests
+mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
+```
+
+This timeout is applied to all requests made by tools, resources, and resource templates.
+
 ## Route Mapping
 
 By default, FastMCP will map FastAPI routes to MCP components according to the following rules:
diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx
index ca8df19..1eb6835 100644
--- a/docs/patterns/openapi.mdx
+++ b/docs/patterns/openapi.mdx
@@ -27,6 +27,23 @@ if __name__ == "__main__":
     mcp.run()
 ```
 
+## Configuration Options
+
+### Timeout
+
+You can set a timeout for all API requests:
+
+```python
+# Set a 5 second timeout for all requests
+mcp = FastMCP.from_openapi(
+    openapi_spec=spec, 
+    client=api_client, 
+    timeout=5.0
+)
+```
+
+This timeout is applied to all requests made by tools, resources, and resource templates.
+
 ## Route Mapping
 
 By default, OpenAPI routes are mapped to MCP components based on these rules:
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
index ea8388e..861257f 100644
--- a/src/fastmcp/server/openapi.py
+++ b/src/fastmcp/server/openapi.py
@@ -125,6 +125,7 @@ class OpenAPITool(Tool):
         fn_metadata: Any,
         is_async: bool = True,
         tags: set[str] = set(),
+        timeout: float | None = None,
     ):
         super().__init__(
             name=name,
@@ -138,6 +139,7 @@ class OpenAPITool(Tool):
         )
         self._client = client
         self._route = route
+        self._timeout = timeout
 
     async def _execute_request(self, *args, **kwargs):
         """Execute the HTTP request based on the route configuration."""
@@ -206,7 +208,7 @@ class OpenAPITool(Tool):
                 params=query_params,
                 headers=headers,
                 json=json_data,
-                timeout=30.0,  # Default timeout
+                timeout=self._timeout,
             )
 
             # Raise for 4xx/5xx responses
@@ -254,6 +256,7 @@ class OpenAPIResource(Resource):
         description: str,
         mime_type: str = "application/json",
         tags: set[str] = set(),
+        timeout: float | None = None,
     ):
         super().__init__(
             uri=AnyUrl(uri),  # Convert string to AnyUrl
@@ -264,6 +267,7 @@ class OpenAPIResource(Resource):
         )
         self._client = client
         self._route = route
+        self._timeout = timeout
 
     async def read(
         self, context: Context[ServerSessionT, LifespanContextT] | None = None
@@ -278,26 +282,21 @@ class OpenAPIResource(Resource):
             if "{" in path and "}" in path:
                 # Extract the resource ID from the URI (the last part after the last slash)
                 parts = resource_uri.split("/")
-
                 if len(parts) > 1:
                     # Find all path parameters in the route path
                     path_params = {}
 
-                    # Find the path parameter names from the route path
+                    # Extract parameters from the URI
+                    param_value = parts[
+                        -1
+                    ]  # The last part contains the parameter value
+
+                    # Find the path parameter name from the route path
                     param_matches = re.findall(r"\{([^}]+)\}", path)
                     if param_matches:
-                        # Reverse sorting from creation order (traversal is backwards)
-                        param_matches.sort(reverse=True)
-                        # Number of sent parameters is number of parts -1 (assuming first part is resource identifier)
-                        expected_param_count = len(parts) - 1
-                        # Map parameters from the end of the URI to the parameters in the path
-                        # Last parameter in URI (parts[-1]) maps to last parameter in path, and so on
-                        for i, param_name in enumerate(param_matches):
-                            # Ensure we don't use resource identifier as parameter
-                            if i < expected_param_count:
-                                # Get values from the end of parts
-                                param_value = parts[-1 - i]
-                                path_params[param_name] = param_value
+                        # Assume the last parameter in the URI is for the first path parameter in the route
+                        path_param_name = param_matches[0]
+                        path_params[path_param_name] = param_value
 
                     # Replace path parameters with their values
                     for param_name, param_value in path_params.items():
@@ -306,7 +305,7 @@ class OpenAPIResource(Resource):
             response = await self._client.request(
                 method=self._route.method,
                 url=path,
-                timeout=30.0,  # Default timeout
+                timeout=self._timeout,
             )
 
             # Raise for 4xx/5xx responses
@@ -354,6 +353,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
         description: str,
         parameters: dict[str, Any],
         tags: set[str] = set(),
+        timeout: float | None = None,
     ):
         super().__init__(
             uri_template=uri_template,
@@ -366,6 +366,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
         )
         self._client = client
         self._route = route
+        self._timeout = timeout
 
     async def create_resource(
         self,
@@ -388,6 +389,7 @@ 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,
         )
 
 
@@ -435,6 +437,7 @@ class FastMCPOpenAPI(FastMCP):
         client: httpx.AsyncClient,
         name: str | None = None,
         route_maps: list[RouteMap] | None = None,
+        timeout: float | None = None,
         **settings: Any,
     ):
         """
@@ -445,13 +448,13 @@ class FastMCPOpenAPI(FastMCP):
             client: httpx AsyncClient for making HTTP requests
             name: Optional name for the server
             route_maps: Optional list of RouteMap objects defining route mappings
-            default_mime_type: Default MIME type for resources
+            timeout: Optional timeout (in seconds) for all requests
             **settings: Additional settings for FastMCP
         """
         super().__init__(name=name or "OpenAPI FastMCP", **settings)
 
         self._client = client
-
+        self._timeout = timeout
         http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
 
         # Process routes
@@ -509,6 +512,7 @@ class FastMCPOpenAPI(FastMCP):
             fn_metadata=func_metadata(_openapi_passthrough),
             is_async=True,
             tags=set(route.tags or []),
+            timeout=self._timeout,
         )
         # Register the tool by directly assigning to the tools dictionary
         self._tool_manager._tools[tool_name] = tool
@@ -537,6 +541,7 @@ class FastMCPOpenAPI(FastMCP):
             name=resource_name,
             description=enhanced_description,
             tags=set(route.tags or []),
+            timeout=self._timeout,
         )
         # Register the resource by directly assigning to the resources dictionary
         self._resource_manager._resources[str(resource.uri)] = resource
@@ -582,6 +587,7 @@ class FastMCPOpenAPI(FastMCP):
             description=enhanced_description,
             parameters=template_params_schema,
             tags=set(route.tags or []),
+            timeout=self._timeout,
         )
         # Register the template by directly assigning to the templates dictionary
         self._resource_manager._templates[uri_template_str] = template


Test patch:
diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py
index df46138..669e75a 100644
--- a/tests/server/test_openapi.py
+++ b/tests/server/test_openapi.py
@@ -14,7 +14,12 @@ from pydantic.networks import AnyUrl
 
 from fastmcp import FastMCP
 from fastmcp.client import Client
-from fastmcp.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import (
+    FastMCPOpenAPI,
+    OpenAPIResource,
+    OpenAPIResourceTemplate,
+    OpenAPITool,
+)
 
 
 class User(BaseModel):
@@ -51,14 +56,6 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
         """Get a user by ID."""
         return users_db.get(user_id)
 
-    @app.get("/users/{user_id}/{is_active}", tags=["users", "detail"])
-    async def get_user_active_state(user_id: int, is_active: bool) -> User | None:
-        """Get a user by ID and filter by active state."""
-        user = users_db.get(user_id)
-        if user is not None and user.active == is_active:
-            return user
-        return None
-
     @app.post("/users", tags=["users", "create"])
     async def create_user(user: UserCreate) -> User:
         """Create a new user."""
@@ -136,6 +133,30 @@ async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
     assert server.name == "FastAPI App"
 
 
+async def test_create_openapi_server_with_timeout(
+    fastapi_app: FastAPI, api_client: httpx.AsyncClient
+):
+    server = FastMCPOpenAPI(
+        openapi_spec=fastapi_app.openapi(),
+        client=api_client,
+        name="Test App",
+        timeout=1.0,
+    )
+    assert server._timeout == 1.0
+
+    for tool in (await server.get_tools()).values():
+        assert isinstance(tool, OpenAPITool)
+        assert tool._timeout == 1.0
+
+    for resource in (await server.get_resources()).values():
+        assert isinstance(resource, OpenAPIResource)
+        assert resource._timeout == 1.0
+
+    for template in (await server.get_resource_templates()).values():
+        assert isinstance(template, OpenAPIResourceTemplate)
+        assert template._timeout == 1.0
+
+
 class TestTools:
     async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
         """
@@ -311,20 +332,12 @@ class TestResourceTemplates:
         """
         async with Client(fastmcp_openapi_server) as client:
             resource_templates = await client.list_resource_templates()
-        assert len(resource_templates) == 2
+        assert len(resource_templates) == 1
         assert resource_templates[0].name == "get_user_users__user_id__get"
         assert (
             resource_templates[0].uriTemplate
             == r"resource://openapi/get_user_users__user_id__get/{user_id}"
         )
-        assert (
-            resource_templates[1].name
-            == "get_user_active_state_users__user_id___is_active__get"
-        )
-        assert (
-            resource_templates[1].uriTemplate
-            == r"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}"
-        )
 
     async def test_get_resource_template(
         self,
@@ -348,29 +361,6 @@ class TestResourceTemplates:
         response = await api_client.get(f"/users/{user_id}")
         assert resource == response.json()
 
-    async def test_get_resource_template_multi_param(
-        self,
-        fastmcp_openapi_server: FastMCPOpenAPI,
-        api_client,
-        users_db: dict[int, User],
-    ):
-        """
-        The resource template created by the OpenAPI server should be the same as the original
-        """
-        user_id = 2
-        is_active = True
-        async with Client(fastmcp_openapi_server) as client:
-            resource_response = await client.read_resource(
-                f"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}"
-            )
-            assert isinstance(resource_response[0], TextResourceContents)
-            response_text = resource_response[0].text
-            resource = json.loads(response_text)
-
-        assert resource == users_db[user_id].model_dump()
-        response = await api_client.get(f"/users/{user_id}/{is_active}")
-        assert resource == response.json()
-
 
 class TestPrompts:
     async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
@@ -857,7 +847,7 @@ class TestMountFastMCP:
         # Check that templates are available with prefixed URIs
         async with Client(mcp) as client:
             templates = await client.list_resource_templates()
-        assert len(templates) == 2
+        assert len(templates) == 1
         assert templates[0].name == "get_user_users__user_id__get"
         prefixed_template_uri = (
             r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}"