Jeremiah Lowin Claude commited on
Commit
7004e72
·
1 Parent(s): 00309ef

Fix OpenAPI parameter name collisions with location suffixing

Browse files

Resolves parameter conflicts when same name appears in path/query/header
and request body by adding double underscore suffixes (id__path, id__query).
Body parameters keep original names for natural REST API patterns.

Closes #1100

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

src/fastmcp/server/openapi.py CHANGED
@@ -261,19 +261,45 @@ class OpenAPITool(Tool):
261
  async def run(self, arguments: dict[str, Any]) -> ToolResult:
262
  """Execute the HTTP request based on the route configuration."""
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  # Prepare URL
265
  path = self._route.path
266
 
267
- # Replace path parameters with values from kwargs
268
- # Path parameters should never be None as they're typically required
269
- # but we'll handle that case anyway
270
- path_params = {
271
- p.name: arguments.get(p.name)
272
- for p in self._route.parameters
273
- if p.location == "path"
274
- and p.name in arguments
275
- and arguments.get(p.name) is not None
276
- }
 
 
 
 
277
 
278
  # Ensure all path parameters are provided
279
  required_path_params = {
@@ -312,35 +338,49 @@ class OpenAPITool(Tool):
312
  # Prepare query parameters - filter out None and empty strings
313
  query_params = {}
314
  for p in self._route.parameters:
315
- if (
316
- p.location == "query"
317
- and p.name in arguments
318
- and arguments.get(p.name) is not None
319
- and arguments.get(p.name) != ""
320
- ):
321
- param_value = arguments.get(p.name)
322
-
323
- # Format array query parameters as comma-separated strings
324
- # following OpenAPI form style (default for query parameters)
325
- if isinstance(param_value, list) and p.schema_.get("type") == "array":
326
- # Get explode parameter from the parameter info, default is True for query parameters
327
- # If explode is True, the array is serialized as separate parameters
328
- # If explode is False, the array is serialized as a comma-separated string
329
- explode = p.explode if p.explode is not None else True
330
-
331
- if explode:
332
- # When explode=True, we pass the array directly, which HTTPX will serialize
333
- # as multiple parameters with the same name
334
- query_params[p.name] = param_value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  else:
336
- # Format array as comma-separated string when explode=False
337
- formatted_value = format_array_parameter(
338
- param_value, p.name, is_query_parameter=True
339
- )
340
- query_params[p.name] = formatted_value
341
- else:
342
- # Non-array parameters are passed as is
343
- query_params[p.name] = param_value
344
 
345
  # Prepare headers - fix typing by ensuring all values are strings
346
  headers = {}
@@ -348,12 +388,21 @@ class OpenAPITool(Tool):
348
  # Start with OpenAPI-defined header parameters
349
  openapi_headers = {}
350
  for p in self._route.parameters:
351
- if (
352
- p.location == "header"
353
- and p.name in arguments
354
- and arguments[p.name] is not None
355
- ):
356
- openapi_headers[p.name.lower()] = str(arguments[p.name])
 
 
 
 
 
 
 
 
 
357
  headers.update(openapi_headers)
358
 
359
  # Add headers from the current MCP client HTTP request (these take precedence)
@@ -363,16 +412,22 @@ class OpenAPITool(Tool):
363
  # Prepare request body
364
  json_data = None
365
  if self._route.request_body and self._route.request_body.content_schema:
366
- # Extract body parameters, excluding path/query/header params that were already used
367
- path_query_header_params = {
368
- p.name
369
- for p in self._route.parameters
370
- if p.location in ("path", "query", "header")
371
- }
 
 
 
 
 
 
372
  body_params = {
373
  k: v
374
  for k, v in arguments.items()
375
- if k not in path_query_header_params and k != "context"
376
  }
377
 
378
  if body_params:
 
261
  async def run(self, arguments: dict[str, Any]) -> ToolResult:
262
  """Execute the HTTP request based on the route configuration."""
263
 
264
+ # Create mapping from suffixed parameter names back to original names and locations
265
+ # This handles parameter collisions where suffixes were added during schema generation
266
+ param_mapping = {} # suffixed_name -> (original_name, location)
267
+
268
+ # First, check if we have request body properties to detect collisions
269
+ body_props = set()
270
+ if self._route.request_body and self._route.request_body.content_schema:
271
+ content_type = next(iter(self._route.request_body.content_schema))
272
+ body_schema = self._route.request_body.content_schema[content_type]
273
+ body_props = set(body_schema.get("properties", {}).keys())
274
+
275
+ # Build parameter mapping for potentially suffixed parameters
276
+ for param in self._route.parameters:
277
+ original_name = param.name
278
+ suffixed_name = f"{param.name}__{param.location}"
279
+
280
+ # If parameter name collides with body property, it would have been suffixed
281
+ if param.name in body_props:
282
+ param_mapping[suffixed_name] = (original_name, param.location)
283
+ # Also map original name for backward compatibility when no collision
284
+ param_mapping[original_name] = (original_name, param.location)
285
+
286
  # Prepare URL
287
  path = self._route.path
288
 
289
+ # Replace path parameters with values from arguments
290
+ # Look for both original and suffixed parameter names
291
+ path_params = {}
292
+ for p in self._route.parameters:
293
+ if p.location == "path":
294
+ # Try suffixed name first, then original name
295
+ suffixed_name = f"{p.name}__{p.location}"
296
+ if (
297
+ suffixed_name in arguments
298
+ and arguments.get(suffixed_name) is not None
299
+ ):
300
+ path_params[p.name] = arguments[suffixed_name]
301
+ elif p.name in arguments and arguments.get(p.name) is not None:
302
+ path_params[p.name] = arguments[p.name]
303
 
304
  # Ensure all path parameters are provided
305
  required_path_params = {
 
338
  # Prepare query parameters - filter out None and empty strings
339
  query_params = {}
340
  for p in self._route.parameters:
341
+ if p.location == "query":
342
+ # Try suffixed name first, then original name
343
+ suffixed_name = f"{p.name}__{p.location}"
344
+ param_value = None
345
+
346
+ if (
347
+ suffixed_name in arguments
348
+ and arguments.get(suffixed_name) is not None
349
+ and arguments.get(suffixed_name) != ""
350
+ ):
351
+ param_value = arguments[suffixed_name]
352
+ elif (
353
+ p.name in arguments
354
+ and arguments.get(p.name) is not None
355
+ and arguments.get(p.name) != ""
356
+ ):
357
+ param_value = arguments[p.name]
358
+
359
+ if param_value is not None:
360
+ # Format array query parameters as comma-separated strings
361
+ # following OpenAPI form style (default for query parameters)
362
+ if (
363
+ isinstance(param_value, list)
364
+ and p.schema_.get("type") == "array"
365
+ ):
366
+ # Get explode parameter from the parameter info, default is True for query parameters
367
+ # If explode is True, the array is serialized as separate parameters
368
+ # If explode is False, the array is serialized as a comma-separated string
369
+ explode = p.explode if p.explode is not None else True
370
+
371
+ if explode:
372
+ # When explode=True, we pass the array directly, which HTTPX will serialize
373
+ # as multiple parameters with the same name
374
+ query_params[p.name] = param_value
375
+ else:
376
+ # Format array as comma-separated string when explode=False
377
+ formatted_value = format_array_parameter(
378
+ param_value, p.name, is_query_parameter=True
379
+ )
380
+ query_params[p.name] = formatted_value
381
  else:
382
+ # Non-array parameters are passed as is
383
+ query_params[p.name] = param_value
 
 
 
 
 
 
384
 
385
  # Prepare headers - fix typing by ensuring all values are strings
386
  headers = {}
 
388
  # Start with OpenAPI-defined header parameters
389
  openapi_headers = {}
390
  for p in self._route.parameters:
391
+ if p.location == "header":
392
+ # Try suffixed name first, then original name
393
+ suffixed_name = f"{p.name}__{p.location}"
394
+ param_value = None
395
+
396
+ if (
397
+ suffixed_name in arguments
398
+ and arguments.get(suffixed_name) is not None
399
+ ):
400
+ param_value = arguments[suffixed_name]
401
+ elif p.name in arguments and arguments.get(p.name) is not None:
402
+ param_value = arguments[p.name]
403
+
404
+ if param_value is not None:
405
+ openapi_headers[p.name.lower()] = str(param_value)
406
  headers.update(openapi_headers)
407
 
408
  # Add headers from the current MCP client HTTP request (these take precedence)
 
412
  # Prepare request body
413
  json_data = None
414
  if self._route.request_body and self._route.request_body.content_schema:
415
+ # Extract body parameters with collision-aware logic
416
+ # Exclude all parameter names that belong to path/query/header locations
417
+ params_to_exclude = set()
418
+
419
+ for p in self._route.parameters:
420
+ if (
421
+ p.name in body_props
422
+ ): # This parameter had a collision, so it was suffixed
423
+ params_to_exclude.add(f"{p.name}__{p.location}")
424
+ else: # No collision, parameter keeps original name but should still be excluded from body
425
+ params_to_exclude.add(p.name)
426
+
427
  body_params = {
428
  k: v
429
  for k, v in arguments.items()
430
+ if k not in params_to_exclude and k != "context"
431
  }
432
 
433
  if body_params:
src/fastmcp/utilities/openapi.py CHANGED
@@ -1060,6 +1060,7 @@ def _replace_ref_with_defs(
1060
  def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1061
  """
1062
  Combines parameter and request body schemas into a single schema.
 
1063
 
1064
  Args:
1065
  route: HTTPRoute object
@@ -1070,17 +1071,19 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1070
  properties = {}
1071
  required = []
1072
 
1073
- # Add path parameters
 
 
 
 
 
 
 
 
1074
  for param in route.parameters:
1075
- if param.required:
1076
- required.append(param.name)
1077
- properties[param.name] = _replace_ref_with_defs(
1078
- param.schema_.copy(), param.description
1079
- )
1080
 
1081
- # Add request body if it exists
1082
  if route.request_body and route.request_body.content_schema:
1083
- # For now, just use the first content type's schema
1084
  content_type = next(iter(route.request_body.content_schema))
1085
  body_schema = _replace_ref_with_defs(
1086
  route.request_body.content_schema[content_type].copy(),
@@ -1088,7 +1091,44 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1088
  )
1089
  body_props = body_schema.get("properties", {})
1090
 
1091
- # Add request body properties
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1092
  for prop_name, prop_schema in body_props.items():
1093
  properties[prop_name] = prop_schema
1094
 
 
1060
  def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1061
  """
1062
  Combines parameter and request body schemas into a single schema.
1063
+ Handles parameter name collisions by adding location suffixes.
1064
 
1065
  Args:
1066
  route: HTTPRoute object
 
1071
  properties = {}
1072
  required = []
1073
 
1074
+ # First pass: collect parameter names by location and body properties
1075
+ param_names_by_location = {
1076
+ "path": set(),
1077
+ "query": set(),
1078
+ "header": set(),
1079
+ "cookie": set(),
1080
+ }
1081
+ body_props = {}
1082
+
1083
  for param in route.parameters:
1084
+ param_names_by_location[param.location].add(param.name)
 
 
 
 
1085
 
 
1086
  if route.request_body and route.request_body.content_schema:
 
1087
  content_type = next(iter(route.request_body.content_schema))
1088
  body_schema = _replace_ref_with_defs(
1089
  route.request_body.content_schema[content_type].copy(),
 
1091
  )
1092
  body_props = body_schema.get("properties", {})
1093
 
1094
+ # Detect collisions: parameters that exist in both body and path/query/header
1095
+ all_non_body_params = set()
1096
+ for location_params in param_names_by_location.values():
1097
+ all_non_body_params.update(location_params)
1098
+
1099
+ body_param_names = set(body_props.keys())
1100
+ colliding_params = all_non_body_params & body_param_names
1101
+
1102
+ # Add parameters with suffixes for collisions
1103
+ for param in route.parameters:
1104
+ if param.name in colliding_params:
1105
+ # Add suffix for non-body parameters when collision detected
1106
+ suffixed_name = f"{param.name}__{param.location}"
1107
+ if param.required:
1108
+ required.append(suffixed_name)
1109
+
1110
+ # Add location info to description
1111
+ param_schema = _replace_ref_with_defs(
1112
+ param.schema_.copy(), param.description
1113
+ )
1114
+ original_desc = param_schema.get("description", "")
1115
+ location_desc = f"({param.location.capitalize()} parameter)"
1116
+ if original_desc:
1117
+ param_schema["description"] = f"{original_desc} {location_desc}"
1118
+ else:
1119
+ param_schema["description"] = location_desc
1120
+
1121
+ properties[suffixed_name] = param_schema
1122
+ else:
1123
+ # No collision, use original name
1124
+ if param.required:
1125
+ required.append(param.name)
1126
+ properties[param.name] = _replace_ref_with_defs(
1127
+ param.schema_.copy(), param.description
1128
+ )
1129
+
1130
+ # Add request body properties (no suffixes for body parameters)
1131
+ if route.request_body and route.request_body.content_schema:
1132
  for prop_name, prop_schema in body_props.items():
1133
  properties[prop_name] = prop_schema
1134
 
tests/server/openapi/test_parameter_collisions.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for handling parameter name collisions between different OpenAPI parameter locations."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from fastmcp.server.openapi import OpenAPITool
9
+ from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, RequestBodyInfo
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_client():
14
+ """Create a mock httpx.AsyncClient."""
15
+ client = AsyncMock(spec=httpx.AsyncClient)
16
+ mock_response = MagicMock()
17
+ mock_response.json.return_value = {"result": "success"}
18
+ mock_response.raise_for_status.return_value = None
19
+ client.request.return_value = mock_response
20
+ return client
21
+
22
+
23
+ class TestParameterCollisions:
24
+ """Test parameter name collisions between path/query/header and body parameters."""
25
+
26
+ async def test_path_body_collision_current_broken_behavior(self, mock_client):
27
+ """
28
+ Demonstrates the current broken behavior when a parameter exists in both path and body.
29
+ This test should FAIL with the current implementation.
30
+ """
31
+ # Create route with collision: id in both path and body
32
+ route = HTTPRoute(
33
+ path="/users/{id}",
34
+ method="PUT",
35
+ operation_id="update_user",
36
+ parameters=[
37
+ ParameterInfo(
38
+ name="id",
39
+ location="path",
40
+ required=True,
41
+ schema={"type": "integer"},
42
+ )
43
+ ],
44
+ request_body=RequestBodyInfo(
45
+ content_schema={
46
+ "application/json": {
47
+ "type": "object",
48
+ "properties": {
49
+ "id": {"type": "integer", "description": "User ID"},
50
+ "name": {"type": "string", "description": "User name"},
51
+ "email": {"type": "string", "description": "User email"},
52
+ },
53
+ "required": ["id", "name"],
54
+ }
55
+ }
56
+ ),
57
+ )
58
+
59
+ # Create tool with current implementation
60
+ tool = OpenAPITool(
61
+ client=mock_client,
62
+ route=route,
63
+ name="update_user",
64
+ description="Update user",
65
+ parameters={}, # Schema would be generated by _combine_schemas
66
+ )
67
+
68
+ # This call should work but currently fails because body 'id' is excluded
69
+ arguments = {"id": 123, "name": "John Doe", "email": "john@example.com"}
70
+
71
+ await tool.run(arguments)
72
+
73
+ # Check what was actually sent
74
+ call_args = mock_client.request.call_args
75
+ assert call_args is not None
76
+
77
+ # Current broken behavior: id goes to path but is excluded from body
78
+ # This means the body is missing the required 'id' field
79
+ assert call_args[1]["url"] == "/users/123" # Path parameter works
80
+
81
+ # This assertion will FAIL with current implementation because 'id' is excluded from body
82
+ expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
83
+ assert call_args[1]["json"] == expected_body, (
84
+ "Body should include 'id' parameter"
85
+ )
86
+
87
+ async def test_path_body_collision_with_suffixing(self, mock_client):
88
+ """
89
+ Test the desired behavior with parameter suffixing.
90
+ This test should PASS after implementing the fix.
91
+ """
92
+ # Create route with collision: id in both path and body
93
+ route = HTTPRoute(
94
+ path="/users/{id}",
95
+ method="PUT",
96
+ operation_id="update_user",
97
+ parameters=[
98
+ ParameterInfo(
99
+ name="id",
100
+ location="path",
101
+ required=True,
102
+ schema={"type": "integer"},
103
+ )
104
+ ],
105
+ request_body=RequestBodyInfo(
106
+ content_schema={
107
+ "application/json": {
108
+ "type": "object",
109
+ "properties": {
110
+ "id": {"type": "integer", "description": "User ID"},
111
+ "name": {"type": "string", "description": "User name"},
112
+ "email": {"type": "string", "description": "User email"},
113
+ },
114
+ "required": ["id", "name"],
115
+ }
116
+ }
117
+ ),
118
+ )
119
+
120
+ # Tool should be created with suffixed schema
121
+ tool = OpenAPITool(
122
+ client=mock_client,
123
+ route=route,
124
+ name="update_user",
125
+ description="Update user",
126
+ parameters={}, # Schema would include id__path and id
127
+ )
128
+
129
+ # LLM would call with suffixed parameters
130
+ arguments = {
131
+ "id__path": 123, # Goes to path parameter
132
+ "id": 123, # Goes to body parameter
133
+ "name": "John Doe",
134
+ "email": "john@example.com",
135
+ }
136
+
137
+ await tool.run(arguments)
138
+
139
+ # Verify correct request was made
140
+ call_args = mock_client.request.call_args
141
+ assert call_args is not None
142
+
143
+ # Path parameter should be populated from id__path
144
+ assert call_args[1]["url"] == "/users/123"
145
+
146
+ # Body should include id (from unsuffixed parameter)
147
+ expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
148
+ assert call_args[1]["json"] == expected_body
149
+
150
+ async def test_query_body_collision_with_suffixing(self, mock_client):
151
+ """Test parameter collision between query and body parameters."""
152
+ route = HTTPRoute(
153
+ path="/search",
154
+ method="POST",
155
+ operation_id="search_users",
156
+ parameters=[
157
+ ParameterInfo(
158
+ name="limit",
159
+ location="query",
160
+ required=False,
161
+ schema={"type": "integer", "default": 10},
162
+ )
163
+ ],
164
+ request_body=RequestBodyInfo(
165
+ content_schema={
166
+ "application/json": {
167
+ "type": "object",
168
+ "properties": {
169
+ "limit": {
170
+ "type": "integer",
171
+ "description": "Max results in response",
172
+ },
173
+ "query": {"type": "string", "description": "Search query"},
174
+ },
175
+ "required": ["query"],
176
+ }
177
+ }
178
+ ),
179
+ )
180
+
181
+ tool = OpenAPITool(
182
+ client=mock_client,
183
+ route=route,
184
+ name="search_users",
185
+ description="Search users",
186
+ parameters={},
187
+ )
188
+
189
+ # LLM call with suffixed parameters
190
+ arguments = {
191
+ "limit__query": 5, # Goes to query parameter
192
+ "limit": 100, # Goes to body parameter
193
+ "query": "john",
194
+ }
195
+
196
+ await tool.run(arguments)
197
+
198
+ call_args = mock_client.request.call_args
199
+ assert call_args is not None
200
+
201
+ # Query parameter from limit__query
202
+ assert call_args[1]["params"] == {"limit": 5}
203
+
204
+ # Body includes limit from unsuffixed parameter
205
+ expected_body = {"limit": 100, "query": "john"}
206
+ assert call_args[1]["json"] == expected_body
207
+
208
+ async def test_no_collisions_unchanged_behavior(self, mock_client):
209
+ """Test that parameters with no collisions keep original names."""
210
+ route = HTTPRoute(
211
+ path="/users/{user_id}",
212
+ method="POST",
213
+ operation_id="create_user",
214
+ parameters=[
215
+ ParameterInfo(
216
+ name="user_id",
217
+ location="path",
218
+ required=True,
219
+ schema={"type": "integer"},
220
+ )
221
+ ],
222
+ request_body=RequestBodyInfo(
223
+ content_schema={
224
+ "application/json": {
225
+ "type": "object",
226
+ "properties": {
227
+ "name": {"type": "string"},
228
+ "email": {"type": "string"},
229
+ },
230
+ "required": ["name"],
231
+ }
232
+ }
233
+ ),
234
+ )
235
+
236
+ tool = OpenAPITool(
237
+ client=mock_client,
238
+ route=route,
239
+ name="create_user",
240
+ description="Create user",
241
+ parameters={},
242
+ )
243
+
244
+ # No collisions, so original parameter names should work
245
+ arguments = {
246
+ "user_id": 123, # Path parameter (no suffix needed)
247
+ "name": "John", # Body parameter
248
+ "email": "john@example.com",
249
+ }
250
+
251
+ await tool.run(arguments)
252
+
253
+ call_args = mock_client.request.call_args
254
+ assert call_args is not None
255
+
256
+ assert call_args[1]["url"] == "/users/123"
257
+ expected_body = {"name": "John", "email": "john@example.com"}
258
+ assert call_args[1]["json"] == expected_body