Jeremiah Lowin commited on
Commit
fbc96f7
·
unverified ·
2 Parent(s): 3e1b37271ef915

Merge pull request #1008 from jlowin/claude-wt-20250701-104804

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -27,6 +27,7 @@ from fastmcp.utilities.logging import get_logger
27
  from fastmcp.utilities.openapi import (
28
  HTTPRoute,
29
  _combine_schemas,
 
30
  format_description_with_responses,
31
  )
32
 
@@ -296,46 +297,10 @@ class OpenAPITool(Tool):
296
  if is_array:
297
  # Format array values as comma-separated string
298
  # This follows the OpenAPI 'simple' style (default for path)
299
- if all(
300
- isinstance(item, str | int | float | bool)
301
- for item in param_value
302
- ):
303
- # Handle simple array types
304
- path = path.replace(
305
- f"{{{param_name}}}", ",".join(str(v) for v in param_value)
306
- )
307
- else:
308
- # Handle complex array types (containing objects/dicts)
309
- try:
310
- # Try to create a simple representation without Python syntax artifacts
311
- formatted_parts = []
312
- for item in param_value:
313
- if isinstance(item, dict):
314
- # For objects, serialize key-value pairs
315
- item_parts = []
316
- for k, v in item.items():
317
- item_parts.append(f"{k}:{v}")
318
- formatted_parts.append(".".join(item_parts))
319
- else:
320
- # Fallback for other complex types
321
- formatted_parts.append(str(item))
322
-
323
- # Join parts with commas
324
- formatted_value = ",".join(formatted_parts)
325
- path = path.replace(f"{{{param_name}}}", formatted_value)
326
- except Exception as e:
327
- logger.warning(
328
- f"Failed to format complex array path parameter '{param_name}': {e}"
329
- )
330
- # Fallback to string representation, but remove Python syntax artifacts
331
- str_value = (
332
- str(param_value)
333
- .replace("[", "")
334
- .replace("]", "")
335
- .replace("'", "")
336
- .replace('"', "")
337
- )
338
- path = path.replace(f"{{{param_name}}}", str_value)
339
  continue
340
 
341
  # Default handling for non-array parameters or non-array schemas
@@ -355,44 +320,21 @@ class OpenAPITool(Tool):
355
  # Format array query parameters as comma-separated strings
356
  # following OpenAPI form style (default for query parameters)
357
  if isinstance(param_value, list) and p.schema_.get("type") == "array":
358
- # Get explode parameter from schema, default is True for query parameters
359
  # If explode is True, the array is serialized as separate parameters
360
  # If explode is False, the array is serialized as a comma-separated string
361
- explode = p.schema_.get("explode", True)
362
 
363
  if explode:
364
  # When explode=True, we pass the array directly, which HTTPX will serialize
365
  # as multiple parameters with the same name
366
  query_params[p.name] = param_value
367
  else:
368
- # For arrays of simple types (strings, numbers, etc.), join with commas
369
- if all(
370
- isinstance(item, str | int | float | bool)
371
- for item in param_value
372
- ):
373
- query_params[p.name] = ",".join(str(v) for v in param_value)
374
- else:
375
- # For complex types, try to create a simpler representation
376
- try:
377
- # Try to create a simple string representation
378
- formatted_parts = []
379
- for item in param_value:
380
- if isinstance(item, dict):
381
- # For objects, serialize key-value pairs
382
- item_parts = []
383
- for k, v in item.items():
384
- item_parts.append(f"{k}:{v}")
385
- formatted_parts.append(".".join(item_parts))
386
- else:
387
- formatted_parts.append(str(item))
388
-
389
- query_params[p.name] = ",".join(formatted_parts)
390
- except Exception as e:
391
- logger.warning(
392
- f"Failed to format complex array query parameter '{p.name}': {e}"
393
- )
394
- # Fallback to string representation
395
- query_params[p.name] = param_value
396
  else:
397
  # Non-array parameters are passed as is
398
  query_params[p.name] = param_value
 
27
  from fastmcp.utilities.openapi import (
28
  HTTPRoute,
29
  _combine_schemas,
30
+ format_array_parameter,
31
  format_description_with_responses,
32
  )
33
 
 
297
  if is_array:
298
  # Format array values as comma-separated string
299
  # This follows the OpenAPI 'simple' style (default for path)
300
+ formatted_value = format_array_parameter(
301
+ param_value, param_name, is_query_parameter=False
302
+ )
303
+ path = path.replace(f"{{{param_name}}}", str(formatted_value))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  continue
305
 
306
  # Default handling for non-array parameters or non-array schemas
 
320
  # Format array query parameters as comma-separated strings
321
  # following OpenAPI form style (default for query parameters)
322
  if isinstance(param_value, list) and p.schema_.get("type") == "array":
323
+ # Get explode parameter from the parameter info, default is True for query parameters
324
  # If explode is True, the array is serialized as separate parameters
325
  # If explode is False, the array is serialized as a comma-separated string
326
+ explode = p.explode if p.explode is not None else True
327
 
328
  if explode:
329
  # When explode=True, we pass the array directly, which HTTPX will serialize
330
  # as multiple parameters with the same name
331
  query_params[p.name] = param_value
332
  else:
333
+ # Format array as comma-separated string when explode=False
334
+ formatted_value = format_array_parameter(
335
+ param_value, p.name, is_query_parameter=True
336
+ )
337
+ query_params[p.name] = formatted_value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  else:
339
  # Non-array parameters are passed as is
340
  query_params[p.name] = param_value
src/fastmcp/utilities/openapi.py CHANGED
@@ -39,6 +39,60 @@ ParameterLocation = Literal["path", "query", "header", "cookie"]
39
  JsonSchema = dict[str, Any]
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  class ParameterInfo(FastMCPBaseModel):
43
  """Represents a single parameter for an HTTP operation in our IR."""
44
 
@@ -47,6 +101,7 @@ class ParameterInfo(FastMCPBaseModel):
47
  required: bool = False
48
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
49
  description: str | None = None
 
50
 
51
 
52
  class RequestBodyInfo(FastMCPBaseModel):
@@ -359,6 +414,9 @@ class OpenAPIParser(
359
  ):
360
  param_schema_dict["default"] = resolved_media_schema.default
361
 
 
 
 
362
  # Create parameter info object
363
  param_info = ParameterInfo(
364
  name=parameter.name,
@@ -366,6 +424,7 @@ class OpenAPIParser(
366
  required=parameter.required,
367
  schema=param_schema_dict,
368
  description=parameter.description,
 
369
  )
370
  extracted_params.append(param_info)
371
  except Exception as e:
 
39
  JsonSchema = dict[str, Any]
40
 
41
 
42
+ def format_array_parameter(
43
+ values: list, parameter_name: str, is_query_parameter: bool = False
44
+ ) -> str | list:
45
+ """
46
+ Format an array parameter according to OpenAPI specifications.
47
+
48
+ Args:
49
+ values: List of values to format
50
+ parameter_name: Name of the parameter (for error messages)
51
+ is_query_parameter: If True, can return list for explode=True behavior
52
+
53
+ Returns:
54
+ String (comma-separated) or list (for query params with explode=True)
55
+ """
56
+ # For arrays of simple types (strings, numbers, etc.), join with commas
57
+ if all(isinstance(item, str | int | float | bool) for item in values):
58
+ return ",".join(str(v) for v in values)
59
+
60
+ # For complex types, try to create a simpler representation
61
+ try:
62
+ # Try to create a simple string representation
63
+ formatted_parts = []
64
+ for item in values:
65
+ if isinstance(item, dict):
66
+ # For objects, serialize key-value pairs
67
+ item_parts = []
68
+ for k, v in item.items():
69
+ item_parts.append(f"{k}:{v}")
70
+ formatted_parts.append(".".join(item_parts))
71
+ else:
72
+ formatted_parts.append(str(item))
73
+
74
+ return ",".join(formatted_parts)
75
+ except Exception as e:
76
+ param_type = "query" if is_query_parameter else "path"
77
+ logger.warning(
78
+ f"Failed to format complex array {param_type} parameter '{parameter_name}': {e}"
79
+ )
80
+
81
+ if is_query_parameter:
82
+ # For query parameters, fallback to original list
83
+ return values
84
+ else:
85
+ # For path parameters, fallback to string representation without Python syntax
86
+ str_value = (
87
+ str(values)
88
+ .replace("[", "")
89
+ .replace("]", "")
90
+ .replace("'", "")
91
+ .replace('"', "")
92
+ )
93
+ return str_value
94
+
95
+
96
  class ParameterInfo(FastMCPBaseModel):
97
  """Represents a single parameter for an HTTP operation in our IR."""
98
 
 
101
  required: bool = False
102
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
103
  description: str | None = None
104
+ explode: bool | None = None # OpenAPI explode property for array parameters
105
 
106
 
107
  class RequestBodyInfo(FastMCPBaseModel):
 
414
  ):
415
  param_schema_dict["default"] = resolved_media_schema.default
416
 
417
+ # Extract explode property if present
418
+ explode = getattr(parameter, "explode", None)
419
+
420
  # Create parameter info object
421
  param_info = ParameterInfo(
422
  name=parameter.name,
 
424
  required=parameter.required,
425
  schema=param_schema_dict,
426
  description=parameter.description,
427
+ explode=explode,
428
  )
429
  extracted_params.append(param_info)
430
  except Exception as e:
tests/server/openapi/test_explode_integration.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration test for OpenAPI explode property handling.
2
+
3
+ This test verifies that the explode property is correctly parsed from OpenAPI
4
+ specifications and properly applied during HTTP request serialization.
5
+ """
6
+
7
+ from unittest.mock import AsyncMock, MagicMock
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from fastmcp.server.openapi import OpenAPITool
13
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
14
+
15
+
16
+ class TestExplodeIntegration:
17
+ """Test the complete pipeline from OpenAPI spec to HTTP request parameters."""
18
+
19
+ def test_explode_false_parsing_from_openapi_spec(self):
20
+ """Test that explode=false is correctly parsed from OpenAPI specification."""
21
+ # Real OpenAPI spec with explode: false
22
+ openapi_spec = {
23
+ "openapi": "3.1.0",
24
+ "info": {"title": "Test API", "version": "1.0.0"},
25
+ "paths": {
26
+ "/search": {
27
+ "get": {
28
+ "operationId": "search_items",
29
+ "parameters": [
30
+ {
31
+ "name": "tags",
32
+ "in": "query",
33
+ "required": False,
34
+ "style": "form",
35
+ "explode": False, # This should be respected
36
+ "schema": {
37
+ "type": "array",
38
+ "items": {"type": "string"},
39
+ },
40
+ }
41
+ ],
42
+ "responses": {
43
+ "200": {
44
+ "description": "Success",
45
+ "content": {
46
+ "application/json": {"schema": {"type": "object"}}
47
+ },
48
+ }
49
+ },
50
+ }
51
+ }
52
+ },
53
+ }
54
+
55
+ # Parse the spec
56
+ routes = parse_openapi_to_http_routes(openapi_spec)
57
+ route = routes[0]
58
+ parameter = route.parameters[0]
59
+
60
+ # Verify explode property was captured correctly
61
+ assert parameter.name == "tags"
62
+ assert parameter.location == "query"
63
+ assert parameter.explode is False, (
64
+ f"Expected explode=False, got {parameter.explode}"
65
+ )
66
+
67
+ def test_explode_true_parsing_from_openapi_spec(self):
68
+ """Test that explode=true is correctly parsed from OpenAPI specification."""
69
+ openapi_spec = {
70
+ "openapi": "3.1.0",
71
+ "info": {"title": "Test API", "version": "1.0.0"},
72
+ "paths": {
73
+ "/search": {
74
+ "get": {
75
+ "operationId": "search_items",
76
+ "parameters": [
77
+ {
78
+ "name": "tags",
79
+ "in": "query",
80
+ "explode": True, # Explicitly set to true
81
+ "schema": {
82
+ "type": "array",
83
+ "items": {"type": "string"},
84
+ },
85
+ }
86
+ ],
87
+ "responses": {"200": {"description": "Success"}},
88
+ }
89
+ }
90
+ },
91
+ }
92
+
93
+ routes = parse_openapi_to_http_routes(openapi_spec)
94
+ parameter = routes[0].parameters[0]
95
+
96
+ assert parameter.explode is True, (
97
+ f"Expected explode=True, got {parameter.explode}"
98
+ )
99
+
100
+ def test_explode_default_parsing_from_openapi_spec(self):
101
+ """Test that missing explode defaults to None during parsing."""
102
+ openapi_spec = {
103
+ "openapi": "3.1.0",
104
+ "info": {"title": "Test API", "version": "1.0.0"},
105
+ "paths": {
106
+ "/search": {
107
+ "get": {
108
+ "operationId": "search_items",
109
+ "parameters": [
110
+ {
111
+ "name": "tags",
112
+ "in": "query",
113
+ "schema": {
114
+ "type": "array",
115
+ "items": {"type": "string"},
116
+ },
117
+ # No explode property specified
118
+ }
119
+ ],
120
+ "responses": {"200": {"description": "Success"}},
121
+ }
122
+ }
123
+ },
124
+ }
125
+
126
+ routes = parse_openapi_to_http_routes(openapi_spec)
127
+ parameter = routes[0].parameters[0]
128
+
129
+ assert parameter.explode is None, (
130
+ f"Expected explode=None, got {parameter.explode}"
131
+ )
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_explode_false_request_serialization(self):
135
+ """Test that explode=false results in comma-separated query parameters in HTTP requests.
136
+
137
+ This is the critical integration test that would have failed before the fix.
138
+ """
139
+ # OpenAPI spec with explode: false
140
+ openapi_spec = {
141
+ "openapi": "3.1.0",
142
+ "info": {"title": "Test API", "version": "1.0.0"},
143
+ "paths": {
144
+ "/search": {
145
+ "get": {
146
+ "operationId": "search_items",
147
+ "parameters": [
148
+ {
149
+ "name": "tags",
150
+ "in": "query",
151
+ "explode": False,
152
+ "schema": {
153
+ "type": "array",
154
+ "items": {"type": "string"},
155
+ },
156
+ }
157
+ ],
158
+ "responses": {"200": {"description": "Success"}},
159
+ }
160
+ }
161
+ },
162
+ }
163
+
164
+ # Parse and create tool
165
+ routes = parse_openapi_to_http_routes(openapi_spec)
166
+ route = routes[0]
167
+
168
+ # Mock HTTP client
169
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
170
+ mock_response = MagicMock()
171
+ mock_response.status_code = 200
172
+ mock_response.json.return_value = {}
173
+ mock_response.raise_for_status.return_value = None
174
+ mock_client.request.return_value = mock_response
175
+
176
+ # Create tool
177
+ tool = OpenAPITool(
178
+ client=mock_client,
179
+ route=route,
180
+ name="search_items",
181
+ description="Search items",
182
+ parameters={},
183
+ )
184
+
185
+ # Execute tool with array parameter
186
+ await tool.run({"tags": ["red", "blue", "green"]})
187
+
188
+ # Verify the HTTP request was made with comma-separated parameters
189
+ mock_client.request.assert_called_once()
190
+ call_kwargs = mock_client.request.call_args.kwargs
191
+
192
+ # Check that params contains comma-separated values, not an array
193
+ params = call_kwargs.get("params", {})
194
+ assert "tags" in params, "tags parameter should be present"
195
+
196
+ tags_value = params["tags"]
197
+ assert isinstance(tags_value, str), (
198
+ f"Expected string for explode=false, got {type(tags_value)}"
199
+ )
200
+ assert tags_value == "red,blue,green", (
201
+ f"Expected 'red,blue,green', got '{tags_value}'"
202
+ )
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_explode_true_request_serialization(self):
206
+ """Test that explode=true results in separate query parameters in HTTP requests."""
207
+ openapi_spec = {
208
+ "openapi": "3.1.0",
209
+ "info": {"title": "Test API", "version": "1.0.0"},
210
+ "paths": {
211
+ "/search": {
212
+ "get": {
213
+ "operationId": "search_items",
214
+ "parameters": [
215
+ {
216
+ "name": "tags",
217
+ "in": "query",
218
+ "explode": True,
219
+ "schema": {
220
+ "type": "array",
221
+ "items": {"type": "string"},
222
+ },
223
+ }
224
+ ],
225
+ "responses": {"200": {"description": "Success"}},
226
+ }
227
+ }
228
+ },
229
+ }
230
+
231
+ routes = parse_openapi_to_http_routes(openapi_spec)
232
+ route = routes[0]
233
+
234
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
235
+ mock_response = MagicMock()
236
+ mock_response.status_code = 200
237
+ mock_response.json.return_value = {}
238
+ mock_response.raise_for_status.return_value = None
239
+ mock_client.request.return_value = mock_response
240
+
241
+ tool = OpenAPITool(
242
+ client=mock_client,
243
+ route=route,
244
+ name="search_items",
245
+ description="Search items",
246
+ parameters={},
247
+ )
248
+
249
+ await tool.run({"tags": ["red", "blue", "green"]})
250
+
251
+ mock_client.request.assert_called_once()
252
+ call_kwargs = mock_client.request.call_args.kwargs
253
+
254
+ params = call_kwargs.get("params", {})
255
+ assert "tags" in params, "tags parameter should be present"
256
+
257
+ tags_value = params["tags"]
258
+ assert isinstance(tags_value, list), (
259
+ f"Expected list for explode=true, got {type(tags_value)}"
260
+ )
261
+ assert tags_value == ["red", "blue", "green"], (
262
+ f"Expected ['red', 'blue', 'green'], got {tags_value}"
263
+ )
264
+
265
+ @pytest.mark.asyncio
266
+ async def test_explode_default_request_serialization(self):
267
+ """Test that default behavior (no explode) uses explode=true for query parameters."""
268
+ openapi_spec = {
269
+ "openapi": "3.1.0",
270
+ "info": {"title": "Test API", "version": "1.0.0"},
271
+ "paths": {
272
+ "/search": {
273
+ "get": {
274
+ "operationId": "search_items",
275
+ "parameters": [
276
+ {
277
+ "name": "tags",
278
+ "in": "query",
279
+ "schema": {
280
+ "type": "array",
281
+ "items": {"type": "string"},
282
+ },
283
+ # No explode specified - should default to true for query params
284
+ }
285
+ ],
286
+ "responses": {"200": {"description": "Success"}},
287
+ }
288
+ }
289
+ },
290
+ }
291
+
292
+ routes = parse_openapi_to_http_routes(openapi_spec)
293
+ route = routes[0]
294
+
295
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
296
+ mock_response = MagicMock()
297
+ mock_response.status_code = 200
298
+ mock_response.json.return_value = {}
299
+ mock_response.raise_for_status.return_value = None
300
+ mock_client.request.return_value = mock_response
301
+
302
+ tool = OpenAPITool(
303
+ client=mock_client,
304
+ route=route,
305
+ name="search_items",
306
+ description="Search items",
307
+ parameters={},
308
+ )
309
+
310
+ await tool.run({"tags": ["red", "blue", "green"]})
311
+
312
+ mock_client.request.assert_called_once()
313
+ call_kwargs = mock_client.request.call_args.kwargs
314
+
315
+ params = call_kwargs.get("params", {})
316
+ tags_value = params["tags"]
317
+
318
+ # Default behavior should be explode=true (separate parameters)
319
+ assert isinstance(tags_value, list), (
320
+ f"Expected list for default behavior, got {type(tags_value)}"
321
+ )
322
+ assert tags_value == ["red", "blue", "green"], (
323
+ f"Expected ['red', 'blue', 'green'], got {tags_value}"
324
+ )
tests/server/openapi/test_openapi_path_parameters.py CHANGED
@@ -320,9 +320,9 @@ async def test_array_query_parameter_format(mock_client):
320
  name="days",
321
  location="query", # This is a query parameter
322
  required=True,
 
323
  schema={
324
  "type": "array",
325
- "explode": False, # Set explode=False to test comma-separated formatting
326
  "items": {
327
  "type": "string",
328
  "enum": [
@@ -390,9 +390,9 @@ async def test_array_query_parameter_exploded_format(mock_client):
390
  name="days",
391
  location="query", # This is a query parameter
392
  required=True,
 
393
  schema={
394
  "type": "array",
395
- "explode": True, # Set explode=True for separate parameter serialization
396
  "items": {
397
  "type": "string",
398
  "enum": [
 
320
  name="days",
321
  location="query", # This is a query parameter
322
  required=True,
323
+ explode=False, # Set explode=False to test comma-separated formatting
324
  schema={
325
  "type": "array",
 
326
  "items": {
327
  "type": "string",
328
  "enum": [
 
390
  name="days",
391
  location="query", # This is a query parameter
392
  required=True,
393
+ explode=True, # Set explode=True for separate parameter serialization
394
  schema={
395
  "type": "array",
 
396
  "items": {
397
  "type": "string",
398
  "enum": [