Jeremiah Lowin commited on
Commit
d662e59
·
unverified ·
2 Parent(s): 88af95970737e6

Merge pull request #287 from jlowin/openapi-params

Browse files
docs/patterns/openapi.mdx CHANGED
@@ -106,6 +106,38 @@ mcp = await FastMCP.from_openapi(
106
  - It sends the request through the provided httpx client
107
  - It translates the HTTP response to the appropriate MCP format
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  ## Complete Example
110
 
111
  ```python [expandable]
 
106
  - It sends the request through the provided httpx client
107
  - It translates the HTTP response to the appropriate MCP format
108
 
109
+ ### Request Parameter Handling
110
+
111
+ FastMCP carefully handles different types of parameters in OpenAPI requests:
112
+
113
+ #### Query Parameters
114
+
115
+ By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues.
116
+
117
+ For example, if you call a tool with these parameters:
118
+ ```python
119
+ await client.call_tool("search_products", {
120
+ "category": "electronics", # Will be included
121
+ "min_price": 100, # Will be included
122
+ "max_price": None, # Will be excluded
123
+ "brand": "", # Will be excluded
124
+ })
125
+ ```
126
+
127
+ The resulting HTTP request will only include `category=electronics&min_price=100`.
128
+
129
+ #### Path Parameters
130
+
131
+ For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised.
132
+
133
+ ```python
134
+ # This will work
135
+ await client.call_tool("get_product", {"product_id": 123})
136
+
137
+ # This will raise ValueError: "Missing required path parameters: {'product_id'}"
138
+ await client.call_tool("get_product", {"product_id": None})
139
+ ```
140
+
141
  ## Complete Example
142
 
143
  ```python [expandable]
src/fastmcp/server/openapi.py CHANGED
@@ -149,19 +149,37 @@ class OpenAPITool(Tool):
149
  path = self._route.path
150
 
151
  # Replace path parameters with values from kwargs
 
 
152
  path_params = {
153
  p.name: kwargs.get(p.name)
154
  for p in self._route.parameters
155
  if p.location == "path"
 
 
156
  }
 
 
 
 
 
 
 
 
 
 
 
157
  for param_name, param_value in path_params.items():
158
  path = path.replace(f"{{{param_name}}}", str(param_value))
159
 
160
- # Prepare query parameters
161
  query_params = {
162
  p.name: kwargs.get(p.name)
163
  for p in self._route.parameters
164
- if p.location == "query" and p.name in kwargs
 
 
 
165
  }
166
 
167
  # Prepare headers - fix typing by ensuring all values are strings
@@ -312,9 +330,18 @@ class OpenAPIResource(Resource):
312
  for param_name, param_value in path_params.items():
313
  path = path.replace(f"{{{param_name}}}", str(param_value))
314
 
 
 
 
 
 
 
 
 
315
  response = await self._client.request(
316
  method=self._route.method,
317
  url=path,
 
318
  timeout=self._timeout,
319
  )
320
 
 
149
  path = self._route.path
150
 
151
  # Replace path parameters with values from kwargs
152
+ # Path parameters should never be None as they're typically required
153
+ # but we'll handle that case anyway
154
  path_params = {
155
  p.name: kwargs.get(p.name)
156
  for p in self._route.parameters
157
  if p.location == "path"
158
+ and p.name in kwargs
159
+ and kwargs.get(p.name) is not None
160
  }
161
+
162
+ # Ensure all path parameters are provided
163
+ required_path_params = {
164
+ p.name
165
+ for p in self._route.parameters
166
+ if p.location == "path" and p.required
167
+ }
168
+ missing_params = required_path_params - path_params.keys()
169
+ if missing_params:
170
+ raise ValueError(f"Missing required path parameters: {missing_params}")
171
+
172
  for param_name, param_value in path_params.items():
173
  path = path.replace(f"{{{param_name}}}", str(param_value))
174
 
175
+ # Prepare query parameters - filter out None and empty strings
176
  query_params = {
177
  p.name: kwargs.get(p.name)
178
  for p in self._route.parameters
179
+ if p.location == "query"
180
+ and p.name in kwargs
181
+ and kwargs.get(p.name) is not None
182
+ and kwargs.get(p.name) != ""
183
  }
184
 
185
  # Prepare headers - fix typing by ensuring all values are strings
 
330
  for param_name, param_value in path_params.items():
331
  path = path.replace(f"{{{param_name}}}", str(param_value))
332
 
333
+ # Filter any query parameters - get query parameters and filter out None/empty values
334
+ query_params = {}
335
+ for param in self._route.parameters:
336
+ if param.location == "query" and hasattr(self, f"_{param.name}"):
337
+ value = getattr(self, f"_{param.name}")
338
+ if value is not None and value != "":
339
+ query_params[param.name] = value
340
+
341
  response = await self._client.request(
342
  method=self._route.method,
343
  url=path,
344
+ params=query_params,
345
  timeout=self._timeout,
346
  )
347
 
tests/server/test_openapi.py CHANGED
@@ -14,6 +14,7 @@ from pydantic.networks import AnyUrl
14
 
15
  from fastmcp import FastMCP
16
  from fastmcp.client import Client
 
17
  from fastmcp.server.openapi import (
18
  FastMCPOpenAPI,
19
  OpenAPIResource,
@@ -53,6 +54,22 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
53
  """Get all users."""
54
  return sorted(users_db.values(), key=lambda x: x.id)
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  @app.get("/users/{user_id}", tags=["users", "detail"])
57
  async def get_user(user_id: int) -> User | None:
58
  """Get a user by ID."""
@@ -304,7 +321,7 @@ class TestResources:
304
  """
305
  async with Client(fastmcp_openapi_server) as client:
306
  resources = await client.list_resources()
307
- assert len(resources) == 3
308
  assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
309
  assert resources[0].name == "get_users_users_get"
310
 
@@ -904,7 +921,7 @@ class TestMountFastMCP:
904
  # Check that resources are available with prefixed URIs
905
  async with Client(mcp) as client:
906
  resources = await client.list_resources()
907
- assert len(resources) == 3
908
  # We're checking the key used by mcp to store the resource
909
  # The prefixed URI is used as the key, but the resource's original uri is preserved
910
  prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
@@ -932,3 +949,88 @@ class TestMountFastMCP:
932
  async with Client(mcp) as client:
933
  prompts = await client.list_prompts()
934
  assert len(prompts) == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  from fastmcp import FastMCP
16
  from fastmcp.client import Client
17
+ from fastmcp.exceptions import ClientError
18
  from fastmcp.server.openapi import (
19
  FastMCPOpenAPI,
20
  OpenAPIResource,
 
54
  """Get all users."""
55
  return sorted(users_db.values(), key=lambda x: x.id)
56
 
57
+ @app.get("/search", tags=["search"])
58
+ async def search_users(
59
+ name: str | None = None, active: bool | None = None, min_id: int | None = None
60
+ ) -> list[User]:
61
+ """Search users with optional filters."""
62
+ results = list(users_db.values())
63
+
64
+ if name is not None:
65
+ results = [u for u in results if name.lower() in u.name.lower()]
66
+ if active is not None:
67
+ results = [u for u in results if u.active == active]
68
+ if min_id is not None:
69
+ results = [u for u in results if u.id >= min_id]
70
+
71
+ return sorted(results, key=lambda x: x.id)
72
+
73
  @app.get("/users/{user_id}", tags=["users", "detail"])
74
  async def get_user(user_id: int) -> User | None:
75
  """Get a user by ID."""
 
321
  """
322
  async with Client(fastmcp_openapi_server) as client:
323
  resources = await client.list_resources()
324
+ assert len(resources) == 4
325
  assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
326
  assert resources[0].name == "get_users_users_get"
327
 
 
921
  # Check that resources are available with prefixed URIs
922
  async with Client(mcp) as client:
923
  resources = await client.list_resources()
924
+ assert len(resources) == 4 # Updated to account for new search endpoint
925
  # We're checking the key used by mcp to store the resource
926
  # The prefixed URI is used as the key, but the resource's original uri is preserved
927
  prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
 
949
  async with Client(mcp) as client:
950
  prompts = await client.list_prompts()
951
  assert len(prompts) == 0
952
+
953
+
954
+ async def test_empty_query_parameters_not_sent(
955
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
956
+ ):
957
+ """Test that empty and None query parameters are not sent in the request."""
958
+
959
+ # Create a TransportAdapter to track requests
960
+ class RequestCapture(httpx.AsyncBaseTransport):
961
+ def __init__(self, wrapped):
962
+ self.wrapped = wrapped
963
+ self.requests = []
964
+
965
+ async def handle_async_request(self, request):
966
+ self.requests.append(request)
967
+ return await self.wrapped.handle_async_request(request)
968
+
969
+ # Use our transport adapter to wrap the original one
970
+ capture = RequestCapture(api_client._transport)
971
+ api_client._transport = capture
972
+
973
+ # Create the OpenAPI server with new route map to make search endpoint a tool
974
+ openapi_spec = fastapi_app.openapi()
975
+ mcp_server = FastMCPOpenAPI(
976
+ openapi_spec=openapi_spec,
977
+ client=api_client,
978
+ route_maps=[
979
+ RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
980
+ ],
981
+ )
982
+
983
+ # Call the search tool with mixed parameter values
984
+ async with Client(mcp_server) as client:
985
+ await client.call_tool(
986
+ "search_users_search_get",
987
+ {
988
+ "name": "", # Empty string should be excluded
989
+ "active": None, # None should be excluded
990
+ "min_id": 2, # Has value, should be included
991
+ },
992
+ )
993
+
994
+ # Verify that the request URL only has min_id parameter
995
+ assert len(capture.requests) > 0
996
+ request = capture.requests[-1] # Get the last request
997
+
998
+ # URL should only contain min_id=2, not name= or active=
999
+ url = str(request.url)
1000
+ assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
1001
+ assert "name=" not in url, f"URL should not contain name=, got: {url}"
1002
+ assert "active=" not in url, f"URL should not contain active=, got: {url}"
1003
+
1004
+ # More direct check - parse the URL to examine query params
1005
+ from urllib.parse import parse_qs, urlparse
1006
+
1007
+ parsed_url = urlparse(url)
1008
+ query_params = parse_qs(parsed_url.query)
1009
+
1010
+ assert "min_id" in query_params
1011
+ assert "name" not in query_params
1012
+ assert "active" not in query_params
1013
+
1014
+
1015
+ async def test_none_path_parameters_rejected(
1016
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
1017
+ ):
1018
+ """Test that None values for path parameters are properly rejected."""
1019
+ # Create the OpenAPI server
1020
+ openapi_spec = fastapi_app.openapi()
1021
+ mcp_server = FastMCPOpenAPI(
1022
+ openapi_spec=openapi_spec,
1023
+ client=api_client,
1024
+ )
1025
+
1026
+ # Create a client and try to call a tool with a None path parameter
1027
+ async with Client(mcp_server) as client:
1028
+ # get_user has a required path parameter user_id
1029
+ with pytest.raises(ClientError, match="Missing required path parameters"):
1030
+ await client.call_tool(
1031
+ "update_user_name_users__user_id__name_patch",
1032
+ {
1033
+ "user_id": None, # This should cause an error
1034
+ "name": "New Name",
1035
+ },
1036
+ )