fastmcp 2.10.6__py3-none-any.whl → 2.11.1__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/cli/cli.py +128 -33
- 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_json.py +41 -0
- 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/resources/resource.py +21 -1
- fastmcp/resources/template.py +20 -1
- fastmcp/server/auth/__init__.py +18 -2
- fastmcp/server/auth/auth.py +225 -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 +151 -0
- fastmcp/server/auth/registry.py +52 -0
- fastmcp/server/context.py +107 -26
- fastmcp/server/dependencies.py +9 -2
- fastmcp/server/http.py +48 -57
- fastmcp/server/middleware/middleware.py +3 -23
- fastmcp/server/openapi.py +1 -1
- fastmcp/server/proxy.py +50 -11
- fastmcp/server/server.py +168 -59
- fastmcp/settings.py +73 -6
- fastmcp/tools/tool.py +36 -3
- fastmcp/tools/tool_manager.py +38 -2
- fastmcp/tools/tool_transform.py +112 -3
- fastmcp/utilities/components.py +41 -3
- 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 +243 -57
- fastmcp/utilities/tests.py +54 -6
- fastmcp/utilities/types.py +94 -11
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/METADATA +4 -3
- fastmcp-2.11.1.dist-info/RECORD +108 -0
- fastmcp/server/auth/providers/bearer_env.py +0 -63
- fastmcp/utilities/cache.py +0 -26
- fastmcp-2.10.6.dist-info/RECORD +0 -93
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/utilities/openapi.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import logging
|
|
3
2
|
from typing import Any, Generic, Literal, TypeVar, cast
|
|
4
3
|
|
|
5
4
|
from openapi_pydantic import (
|
|
@@ -24,10 +23,10 @@ from openapi_pydantic.v3.v3_0 import Response as Response_30
|
|
|
24
23
|
from openapi_pydantic.v3.v3_0 import Schema as Schema_30
|
|
25
24
|
from pydantic import BaseModel, Field, ValidationError
|
|
26
25
|
|
|
27
|
-
from fastmcp.utilities.
|
|
26
|
+
from fastmcp.utilities.logging import get_logger
|
|
28
27
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
29
28
|
|
|
30
|
-
logger =
|
|
29
|
+
logger = get_logger(__name__)
|
|
31
30
|
|
|
32
31
|
# --- Intermediate Representation (IR) Definition ---
|
|
33
32
|
# (IR models remain the same)
|
|
@@ -83,13 +82,9 @@ def format_array_parameter(
|
|
|
83
82
|
return values
|
|
84
83
|
else:
|
|
85
84
|
# For path parameters, fallback to string representation without Python syntax
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.replace("]", "")
|
|
90
|
-
.replace("'", "")
|
|
91
|
-
.replace('"', "")
|
|
92
|
-
)
|
|
85
|
+
# Use str.translate() for efficient character removal
|
|
86
|
+
translation_table = str.maketrans("", "", "[]'\"")
|
|
87
|
+
str_value = str(values).translate(translation_table)
|
|
93
88
|
return str_value
|
|
94
89
|
|
|
95
90
|
|
|
@@ -102,8 +97,7 @@ def format_deep_object_parameter(
|
|
|
102
97
|
According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
|
|
103
98
|
object properties as separate query parameters with bracket notation.
|
|
104
99
|
|
|
105
|
-
For example: {"id": "123", "type": "user"} becomes
|
|
106
|
-
param[id]=123¶m[type]=user
|
|
100
|
+
For example: `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`.
|
|
107
101
|
|
|
108
102
|
Args:
|
|
109
103
|
param_value: Dictionary value to format
|
|
@@ -175,6 +169,7 @@ class HTTPRoute(FastMCPBaseModel):
|
|
|
175
169
|
default_factory=dict
|
|
176
170
|
) # Store component schemas
|
|
177
171
|
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
172
|
+
openapi_version: str | None = None
|
|
178
173
|
|
|
179
174
|
|
|
180
175
|
# Export public symbols
|
|
@@ -189,6 +184,7 @@ __all__ = [
|
|
|
189
184
|
"parse_openapi_to_http_routes",
|
|
190
185
|
"extract_output_schema_from_responses",
|
|
191
186
|
"format_deep_object_parameter",
|
|
187
|
+
"_handle_nullable_fields",
|
|
192
188
|
]
|
|
193
189
|
|
|
194
190
|
# Type variables for generic parser
|
|
@@ -228,6 +224,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
228
224
|
Response_30,
|
|
229
225
|
Operation_30,
|
|
230
226
|
PathItem_30,
|
|
227
|
+
openapi_version,
|
|
231
228
|
)
|
|
232
229
|
return parser.parse()
|
|
233
230
|
else:
|
|
@@ -245,6 +242,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
245
242
|
Response,
|
|
246
243
|
Operation,
|
|
247
244
|
PathItem,
|
|
245
|
+
openapi_version,
|
|
248
246
|
)
|
|
249
247
|
return parser.parse()
|
|
250
248
|
except ValidationError as e:
|
|
@@ -278,6 +276,7 @@ class OpenAPIParser(
|
|
|
278
276
|
response_cls: type[TResponse],
|
|
279
277
|
operation_cls: type[TOperation],
|
|
280
278
|
path_item_cls: type[TPathItem],
|
|
279
|
+
openapi_version: str,
|
|
281
280
|
):
|
|
282
281
|
"""Initialize the parser with the OpenAPI schema and type classes."""
|
|
283
282
|
self.openapi = openapi
|
|
@@ -288,6 +287,7 @@ class OpenAPIParser(
|
|
|
288
287
|
self.response_cls = response_cls
|
|
289
288
|
self.operation_cls = operation_cls
|
|
290
289
|
self.path_item_cls = path_item_cls
|
|
290
|
+
self.openapi_version = openapi_version
|
|
291
291
|
|
|
292
292
|
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
|
293
293
|
"""Convert string parameter location to our ParameterLocation type."""
|
|
@@ -710,6 +710,7 @@ class OpenAPIParser(
|
|
|
710
710
|
responses=responses,
|
|
711
711
|
schema_definitions=schema_definitions,
|
|
712
712
|
extensions=extensions,
|
|
713
|
+
openapi_version=self.openapi_version,
|
|
713
714
|
)
|
|
714
715
|
routes.append(route)
|
|
715
716
|
logger.info(
|
|
@@ -1117,16 +1118,17 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1117
1118
|
|
|
1118
1119
|
if isinstance(original_type, str):
|
|
1119
1120
|
# Single type - make it a union with null
|
|
1120
|
-
|
|
1121
|
-
|
|
1121
|
+
# Optimize: avoid full schema copy by building directly
|
|
1122
1122
|
nested_non_nullable_schema = {
|
|
1123
1123
|
"type": original_type,
|
|
1124
1124
|
}
|
|
1125
|
+
nullable_schema = {}
|
|
1125
1126
|
|
|
1126
|
-
#
|
|
1127
|
-
|
|
1127
|
+
# Define type-specific properties that should move to nested schema
|
|
1128
|
+
type_specific_properties = set()
|
|
1128
1129
|
if original_type == "array":
|
|
1129
|
-
|
|
1130
|
+
# https://json-schema.org/understanding-json-schema/reference/array
|
|
1131
|
+
type_specific_properties = {
|
|
1130
1132
|
"items",
|
|
1131
1133
|
"prefixItems",
|
|
1132
1134
|
"unevaluatedItems",
|
|
@@ -1136,17 +1138,10 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1136
1138
|
"minItems",
|
|
1137
1139
|
"maxItems",
|
|
1138
1140
|
"uniqueItems",
|
|
1139
|
-
|
|
1140
|
-
if array_property in nullable_schema:
|
|
1141
|
-
nested_non_nullable_schema[array_property] = nullable_schema[
|
|
1142
|
-
array_property
|
|
1143
|
-
]
|
|
1144
|
-
del nullable_schema[array_property]
|
|
1145
|
-
|
|
1146
|
-
# If the original type is an object, move the object-specific properties into the now-nested schema
|
|
1147
|
-
# https://json-schema.org/understanding-json-schema/reference/object
|
|
1141
|
+
}
|
|
1148
1142
|
elif original_type == "object":
|
|
1149
|
-
|
|
1143
|
+
# https://json-schema.org/understanding-json-schema/reference/object
|
|
1144
|
+
type_specific_properties = {
|
|
1150
1145
|
"properties",
|
|
1151
1146
|
"patternProperties",
|
|
1152
1147
|
"additionalProperties",
|
|
@@ -1155,22 +1150,103 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1155
1150
|
"propertyNames",
|
|
1156
1151
|
"minProperties",
|
|
1157
1152
|
"maxProperties",
|
|
1158
|
-
|
|
1159
|
-
if object_property in nullable_schema:
|
|
1160
|
-
nested_non_nullable_schema[object_property] = nullable_schema[
|
|
1161
|
-
object_property
|
|
1162
|
-
]
|
|
1163
|
-
del nullable_schema[object_property]
|
|
1153
|
+
}
|
|
1164
1154
|
|
|
1165
|
-
|
|
1155
|
+
# Efficiently distribute properties without copying the entire schema
|
|
1156
|
+
for key, value in schema.items():
|
|
1157
|
+
if key == "type":
|
|
1158
|
+
continue # Already handled
|
|
1159
|
+
elif key in type_specific_properties:
|
|
1160
|
+
nested_non_nullable_schema[key] = value
|
|
1161
|
+
else:
|
|
1162
|
+
nullable_schema[key] = value
|
|
1166
1163
|
|
|
1167
|
-
|
|
1168
|
-
del nullable_schema["type"]
|
|
1164
|
+
nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
|
|
1169
1165
|
return nullable_schema
|
|
1170
1166
|
|
|
1171
1167
|
return schema
|
|
1172
1168
|
|
|
1173
1169
|
|
|
1170
|
+
def _add_null_to_type(schema: dict[str, Any]) -> None:
|
|
1171
|
+
"""Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
|
|
1172
|
+
if "type" in schema:
|
|
1173
|
+
current_type = schema["type"]
|
|
1174
|
+
|
|
1175
|
+
if isinstance(current_type, str):
|
|
1176
|
+
# Convert string type to array with null
|
|
1177
|
+
schema["type"] = [current_type, "null"]
|
|
1178
|
+
elif isinstance(current_type, list):
|
|
1179
|
+
# Add null to array if not already present
|
|
1180
|
+
if "null" not in current_type:
|
|
1181
|
+
schema["type"] = current_type + ["null"]
|
|
1182
|
+
elif "oneOf" in schema:
|
|
1183
|
+
# Convert oneOf to anyOf with null type
|
|
1184
|
+
schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
|
|
1185
|
+
elif "anyOf" in schema:
|
|
1186
|
+
# Add null type to anyOf if not already present
|
|
1187
|
+
if not any(item.get("type") == "null" for item in schema["anyOf"]):
|
|
1188
|
+
schema["anyOf"].append({"type": "null"})
|
|
1189
|
+
elif "allOf" in schema:
|
|
1190
|
+
# For allOf, wrap in anyOf with null - this means (all conditions) OR null
|
|
1191
|
+
schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
|
|
1195
|
+
"""Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
|
|
1196
|
+
"nullable": true} -> {"type": ["string", "null"]}"""
|
|
1197
|
+
|
|
1198
|
+
if not isinstance(schema, dict):
|
|
1199
|
+
return schema
|
|
1200
|
+
|
|
1201
|
+
# Check if we need to modify anything first to avoid unnecessary copying
|
|
1202
|
+
has_root_nullable_field = "nullable" in schema
|
|
1203
|
+
has_root_nullable_true = (
|
|
1204
|
+
has_root_nullable_field
|
|
1205
|
+
and schema["nullable"]
|
|
1206
|
+
and (
|
|
1207
|
+
"type" in schema
|
|
1208
|
+
or "oneOf" in schema
|
|
1209
|
+
or "anyOf" in schema
|
|
1210
|
+
or "allOf" in schema
|
|
1211
|
+
)
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
has_property_nullable_field = False
|
|
1215
|
+
if "properties" in schema:
|
|
1216
|
+
for prop_schema in schema["properties"].values():
|
|
1217
|
+
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
1218
|
+
has_property_nullable_field = True
|
|
1219
|
+
break
|
|
1220
|
+
|
|
1221
|
+
# If no nullable fields at all, return original schema unchanged
|
|
1222
|
+
if not has_root_nullable_field and not has_property_nullable_field:
|
|
1223
|
+
return schema
|
|
1224
|
+
|
|
1225
|
+
# Only copy if we need to modify
|
|
1226
|
+
result = schema.copy()
|
|
1227
|
+
|
|
1228
|
+
# Handle root level nullable - always remove the field, convert type if true
|
|
1229
|
+
if has_root_nullable_field:
|
|
1230
|
+
result.pop("nullable")
|
|
1231
|
+
if has_root_nullable_true:
|
|
1232
|
+
_add_null_to_type(result)
|
|
1233
|
+
|
|
1234
|
+
# Handle properties nullable fields
|
|
1235
|
+
if has_property_nullable_field and "properties" in result:
|
|
1236
|
+
for prop_name, prop_schema in result["properties"].items():
|
|
1237
|
+
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
1238
|
+
nullable_value = prop_schema.pop("nullable")
|
|
1239
|
+
if nullable_value and (
|
|
1240
|
+
"type" in prop_schema
|
|
1241
|
+
or "oneOf" in prop_schema
|
|
1242
|
+
or "anyOf" in prop_schema
|
|
1243
|
+
or "allOf" in prop_schema
|
|
1244
|
+
):
|
|
1245
|
+
_add_null_to_type(prop_schema)
|
|
1246
|
+
|
|
1247
|
+
return result
|
|
1248
|
+
|
|
1249
|
+
|
|
1174
1250
|
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
1175
1251
|
"""
|
|
1176
1252
|
Combines parameter and request body schemas into a single schema.
|
|
@@ -1232,9 +1308,8 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1232
1308
|
else:
|
|
1233
1309
|
param_schema["description"] = location_desc
|
|
1234
1310
|
|
|
1235
|
-
#
|
|
1236
|
-
|
|
1237
|
-
param_schema = _make_optional_parameter_nullable(param_schema)
|
|
1311
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
1312
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
1238
1313
|
|
|
1239
1314
|
properties[suffixed_name] = param_schema
|
|
1240
1315
|
else:
|
|
@@ -1245,9 +1320,8 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1245
1320
|
param.schema_.copy(), param.description
|
|
1246
1321
|
)
|
|
1247
1322
|
|
|
1248
|
-
#
|
|
1249
|
-
|
|
1250
|
-
param_schema = _make_optional_parameter_nullable(param_schema)
|
|
1323
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
1324
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
1251
1325
|
|
|
1252
1326
|
properties[param.name] = param_schema
|
|
1253
1327
|
|
|
@@ -1266,10 +1340,42 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1266
1340
|
}
|
|
1267
1341
|
# Add schema definitions if available
|
|
1268
1342
|
if route.schema_definitions:
|
|
1269
|
-
result["$defs"] = route.schema_definitions
|
|
1270
|
-
|
|
1271
|
-
# Use
|
|
1272
|
-
result
|
|
1343
|
+
result["$defs"] = route.schema_definitions.copy()
|
|
1344
|
+
|
|
1345
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
1346
|
+
if result.get("additionalProperties") is False:
|
|
1347
|
+
result.pop("additionalProperties")
|
|
1348
|
+
|
|
1349
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
1350
|
+
if "$defs" in result:
|
|
1351
|
+
used_refs = set()
|
|
1352
|
+
|
|
1353
|
+
def find_refs_in_value(value):
|
|
1354
|
+
if isinstance(value, dict):
|
|
1355
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
1356
|
+
ref = value["$ref"]
|
|
1357
|
+
if ref.startswith("#/$defs/"):
|
|
1358
|
+
used_refs.add(ref.split("/")[-1])
|
|
1359
|
+
for v in value.values():
|
|
1360
|
+
find_refs_in_value(v)
|
|
1361
|
+
elif isinstance(value, list):
|
|
1362
|
+
for item in value:
|
|
1363
|
+
find_refs_in_value(item)
|
|
1364
|
+
|
|
1365
|
+
# Find refs in the main schema (excluding $defs section)
|
|
1366
|
+
for key, value in result.items():
|
|
1367
|
+
if key != "$defs":
|
|
1368
|
+
find_refs_in_value(value)
|
|
1369
|
+
|
|
1370
|
+
# Remove unused definitions
|
|
1371
|
+
if used_refs:
|
|
1372
|
+
result["$defs"] = {
|
|
1373
|
+
name: def_schema
|
|
1374
|
+
for name, def_schema in result["$defs"].items()
|
|
1375
|
+
if name in used_refs
|
|
1376
|
+
}
|
|
1377
|
+
else:
|
|
1378
|
+
result.pop("$defs")
|
|
1273
1379
|
|
|
1274
1380
|
return result
|
|
1275
1381
|
|
|
@@ -1279,17 +1385,41 @@ def _adjust_union_types(
|
|
|
1279
1385
|
) -> dict[str, Any] | list[Any]:
|
|
1280
1386
|
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
|
1281
1387
|
if isinstance(schema, dict):
|
|
1282
|
-
if
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1388
|
+
# Optimize: only copy if we need to modify something
|
|
1389
|
+
has_one_of = "oneOf" in schema
|
|
1390
|
+
needs_recursive_processing = False
|
|
1391
|
+
|
|
1392
|
+
# Check if we need recursive processing
|
|
1393
|
+
for v in schema.values():
|
|
1394
|
+
if isinstance(v, dict | list):
|
|
1395
|
+
needs_recursive_processing = True
|
|
1396
|
+
break
|
|
1397
|
+
|
|
1398
|
+
# If nothing to change, return original
|
|
1399
|
+
if not has_one_of and not needs_recursive_processing:
|
|
1400
|
+
return schema
|
|
1401
|
+
|
|
1402
|
+
# Work on a copy only when modification is needed
|
|
1403
|
+
result = schema.copy()
|
|
1404
|
+
if has_one_of:
|
|
1405
|
+
result["anyOf"] = result.pop("oneOf")
|
|
1406
|
+
|
|
1407
|
+
# Only recurse where needed
|
|
1408
|
+
if needs_recursive_processing:
|
|
1409
|
+
for k, v in result.items():
|
|
1410
|
+
if isinstance(v, dict | list):
|
|
1411
|
+
result[k] = _adjust_union_types(v)
|
|
1412
|
+
|
|
1413
|
+
return result
|
|
1286
1414
|
elif isinstance(schema, list):
|
|
1287
1415
|
return [_adjust_union_types(item) for item in schema]
|
|
1288
1416
|
return schema
|
|
1289
1417
|
|
|
1290
1418
|
|
|
1291
1419
|
def extract_output_schema_from_responses(
|
|
1292
|
-
responses: dict[str, ResponseInfo],
|
|
1420
|
+
responses: dict[str, ResponseInfo],
|
|
1421
|
+
schema_definitions: dict[str, Any] | None = None,
|
|
1422
|
+
openapi_version: str | None = None,
|
|
1293
1423
|
) -> dict[str, Any] | None:
|
|
1294
1424
|
"""
|
|
1295
1425
|
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
@@ -1301,6 +1431,7 @@ def extract_output_schema_from_responses(
|
|
|
1301
1431
|
Args:
|
|
1302
1432
|
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
1303
1433
|
schema_definitions: Optional schema definitions to include in the output schema
|
|
1434
|
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
|
1304
1435
|
|
|
1305
1436
|
Returns:
|
|
1306
1437
|
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
@@ -1357,6 +1488,21 @@ def extract_output_schema_from_responses(
|
|
|
1357
1488
|
# Clean and copy the schema
|
|
1358
1489
|
output_schema = schema.copy()
|
|
1359
1490
|
|
|
1491
|
+
# If schema has a $ref, resolve it first before processing nullable fields
|
|
1492
|
+
if "$ref" in output_schema and schema_definitions:
|
|
1493
|
+
ref_path = output_schema["$ref"]
|
|
1494
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
1495
|
+
schema_name = ref_path.split("/")[-1]
|
|
1496
|
+
if schema_name in schema_definitions:
|
|
1497
|
+
# Replace $ref with the actual schema definition
|
|
1498
|
+
output_schema = schema_definitions[schema_name].copy()
|
|
1499
|
+
|
|
1500
|
+
# Handle OpenAPI nullable fields by converting them to JSON Schema format
|
|
1501
|
+
# This prevents "None is not of type 'string'" validation errors
|
|
1502
|
+
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
1503
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
1504
|
+
output_schema = _handle_nullable_fields(output_schema)
|
|
1505
|
+
|
|
1360
1506
|
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
1361
1507
|
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
1362
1508
|
if output_schema.get("type") != "object":
|
|
@@ -1369,12 +1515,52 @@ def extract_output_schema_from_responses(
|
|
|
1369
1515
|
}
|
|
1370
1516
|
output_schema = wrapped_schema
|
|
1371
1517
|
|
|
1372
|
-
# Add schema definitions if available
|
|
1373
|
-
if
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1518
|
+
# Add schema definitions if available and handle nullable fields in them
|
|
1519
|
+
# Only add $defs if we didn't resolve the $ref inline above
|
|
1520
|
+
if schema_definitions and "$ref" not in schema.copy():
|
|
1521
|
+
processed_defs = {}
|
|
1522
|
+
for def_name, def_schema in schema_definitions.items():
|
|
1523
|
+
# Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
1524
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
1525
|
+
processed_defs[def_name] = _handle_nullable_fields(def_schema)
|
|
1526
|
+
else:
|
|
1527
|
+
processed_defs[def_name] = def_schema
|
|
1528
|
+
output_schema["$defs"] = processed_defs
|
|
1529
|
+
|
|
1530
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
1531
|
+
if output_schema.get("additionalProperties") is False:
|
|
1532
|
+
output_schema.pop("additionalProperties")
|
|
1533
|
+
|
|
1534
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
1535
|
+
if "$defs" in output_schema:
|
|
1536
|
+
used_refs = set()
|
|
1537
|
+
|
|
1538
|
+
def find_refs_in_value(value):
|
|
1539
|
+
if isinstance(value, dict):
|
|
1540
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
1541
|
+
ref = value["$ref"]
|
|
1542
|
+
if ref.startswith("#/$defs/"):
|
|
1543
|
+
used_refs.add(ref.split("/")[-1])
|
|
1544
|
+
for v in value.values():
|
|
1545
|
+
find_refs_in_value(v)
|
|
1546
|
+
elif isinstance(value, list):
|
|
1547
|
+
for item in value:
|
|
1548
|
+
find_refs_in_value(item)
|
|
1549
|
+
|
|
1550
|
+
# Find refs in the main schema (excluding $defs section)
|
|
1551
|
+
for key, value in output_schema.items():
|
|
1552
|
+
if key != "$defs":
|
|
1553
|
+
find_refs_in_value(value)
|
|
1554
|
+
|
|
1555
|
+
# Remove unused definitions
|
|
1556
|
+
if used_refs:
|
|
1557
|
+
output_schema["$defs"] = {
|
|
1558
|
+
name: def_schema
|
|
1559
|
+
for name, def_schema in output_schema["$defs"].items()
|
|
1560
|
+
if name in used_refs
|
|
1561
|
+
}
|
|
1562
|
+
else:
|
|
1563
|
+
output_schema.pop("$defs")
|
|
1378
1564
|
|
|
1379
1565
|
# Adjust union types to handle overlapping unions
|
|
1380
1566
|
output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
|
fastmcp/utilities/tests.py
CHANGED
|
@@ -8,10 +8,13 @@ import time
|
|
|
8
8
|
from collections.abc import Callable, Generator
|
|
9
9
|
from contextlib import contextmanager
|
|
10
10
|
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
12
|
|
|
13
|
+
import httpx
|
|
12
14
|
import uvicorn
|
|
13
15
|
|
|
14
16
|
from fastmcp import settings
|
|
17
|
+
from fastmcp.client.auth.oauth import OAuth
|
|
15
18
|
from fastmcp.utilities.http import find_available_port
|
|
16
19
|
|
|
17
20
|
if TYPE_CHECKING:
|
|
@@ -37,21 +40,18 @@ def temporary_settings(**kwargs: Any):
|
|
|
37
40
|
assert fastmcp.settings.log_level == 'INFO'
|
|
38
41
|
```
|
|
39
42
|
"""
|
|
40
|
-
old_settings = copy.deepcopy(settings
|
|
43
|
+
old_settings = copy.deepcopy(settings)
|
|
41
44
|
|
|
42
45
|
try:
|
|
43
46
|
# apply the new settings
|
|
44
47
|
for attr, value in kwargs.items():
|
|
45
|
-
|
|
46
|
-
raise AttributeError(f"Setting {attr} does not exist.")
|
|
47
|
-
setattr(settings, attr, value)
|
|
48
|
+
settings.set_setting(attr, value)
|
|
48
49
|
yield
|
|
49
50
|
|
|
50
51
|
finally:
|
|
51
52
|
# restore the old settings
|
|
52
53
|
for attr in kwargs:
|
|
53
|
-
|
|
54
|
-
setattr(settings, attr, old_settings[attr])
|
|
54
|
+
settings.set_setting(attr, old_settings.get_setting(attr))
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> None:
|
|
@@ -142,3 +142,51 @@ def caplog_for_fastmcp(caplog):
|
|
|
142
142
|
yield
|
|
143
143
|
finally:
|
|
144
144
|
logger.removeHandler(caplog.handler)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class HeadlessOAuth(OAuth):
|
|
148
|
+
"""
|
|
149
|
+
OAuth provider that bypasses browser interaction for testing.
|
|
150
|
+
|
|
151
|
+
This simulates the complete OAuth flow programmatically by making HTTP requests
|
|
152
|
+
instead of opening a browser and running a callback server. Useful for automated testing.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, mcp_url: str, **kwargs):
|
|
156
|
+
"""Initialize HeadlessOAuth with stored response tracking."""
|
|
157
|
+
self._stored_response = None
|
|
158
|
+
super().__init__(mcp_url, **kwargs)
|
|
159
|
+
|
|
160
|
+
async def redirect_handler(self, authorization_url: str) -> None:
|
|
161
|
+
"""Make HTTP request to authorization URL and store response for callback handler."""
|
|
162
|
+
async with httpx.AsyncClient() as client:
|
|
163
|
+
response = await client.get(authorization_url, follow_redirects=False)
|
|
164
|
+
self._stored_response = response
|
|
165
|
+
|
|
166
|
+
async def callback_handler(self) -> tuple[str, str | None]:
|
|
167
|
+
"""Parse stored response and return (auth_code, state)."""
|
|
168
|
+
if not self._stored_response:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"No authorization response stored. redirect_handler must be called first."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
response = self._stored_response
|
|
174
|
+
|
|
175
|
+
# Extract auth code from redirect location
|
|
176
|
+
if response.status_code == 302:
|
|
177
|
+
redirect_url = response.headers["location"]
|
|
178
|
+
parsed = urlparse(redirect_url)
|
|
179
|
+
query_params = parse_qs(parsed.query)
|
|
180
|
+
|
|
181
|
+
if "error" in query_params:
|
|
182
|
+
error = query_params["error"][0]
|
|
183
|
+
error_desc = query_params.get("error_description", ["Unknown error"])[0]
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"OAuth authorization failed: {error} - {error_desc}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
auth_code = query_params["code"][0]
|
|
189
|
+
state = query_params.get("state", [None])[0]
|
|
190
|
+
return auth_code, state
|
|
191
|
+
else:
|
|
192
|
+
raise RuntimeError(f"Authorization failed: {response.status_code}")
|