fastmcp 2.10.5__py3-none-any.whl → 2.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +7 -2
- fastmcp/cli/cli.py +128 -33
- fastmcp/cli/install/__init__.py +2 -2
- fastmcp/cli/install/claude_code.py +42 -1
- fastmcp/cli/install/claude_desktop.py +42 -1
- fastmcp/cli/install/cursor.py +42 -1
- fastmcp/cli/install/{mcp_config.py → mcp_json.py} +51 -7
- fastmcp/cli/run.py +127 -1
- fastmcp/client/__init__.py +2 -0
- fastmcp/client/auth/oauth.py +68 -99
- fastmcp/client/oauth_callback.py +18 -0
- fastmcp/client/transports.py +69 -15
- fastmcp/contrib/component_manager/example.py +2 -2
- fastmcp/experimental/server/openapi/README.md +266 -0
- fastmcp/experimental/server/openapi/__init__.py +38 -0
- fastmcp/experimental/server/openapi/components.py +348 -0
- fastmcp/experimental/server/openapi/routing.py +132 -0
- fastmcp/experimental/server/openapi/server.py +466 -0
- fastmcp/experimental/utilities/openapi/README.md +239 -0
- fastmcp/experimental/utilities/openapi/__init__.py +68 -0
- fastmcp/experimental/utilities/openapi/director.py +208 -0
- fastmcp/experimental/utilities/openapi/formatters.py +355 -0
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
- fastmcp/experimental/utilities/openapi/models.py +85 -0
- fastmcp/experimental/utilities/openapi/parser.py +618 -0
- fastmcp/experimental/utilities/openapi/schemas.py +538 -0
- fastmcp/mcp_config.py +125 -88
- fastmcp/prompts/prompt.py +11 -1
- fastmcp/prompts/prompt_manager.py +1 -1
- fastmcp/resources/resource.py +21 -1
- fastmcp/resources/resource_manager.py +2 -2
- fastmcp/resources/template.py +20 -1
- fastmcp/server/auth/__init__.py +17 -2
- fastmcp/server/auth/auth.py +144 -7
- fastmcp/server/auth/providers/bearer.py +25 -473
- fastmcp/server/auth/providers/in_memory.py +4 -2
- fastmcp/server/auth/providers/jwt.py +538 -0
- fastmcp/server/auth/providers/workos.py +170 -0
- fastmcp/server/auth/registry.py +52 -0
- fastmcp/server/context.py +110 -26
- fastmcp/server/dependencies.py +9 -2
- fastmcp/server/http.py +62 -30
- fastmcp/server/middleware/middleware.py +3 -23
- fastmcp/server/openapi.py +26 -13
- fastmcp/server/proxy.py +89 -8
- fastmcp/server/server.py +170 -62
- fastmcp/settings.py +83 -18
- fastmcp/tools/tool.py +41 -6
- fastmcp/tools/tool_manager.py +39 -3
- fastmcp/tools/tool_transform.py +122 -6
- fastmcp/utilities/components.py +35 -2
- fastmcp/utilities/json_schema.py +136 -98
- fastmcp/utilities/json_schema_type.py +1 -3
- fastmcp/utilities/mcp_config.py +28 -0
- fastmcp/utilities/openapi.py +306 -30
- fastmcp/utilities/tests.py +54 -6
- fastmcp/utilities/types.py +89 -11
- {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
- fastmcp-2.11.0.dist-info/RECORD +108 -0
- fastmcp/server/auth/providers/bearer_env.py +0 -63
- fastmcp/utilities/cache.py +0 -26
- fastmcp-2.10.5.dist-info/RECORD +0 -93
- {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Schema manipulation utilities for OpenAPI operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp.utilities.logging import get_logger
|
|
6
|
+
|
|
7
|
+
from .models import HTTPRoute, JsonSchema, ResponseInfo
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
|
13
|
+
"""
|
|
14
|
+
Clean up a schema dictionary for display by removing internal/complex fields.
|
|
15
|
+
"""
|
|
16
|
+
if not schema or not isinstance(schema, dict):
|
|
17
|
+
return schema
|
|
18
|
+
|
|
19
|
+
# Make a copy to avoid modifying the input schema
|
|
20
|
+
cleaned = schema.copy()
|
|
21
|
+
|
|
22
|
+
# Fields commonly removed for simpler display to LLMs or users
|
|
23
|
+
fields_to_remove = [
|
|
24
|
+
"allOf",
|
|
25
|
+
"anyOf",
|
|
26
|
+
"oneOf",
|
|
27
|
+
"not", # Composition keywords
|
|
28
|
+
"nullable", # Handled by type unions usually
|
|
29
|
+
"discriminator",
|
|
30
|
+
"readOnly",
|
|
31
|
+
"writeOnly",
|
|
32
|
+
"deprecated",
|
|
33
|
+
"xml",
|
|
34
|
+
"externalDocs",
|
|
35
|
+
# Can be verbose, maybe remove based on flag?
|
|
36
|
+
# "pattern", "minLength", "maxLength",
|
|
37
|
+
# "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
|
|
38
|
+
# "multipleOf", "minItems", "maxItems", "uniqueItems",
|
|
39
|
+
# "minProperties", "maxProperties"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
for field in fields_to_remove:
|
|
43
|
+
if field in cleaned:
|
|
44
|
+
cleaned.pop(field)
|
|
45
|
+
|
|
46
|
+
# Recursively clean properties and items
|
|
47
|
+
if "properties" in cleaned:
|
|
48
|
+
cleaned["properties"] = {
|
|
49
|
+
k: clean_schema_for_display(v) for k, v in cleaned["properties"].items()
|
|
50
|
+
}
|
|
51
|
+
# Remove properties section if empty after cleaning
|
|
52
|
+
if not cleaned["properties"]:
|
|
53
|
+
cleaned.pop("properties")
|
|
54
|
+
|
|
55
|
+
if "items" in cleaned:
|
|
56
|
+
cleaned["items"] = clean_schema_for_display(cleaned["items"])
|
|
57
|
+
# Remove items section if empty after cleaning
|
|
58
|
+
if not cleaned["items"]:
|
|
59
|
+
cleaned.pop("items")
|
|
60
|
+
|
|
61
|
+
if "additionalProperties" in cleaned:
|
|
62
|
+
# Often verbose, can be simplified
|
|
63
|
+
if isinstance(cleaned["additionalProperties"], dict):
|
|
64
|
+
cleaned["additionalProperties"] = clean_schema_for_display(
|
|
65
|
+
cleaned["additionalProperties"]
|
|
66
|
+
)
|
|
67
|
+
elif cleaned["additionalProperties"] is True:
|
|
68
|
+
# Maybe keep 'true' or represent as 'Allows additional properties' text?
|
|
69
|
+
pass # Keep simple boolean for now
|
|
70
|
+
|
|
71
|
+
return cleaned
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _replace_ref_with_defs(
|
|
75
|
+
info: dict[str, Any], description: str | None = None
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Replace openapi $ref with jsonschema $defs
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
82
|
+
- {"$ref": "#/components/schemas/..."}
|
|
83
|
+
- {"items": {"$ref": "#/components/schemas/..."}}
|
|
84
|
+
- {"anyOf": [{"$ref": "#/components/schemas/..."}]}
|
|
85
|
+
- {"allOf": [{"$ref": "#/components/schemas/..."}]}
|
|
86
|
+
- {"oneOf": [{"$ref": "#/components/schemas/..."}]}
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
info: dict[str, Any]
|
|
90
|
+
description: str | None
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
dict[str, Any]
|
|
94
|
+
"""
|
|
95
|
+
schema = info.copy()
|
|
96
|
+
if ref_path := schema.get("$ref"):
|
|
97
|
+
if isinstance(ref_path, str):
|
|
98
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
99
|
+
schema_name = ref_path.split("/")[-1]
|
|
100
|
+
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
101
|
+
elif not ref_path.startswith("#/"):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"External or non-local reference not supported: {ref_path}. "
|
|
104
|
+
f"FastMCP only supports local schema references starting with '#/'. "
|
|
105
|
+
f"Please include all schema definitions within the OpenAPI document."
|
|
106
|
+
)
|
|
107
|
+
elif properties := schema.get("properties"):
|
|
108
|
+
if "$ref" in properties:
|
|
109
|
+
schema["properties"] = _replace_ref_with_defs(properties)
|
|
110
|
+
else:
|
|
111
|
+
schema["properties"] = {
|
|
112
|
+
prop_name: _replace_ref_with_defs(prop_schema)
|
|
113
|
+
for prop_name, prop_schema in properties.items()
|
|
114
|
+
}
|
|
115
|
+
elif item_schema := schema.get("items"):
|
|
116
|
+
schema["items"] = _replace_ref_with_defs(item_schema)
|
|
117
|
+
for section in ["anyOf", "allOf", "oneOf"]:
|
|
118
|
+
for i, item in enumerate(schema.get(section, [])):
|
|
119
|
+
schema[section][i] = _replace_ref_with_defs(item)
|
|
120
|
+
if info.get("description", description) and not schema.get("description"):
|
|
121
|
+
schema["description"] = description
|
|
122
|
+
return schema
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
"""
|
|
127
|
+
Make an optional parameter schema nullable to allow None values.
|
|
128
|
+
|
|
129
|
+
For optional parameters, we need to allow null values in addition to the
|
|
130
|
+
specified type to handle cases where None is passed for optional parameters.
|
|
131
|
+
"""
|
|
132
|
+
# If schema already has multiple types or is already nullable, don't modify
|
|
133
|
+
if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
|
|
134
|
+
return schema
|
|
135
|
+
|
|
136
|
+
# If it's already nullable (type includes null), don't modify
|
|
137
|
+
if isinstance(schema.get("type"), list) and "null" in schema["type"]:
|
|
138
|
+
return schema
|
|
139
|
+
|
|
140
|
+
# Create a new schema that allows null in addition to the original type
|
|
141
|
+
if "type" in schema:
|
|
142
|
+
original_type = schema["type"]
|
|
143
|
+
if isinstance(original_type, str):
|
|
144
|
+
# Handle different types appropriately
|
|
145
|
+
if original_type in ("array", "object"):
|
|
146
|
+
# For complex types (array/object), preserve the full structure
|
|
147
|
+
# and allow null as an alternative
|
|
148
|
+
if original_type == "array" and "items" in schema:
|
|
149
|
+
# Array with items - preserve items in anyOf branch
|
|
150
|
+
array_schema = schema.copy()
|
|
151
|
+
top_level_fields = ["default", "description", "title", "example"]
|
|
152
|
+
nullable_schema = {}
|
|
153
|
+
|
|
154
|
+
# Move top-level fields to the root
|
|
155
|
+
for field in top_level_fields:
|
|
156
|
+
if field in array_schema:
|
|
157
|
+
nullable_schema[field] = array_schema.pop(field)
|
|
158
|
+
|
|
159
|
+
nullable_schema["anyOf"] = [array_schema, {"type": "null"}]
|
|
160
|
+
return nullable_schema
|
|
161
|
+
|
|
162
|
+
elif original_type == "object" and "properties" in schema:
|
|
163
|
+
# Object with properties - preserve properties in anyOf branch
|
|
164
|
+
object_schema = schema.copy()
|
|
165
|
+
top_level_fields = ["default", "description", "title", "example"]
|
|
166
|
+
nullable_schema = {}
|
|
167
|
+
|
|
168
|
+
# Move top-level fields to the root
|
|
169
|
+
for field in top_level_fields:
|
|
170
|
+
if field in object_schema:
|
|
171
|
+
nullable_schema[field] = object_schema.pop(field)
|
|
172
|
+
|
|
173
|
+
nullable_schema["anyOf"] = [object_schema, {"type": "null"}]
|
|
174
|
+
return nullable_schema
|
|
175
|
+
else:
|
|
176
|
+
# Simple object/array without items/properties
|
|
177
|
+
nullable_schema = {}
|
|
178
|
+
original_schema = schema.copy()
|
|
179
|
+
top_level_fields = ["default", "description", "title", "example"]
|
|
180
|
+
|
|
181
|
+
for field in top_level_fields:
|
|
182
|
+
if field in original_schema:
|
|
183
|
+
nullable_schema[field] = original_schema.pop(field)
|
|
184
|
+
|
|
185
|
+
nullable_schema["anyOf"] = [original_schema, {"type": "null"}]
|
|
186
|
+
return nullable_schema
|
|
187
|
+
else:
|
|
188
|
+
# Simple types (string, integer, number, boolean)
|
|
189
|
+
top_level_fields = ["default", "description", "title", "example"]
|
|
190
|
+
nullable_schema = {}
|
|
191
|
+
original_schema = schema.copy()
|
|
192
|
+
|
|
193
|
+
for field in top_level_fields:
|
|
194
|
+
if field in original_schema:
|
|
195
|
+
nullable_schema[field] = original_schema.pop(field)
|
|
196
|
+
|
|
197
|
+
nullable_schema["anyOf"] = [original_schema, {"type": "null"}]
|
|
198
|
+
return nullable_schema
|
|
199
|
+
|
|
200
|
+
return schema
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _combine_schemas_and_map_params(
|
|
204
|
+
route: HTTPRoute,
|
|
205
|
+
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
|
206
|
+
"""
|
|
207
|
+
Combines parameter and request body schemas into a single schema.
|
|
208
|
+
Handles parameter name collisions by adding location suffixes.
|
|
209
|
+
Also returns parameter mapping for request director.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
route: HTTPRoute object
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (combined schema dictionary, parameter mapping)
|
|
216
|
+
Parameter mapping format: {'flat_arg_name': {'location': 'path', 'openapi_name': 'id'}}
|
|
217
|
+
"""
|
|
218
|
+
properties = {}
|
|
219
|
+
required = []
|
|
220
|
+
parameter_map = {} # Track mapping from flat arg names to OpenAPI locations
|
|
221
|
+
|
|
222
|
+
# First pass: collect parameter names by location and body properties
|
|
223
|
+
param_names_by_location = {
|
|
224
|
+
"path": set(),
|
|
225
|
+
"query": set(),
|
|
226
|
+
"header": set(),
|
|
227
|
+
"cookie": set(),
|
|
228
|
+
}
|
|
229
|
+
body_props = {}
|
|
230
|
+
|
|
231
|
+
for param in route.parameters:
|
|
232
|
+
param_names_by_location[param.location].add(param.name)
|
|
233
|
+
|
|
234
|
+
if route.request_body and route.request_body.content_schema:
|
|
235
|
+
content_type = next(iter(route.request_body.content_schema))
|
|
236
|
+
body_schema = _replace_ref_with_defs(
|
|
237
|
+
route.request_body.content_schema[content_type].copy(),
|
|
238
|
+
route.request_body.description,
|
|
239
|
+
)
|
|
240
|
+
body_props = body_schema.get("properties", {})
|
|
241
|
+
|
|
242
|
+
# Detect collisions: parameters that exist in both body and path/query/header
|
|
243
|
+
all_non_body_params = set()
|
|
244
|
+
for location_params in param_names_by_location.values():
|
|
245
|
+
all_non_body_params.update(location_params)
|
|
246
|
+
|
|
247
|
+
body_param_names = set(body_props.keys())
|
|
248
|
+
colliding_params = all_non_body_params & body_param_names
|
|
249
|
+
|
|
250
|
+
# Add parameters with suffixes for collisions
|
|
251
|
+
for param in route.parameters:
|
|
252
|
+
if param.name in colliding_params:
|
|
253
|
+
# Add suffix for non-body parameters when collision detected
|
|
254
|
+
suffixed_name = f"{param.name}__{param.location}"
|
|
255
|
+
if param.required:
|
|
256
|
+
required.append(suffixed_name)
|
|
257
|
+
|
|
258
|
+
# Track parameter mapping
|
|
259
|
+
parameter_map[suffixed_name] = {
|
|
260
|
+
"location": param.location,
|
|
261
|
+
"openapi_name": param.name,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Add location info to description
|
|
265
|
+
param_schema = _replace_ref_with_defs(
|
|
266
|
+
param.schema_.copy(), param.description
|
|
267
|
+
)
|
|
268
|
+
original_desc = param_schema.get("description", "")
|
|
269
|
+
location_desc = f"({param.location.capitalize()} parameter)"
|
|
270
|
+
if original_desc:
|
|
271
|
+
param_schema["description"] = f"{original_desc} {location_desc}"
|
|
272
|
+
else:
|
|
273
|
+
param_schema["description"] = location_desc
|
|
274
|
+
|
|
275
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
276
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
277
|
+
|
|
278
|
+
properties[suffixed_name] = param_schema
|
|
279
|
+
else:
|
|
280
|
+
# No collision, use original name
|
|
281
|
+
if param.required:
|
|
282
|
+
required.append(param.name)
|
|
283
|
+
|
|
284
|
+
# Track parameter mapping
|
|
285
|
+
parameter_map[param.name] = {
|
|
286
|
+
"location": param.location,
|
|
287
|
+
"openapi_name": param.name,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
param_schema = _replace_ref_with_defs(
|
|
291
|
+
param.schema_.copy(), param.description
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
295
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
296
|
+
|
|
297
|
+
properties[param.name] = param_schema
|
|
298
|
+
|
|
299
|
+
# Add request body properties (no suffixes for body parameters)
|
|
300
|
+
if route.request_body and route.request_body.content_schema:
|
|
301
|
+
for prop_name, prop_schema in body_props.items():
|
|
302
|
+
properties[prop_name] = prop_schema
|
|
303
|
+
|
|
304
|
+
# Track parameter mapping for body properties
|
|
305
|
+
parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name}
|
|
306
|
+
|
|
307
|
+
if route.request_body.required:
|
|
308
|
+
required.extend(body_schema.get("required", []))
|
|
309
|
+
|
|
310
|
+
result = {
|
|
311
|
+
"type": "object",
|
|
312
|
+
"properties": properties,
|
|
313
|
+
"required": required,
|
|
314
|
+
}
|
|
315
|
+
# Add schema definitions if available
|
|
316
|
+
if route.schema_definitions:
|
|
317
|
+
result["$defs"] = route.schema_definitions.copy()
|
|
318
|
+
|
|
319
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
320
|
+
if result.get("additionalProperties") is False:
|
|
321
|
+
result.pop("additionalProperties")
|
|
322
|
+
|
|
323
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
324
|
+
if "$defs" in result:
|
|
325
|
+
used_refs = set()
|
|
326
|
+
|
|
327
|
+
def find_refs_in_value(value):
|
|
328
|
+
if isinstance(value, dict):
|
|
329
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
330
|
+
ref = value["$ref"]
|
|
331
|
+
if ref.startswith("#/$defs/"):
|
|
332
|
+
used_refs.add(ref.split("/")[-1])
|
|
333
|
+
for v in value.values():
|
|
334
|
+
find_refs_in_value(v)
|
|
335
|
+
elif isinstance(value, list):
|
|
336
|
+
for item in value:
|
|
337
|
+
find_refs_in_value(item)
|
|
338
|
+
|
|
339
|
+
# Find refs in the main schema (excluding $defs section)
|
|
340
|
+
for key, value in result.items():
|
|
341
|
+
if key != "$defs":
|
|
342
|
+
find_refs_in_value(value)
|
|
343
|
+
|
|
344
|
+
# Remove unused definitions
|
|
345
|
+
if used_refs:
|
|
346
|
+
result["$defs"] = {
|
|
347
|
+
name: def_schema
|
|
348
|
+
for name, def_schema in result["$defs"].items()
|
|
349
|
+
if name in used_refs
|
|
350
|
+
}
|
|
351
|
+
else:
|
|
352
|
+
result.pop("$defs")
|
|
353
|
+
|
|
354
|
+
return result, parameter_map
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
358
|
+
"""
|
|
359
|
+
Combines parameter and request body schemas into a single schema.
|
|
360
|
+
Handles parameter name collisions by adding location suffixes.
|
|
361
|
+
|
|
362
|
+
This is a backward compatibility wrapper around _combine_schemas_and_map_params.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
route: HTTPRoute object
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Combined schema dictionary
|
|
369
|
+
"""
|
|
370
|
+
schema, _ = _combine_schemas_and_map_params(route)
|
|
371
|
+
return schema
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def extract_output_schema_from_responses(
|
|
375
|
+
responses: dict[str, ResponseInfo],
|
|
376
|
+
schema_definitions: dict[str, Any] | None = None,
|
|
377
|
+
openapi_version: str | None = None,
|
|
378
|
+
) -> dict[str, Any] | None:
|
|
379
|
+
"""
|
|
380
|
+
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
381
|
+
|
|
382
|
+
This function finds the first successful response (200, 201, 202, 204) with a
|
|
383
|
+
JSON-compatible content type and extracts its schema. If the schema is not an
|
|
384
|
+
object type, it wraps it to comply with MCP requirements.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
388
|
+
schema_definitions: Optional schema definitions to include in the output schema
|
|
389
|
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
393
|
+
"""
|
|
394
|
+
if not responses:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
# Priority order for success status codes
|
|
398
|
+
success_codes = ["200", "201", "202", "204"]
|
|
399
|
+
|
|
400
|
+
# Find the first successful response
|
|
401
|
+
response_info = None
|
|
402
|
+
for status_code in success_codes:
|
|
403
|
+
if status_code in responses:
|
|
404
|
+
response_info = responses[status_code]
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
# If no explicit success codes, try any 2xx response
|
|
408
|
+
if response_info is None:
|
|
409
|
+
for status_code, resp_info in responses.items():
|
|
410
|
+
if status_code.startswith("2"):
|
|
411
|
+
response_info = resp_info
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if response_info is None or not response_info.content_schema:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
# Prefer application/json, then fall back to other JSON-compatible types
|
|
418
|
+
json_compatible_types = [
|
|
419
|
+
"application/json",
|
|
420
|
+
"application/vnd.api+json",
|
|
421
|
+
"application/hal+json",
|
|
422
|
+
"application/ld+json",
|
|
423
|
+
"text/json",
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
schema = None
|
|
427
|
+
for content_type in json_compatible_types:
|
|
428
|
+
if content_type in response_info.content_schema:
|
|
429
|
+
schema = response_info.content_schema[content_type]
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
# If no JSON-compatible type found, try the first available content type
|
|
433
|
+
if schema is None and response_info.content_schema:
|
|
434
|
+
first_content_type = next(iter(response_info.content_schema))
|
|
435
|
+
schema = response_info.content_schema[first_content_type]
|
|
436
|
+
logger.debug(
|
|
437
|
+
f"Using non-JSON content type for output schema: {first_content_type}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if not schema or not isinstance(schema, dict):
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
# Clean and copy the schema
|
|
444
|
+
output_schema = schema.copy()
|
|
445
|
+
|
|
446
|
+
# If schema has a $ref, resolve it first before processing nullable fields
|
|
447
|
+
if "$ref" in output_schema and schema_definitions:
|
|
448
|
+
ref_path = output_schema["$ref"]
|
|
449
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
450
|
+
schema_name = ref_path.split("/")[-1]
|
|
451
|
+
if schema_name in schema_definitions:
|
|
452
|
+
# Replace $ref with the actual schema definition
|
|
453
|
+
output_schema = schema_definitions[schema_name].copy()
|
|
454
|
+
|
|
455
|
+
# Convert OpenAPI schema to JSON Schema format
|
|
456
|
+
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
457
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
458
|
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
|
459
|
+
|
|
460
|
+
output_schema = convert_openapi_schema_to_json_schema(
|
|
461
|
+
output_schema, openapi_version
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
465
|
+
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
466
|
+
if output_schema.get("type") != "object":
|
|
467
|
+
# Create a wrapped schema that contains the original schema under a "result" key
|
|
468
|
+
wrapped_schema = {
|
|
469
|
+
"type": "object",
|
|
470
|
+
"properties": {"result": output_schema},
|
|
471
|
+
"required": ["result"],
|
|
472
|
+
"x-fastmcp-wrap-result": True,
|
|
473
|
+
}
|
|
474
|
+
output_schema = wrapped_schema
|
|
475
|
+
|
|
476
|
+
# Add schema definitions if available and handle nullable fields in them
|
|
477
|
+
# Only add $defs if we didn't resolve the $ref inline above
|
|
478
|
+
if schema_definitions and "$ref" not in schema.copy():
|
|
479
|
+
processed_defs = {}
|
|
480
|
+
for def_name, def_schema in schema_definitions.items():
|
|
481
|
+
# Convert OpenAPI schema definitions to JSON Schema format
|
|
482
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
483
|
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
|
484
|
+
|
|
485
|
+
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
|
|
486
|
+
def_schema, openapi_version
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
processed_defs[def_name] = def_schema
|
|
490
|
+
output_schema["$defs"] = processed_defs
|
|
491
|
+
|
|
492
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
493
|
+
if output_schema.get("additionalProperties") is False:
|
|
494
|
+
output_schema.pop("additionalProperties")
|
|
495
|
+
|
|
496
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
497
|
+
if "$defs" in output_schema:
|
|
498
|
+
used_refs = set()
|
|
499
|
+
|
|
500
|
+
def find_refs_in_value(value):
|
|
501
|
+
if isinstance(value, dict):
|
|
502
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
503
|
+
ref = value["$ref"]
|
|
504
|
+
if ref.startswith("#/$defs/"):
|
|
505
|
+
used_refs.add(ref.split("/")[-1])
|
|
506
|
+
for v in value.values():
|
|
507
|
+
find_refs_in_value(v)
|
|
508
|
+
elif isinstance(value, list):
|
|
509
|
+
for item in value:
|
|
510
|
+
find_refs_in_value(item)
|
|
511
|
+
|
|
512
|
+
# Find refs in the main schema (excluding $defs section)
|
|
513
|
+
for key, value in output_schema.items():
|
|
514
|
+
if key != "$defs":
|
|
515
|
+
find_refs_in_value(value)
|
|
516
|
+
|
|
517
|
+
# Remove unused definitions
|
|
518
|
+
if used_refs:
|
|
519
|
+
output_schema["$defs"] = {
|
|
520
|
+
name: def_schema
|
|
521
|
+
for name, def_schema in output_schema["$defs"].items()
|
|
522
|
+
if name in used_refs
|
|
523
|
+
}
|
|
524
|
+
else:
|
|
525
|
+
output_schema.pop("$defs")
|
|
526
|
+
|
|
527
|
+
return output_schema
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# Export public symbols
|
|
531
|
+
__all__ = [
|
|
532
|
+
"clean_schema_for_display",
|
|
533
|
+
"_combine_schemas",
|
|
534
|
+
"_combine_schemas_and_map_params",
|
|
535
|
+
"extract_output_schema_from_responses",
|
|
536
|
+
"_replace_ref_with_defs",
|
|
537
|
+
"_make_optional_parameter_nullable",
|
|
538
|
+
]
|