fastmcp 2.10.6__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/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 +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 +107 -26
- fastmcp/server/dependencies.py +9 -2
- fastmcp/server/http.py +62 -30
- 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 +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 +240 -50
- fastmcp/utilities/tests.py +54 -6
- fastmcp/utilities/types.py +89 -11
- {fastmcp-2.10.6.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.6.dist-info/RECORD +0 -93
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.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)
|
|
@@ -102,8 +101,7 @@ def format_deep_object_parameter(
|
|
|
102
101
|
According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
|
|
103
102
|
object properties as separate query parameters with bracket notation.
|
|
104
103
|
|
|
105
|
-
For example: {"id": "123", "type": "user"} becomes
|
|
106
|
-
param[id]=123¶m[type]=user
|
|
104
|
+
For example: `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`.
|
|
107
105
|
|
|
108
106
|
Args:
|
|
109
107
|
param_value: Dictionary value to format
|
|
@@ -175,6 +173,7 @@ class HTTPRoute(FastMCPBaseModel):
|
|
|
175
173
|
default_factory=dict
|
|
176
174
|
) # Store component schemas
|
|
177
175
|
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
176
|
+
openapi_version: str | None = None
|
|
178
177
|
|
|
179
178
|
|
|
180
179
|
# Export public symbols
|
|
@@ -189,6 +188,7 @@ __all__ = [
|
|
|
189
188
|
"parse_openapi_to_http_routes",
|
|
190
189
|
"extract_output_schema_from_responses",
|
|
191
190
|
"format_deep_object_parameter",
|
|
191
|
+
"_handle_nullable_fields",
|
|
192
192
|
]
|
|
193
193
|
|
|
194
194
|
# Type variables for generic parser
|
|
@@ -228,6 +228,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
228
228
|
Response_30,
|
|
229
229
|
Operation_30,
|
|
230
230
|
PathItem_30,
|
|
231
|
+
openapi_version,
|
|
231
232
|
)
|
|
232
233
|
return parser.parse()
|
|
233
234
|
else:
|
|
@@ -245,6 +246,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
245
246
|
Response,
|
|
246
247
|
Operation,
|
|
247
248
|
PathItem,
|
|
249
|
+
openapi_version,
|
|
248
250
|
)
|
|
249
251
|
return parser.parse()
|
|
250
252
|
except ValidationError as e:
|
|
@@ -278,6 +280,7 @@ class OpenAPIParser(
|
|
|
278
280
|
response_cls: type[TResponse],
|
|
279
281
|
operation_cls: type[TOperation],
|
|
280
282
|
path_item_cls: type[TPathItem],
|
|
283
|
+
openapi_version: str,
|
|
281
284
|
):
|
|
282
285
|
"""Initialize the parser with the OpenAPI schema and type classes."""
|
|
283
286
|
self.openapi = openapi
|
|
@@ -288,6 +291,7 @@ class OpenAPIParser(
|
|
|
288
291
|
self.response_cls = response_cls
|
|
289
292
|
self.operation_cls = operation_cls
|
|
290
293
|
self.path_item_cls = path_item_cls
|
|
294
|
+
self.openapi_version = openapi_version
|
|
291
295
|
|
|
292
296
|
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
|
293
297
|
"""Convert string parameter location to our ParameterLocation type."""
|
|
@@ -710,6 +714,7 @@ class OpenAPIParser(
|
|
|
710
714
|
responses=responses,
|
|
711
715
|
schema_definitions=schema_definitions,
|
|
712
716
|
extensions=extensions,
|
|
717
|
+
openapi_version=self.openapi_version,
|
|
713
718
|
)
|
|
714
719
|
routes.append(route)
|
|
715
720
|
logger.info(
|
|
@@ -1117,16 +1122,17 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1117
1122
|
|
|
1118
1123
|
if isinstance(original_type, str):
|
|
1119
1124
|
# Single type - make it a union with null
|
|
1120
|
-
|
|
1121
|
-
|
|
1125
|
+
# Optimize: avoid full schema copy by building directly
|
|
1122
1126
|
nested_non_nullable_schema = {
|
|
1123
1127
|
"type": original_type,
|
|
1124
1128
|
}
|
|
1129
|
+
nullable_schema = {}
|
|
1125
1130
|
|
|
1126
|
-
#
|
|
1127
|
-
|
|
1131
|
+
# Define type-specific properties that should move to nested schema
|
|
1132
|
+
type_specific_properties = set()
|
|
1128
1133
|
if original_type == "array":
|
|
1129
|
-
|
|
1134
|
+
# https://json-schema.org/understanding-json-schema/reference/array
|
|
1135
|
+
type_specific_properties = {
|
|
1130
1136
|
"items",
|
|
1131
1137
|
"prefixItems",
|
|
1132
1138
|
"unevaluatedItems",
|
|
@@ -1136,17 +1142,10 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1136
1142
|
"minItems",
|
|
1137
1143
|
"maxItems",
|
|
1138
1144
|
"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
|
|
1145
|
+
}
|
|
1148
1146
|
elif original_type == "object":
|
|
1149
|
-
|
|
1147
|
+
# https://json-schema.org/understanding-json-schema/reference/object
|
|
1148
|
+
type_specific_properties = {
|
|
1150
1149
|
"properties",
|
|
1151
1150
|
"patternProperties",
|
|
1152
1151
|
"additionalProperties",
|
|
@@ -1155,22 +1154,103 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1155
1154
|
"propertyNames",
|
|
1156
1155
|
"minProperties",
|
|
1157
1156
|
"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]
|
|
1157
|
+
}
|
|
1164
1158
|
|
|
1165
|
-
|
|
1159
|
+
# Efficiently distribute properties without copying the entire schema
|
|
1160
|
+
for key, value in schema.items():
|
|
1161
|
+
if key == "type":
|
|
1162
|
+
continue # Already handled
|
|
1163
|
+
elif key in type_specific_properties:
|
|
1164
|
+
nested_non_nullable_schema[key] = value
|
|
1165
|
+
else:
|
|
1166
|
+
nullable_schema[key] = value
|
|
1166
1167
|
|
|
1167
|
-
|
|
1168
|
-
del nullable_schema["type"]
|
|
1168
|
+
nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
|
|
1169
1169
|
return nullable_schema
|
|
1170
1170
|
|
|
1171
1171
|
return schema
|
|
1172
1172
|
|
|
1173
1173
|
|
|
1174
|
+
def _add_null_to_type(schema: dict[str, Any]) -> None:
|
|
1175
|
+
"""Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
|
|
1176
|
+
if "type" in schema:
|
|
1177
|
+
current_type = schema["type"]
|
|
1178
|
+
|
|
1179
|
+
if isinstance(current_type, str):
|
|
1180
|
+
# Convert string type to array with null
|
|
1181
|
+
schema["type"] = [current_type, "null"]
|
|
1182
|
+
elif isinstance(current_type, list):
|
|
1183
|
+
# Add null to array if not already present
|
|
1184
|
+
if "null" not in current_type:
|
|
1185
|
+
schema["type"] = current_type + ["null"]
|
|
1186
|
+
elif "oneOf" in schema:
|
|
1187
|
+
# Convert oneOf to anyOf with null type
|
|
1188
|
+
schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
|
|
1189
|
+
elif "anyOf" in schema:
|
|
1190
|
+
# Add null type to anyOf if not already present
|
|
1191
|
+
if not any(item.get("type") == "null" for item in schema["anyOf"]):
|
|
1192
|
+
schema["anyOf"].append({"type": "null"})
|
|
1193
|
+
elif "allOf" in schema:
|
|
1194
|
+
# For allOf, wrap in anyOf with null - this means (all conditions) OR null
|
|
1195
|
+
schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
|
|
1199
|
+
"""Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
|
|
1200
|
+
"nullable": true} -> {"type": ["string", "null"]}"""
|
|
1201
|
+
|
|
1202
|
+
if not isinstance(schema, dict):
|
|
1203
|
+
return schema
|
|
1204
|
+
|
|
1205
|
+
# Check if we need to modify anything first to avoid unnecessary copying
|
|
1206
|
+
has_root_nullable_field = "nullable" in schema
|
|
1207
|
+
has_root_nullable_true = (
|
|
1208
|
+
has_root_nullable_field
|
|
1209
|
+
and schema["nullable"]
|
|
1210
|
+
and (
|
|
1211
|
+
"type" in schema
|
|
1212
|
+
or "oneOf" in schema
|
|
1213
|
+
or "anyOf" in schema
|
|
1214
|
+
or "allOf" in schema
|
|
1215
|
+
)
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
has_property_nullable_field = False
|
|
1219
|
+
if "properties" in schema:
|
|
1220
|
+
for prop_schema in schema["properties"].values():
|
|
1221
|
+
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
1222
|
+
has_property_nullable_field = True
|
|
1223
|
+
break
|
|
1224
|
+
|
|
1225
|
+
# If no nullable fields at all, return original schema unchanged
|
|
1226
|
+
if not has_root_nullable_field and not has_property_nullable_field:
|
|
1227
|
+
return schema
|
|
1228
|
+
|
|
1229
|
+
# Only copy if we need to modify
|
|
1230
|
+
result = schema.copy()
|
|
1231
|
+
|
|
1232
|
+
# Handle root level nullable - always remove the field, convert type if true
|
|
1233
|
+
if has_root_nullable_field:
|
|
1234
|
+
result.pop("nullable")
|
|
1235
|
+
if has_root_nullable_true:
|
|
1236
|
+
_add_null_to_type(result)
|
|
1237
|
+
|
|
1238
|
+
# Handle properties nullable fields
|
|
1239
|
+
if has_property_nullable_field and "properties" in result:
|
|
1240
|
+
for prop_name, prop_schema in result["properties"].items():
|
|
1241
|
+
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
1242
|
+
nullable_value = prop_schema.pop("nullable")
|
|
1243
|
+
if nullable_value and (
|
|
1244
|
+
"type" in prop_schema
|
|
1245
|
+
or "oneOf" in prop_schema
|
|
1246
|
+
or "anyOf" in prop_schema
|
|
1247
|
+
or "allOf" in prop_schema
|
|
1248
|
+
):
|
|
1249
|
+
_add_null_to_type(prop_schema)
|
|
1250
|
+
|
|
1251
|
+
return result
|
|
1252
|
+
|
|
1253
|
+
|
|
1174
1254
|
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
1175
1255
|
"""
|
|
1176
1256
|
Combines parameter and request body schemas into a single schema.
|
|
@@ -1232,9 +1312,8 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1232
1312
|
else:
|
|
1233
1313
|
param_schema["description"] = location_desc
|
|
1234
1314
|
|
|
1235
|
-
#
|
|
1236
|
-
|
|
1237
|
-
param_schema = _make_optional_parameter_nullable(param_schema)
|
|
1315
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
1316
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
1238
1317
|
|
|
1239
1318
|
properties[suffixed_name] = param_schema
|
|
1240
1319
|
else:
|
|
@@ -1245,9 +1324,8 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1245
1324
|
param.schema_.copy(), param.description
|
|
1246
1325
|
)
|
|
1247
1326
|
|
|
1248
|
-
#
|
|
1249
|
-
|
|
1250
|
-
param_schema = _make_optional_parameter_nullable(param_schema)
|
|
1327
|
+
# Don't make optional parameters nullable - they can simply be omitted
|
|
1328
|
+
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
1251
1329
|
|
|
1252
1330
|
properties[param.name] = param_schema
|
|
1253
1331
|
|
|
@@ -1266,10 +1344,42 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1266
1344
|
}
|
|
1267
1345
|
# Add schema definitions if available
|
|
1268
1346
|
if route.schema_definitions:
|
|
1269
|
-
result["$defs"] = route.schema_definitions
|
|
1270
|
-
|
|
1271
|
-
# Use
|
|
1272
|
-
result
|
|
1347
|
+
result["$defs"] = route.schema_definitions.copy()
|
|
1348
|
+
|
|
1349
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
1350
|
+
if result.get("additionalProperties") is False:
|
|
1351
|
+
result.pop("additionalProperties")
|
|
1352
|
+
|
|
1353
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
1354
|
+
if "$defs" in result:
|
|
1355
|
+
used_refs = set()
|
|
1356
|
+
|
|
1357
|
+
def find_refs_in_value(value):
|
|
1358
|
+
if isinstance(value, dict):
|
|
1359
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
1360
|
+
ref = value["$ref"]
|
|
1361
|
+
if ref.startswith("#/$defs/"):
|
|
1362
|
+
used_refs.add(ref.split("/")[-1])
|
|
1363
|
+
for v in value.values():
|
|
1364
|
+
find_refs_in_value(v)
|
|
1365
|
+
elif isinstance(value, list):
|
|
1366
|
+
for item in value:
|
|
1367
|
+
find_refs_in_value(item)
|
|
1368
|
+
|
|
1369
|
+
# Find refs in the main schema (excluding $defs section)
|
|
1370
|
+
for key, value in result.items():
|
|
1371
|
+
if key != "$defs":
|
|
1372
|
+
find_refs_in_value(value)
|
|
1373
|
+
|
|
1374
|
+
# Remove unused definitions
|
|
1375
|
+
if used_refs:
|
|
1376
|
+
result["$defs"] = {
|
|
1377
|
+
name: def_schema
|
|
1378
|
+
for name, def_schema in result["$defs"].items()
|
|
1379
|
+
if name in used_refs
|
|
1380
|
+
}
|
|
1381
|
+
else:
|
|
1382
|
+
result.pop("$defs")
|
|
1273
1383
|
|
|
1274
1384
|
return result
|
|
1275
1385
|
|
|
@@ -1279,17 +1389,41 @@ def _adjust_union_types(
|
|
|
1279
1389
|
) -> dict[str, Any] | list[Any]:
|
|
1280
1390
|
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
|
1281
1391
|
if isinstance(schema, dict):
|
|
1282
|
-
if
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1392
|
+
# Optimize: only copy if we need to modify something
|
|
1393
|
+
has_one_of = "oneOf" in schema
|
|
1394
|
+
needs_recursive_processing = False
|
|
1395
|
+
|
|
1396
|
+
# Check if we need recursive processing
|
|
1397
|
+
for v in schema.values():
|
|
1398
|
+
if isinstance(v, dict | list):
|
|
1399
|
+
needs_recursive_processing = True
|
|
1400
|
+
break
|
|
1401
|
+
|
|
1402
|
+
# If nothing to change, return original
|
|
1403
|
+
if not has_one_of and not needs_recursive_processing:
|
|
1404
|
+
return schema
|
|
1405
|
+
|
|
1406
|
+
# Work on a copy only when modification is needed
|
|
1407
|
+
result = schema.copy()
|
|
1408
|
+
if has_one_of:
|
|
1409
|
+
result["anyOf"] = result.pop("oneOf")
|
|
1410
|
+
|
|
1411
|
+
# Only recurse where needed
|
|
1412
|
+
if needs_recursive_processing:
|
|
1413
|
+
for k, v in result.items():
|
|
1414
|
+
if isinstance(v, dict | list):
|
|
1415
|
+
result[k] = _adjust_union_types(v)
|
|
1416
|
+
|
|
1417
|
+
return result
|
|
1286
1418
|
elif isinstance(schema, list):
|
|
1287
1419
|
return [_adjust_union_types(item) for item in schema]
|
|
1288
1420
|
return schema
|
|
1289
1421
|
|
|
1290
1422
|
|
|
1291
1423
|
def extract_output_schema_from_responses(
|
|
1292
|
-
responses: dict[str, ResponseInfo],
|
|
1424
|
+
responses: dict[str, ResponseInfo],
|
|
1425
|
+
schema_definitions: dict[str, Any] | None = None,
|
|
1426
|
+
openapi_version: str | None = None,
|
|
1293
1427
|
) -> dict[str, Any] | None:
|
|
1294
1428
|
"""
|
|
1295
1429
|
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
@@ -1301,6 +1435,7 @@ def extract_output_schema_from_responses(
|
|
|
1301
1435
|
Args:
|
|
1302
1436
|
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
1303
1437
|
schema_definitions: Optional schema definitions to include in the output schema
|
|
1438
|
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
|
1304
1439
|
|
|
1305
1440
|
Returns:
|
|
1306
1441
|
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
@@ -1357,6 +1492,21 @@ def extract_output_schema_from_responses(
|
|
|
1357
1492
|
# Clean and copy the schema
|
|
1358
1493
|
output_schema = schema.copy()
|
|
1359
1494
|
|
|
1495
|
+
# If schema has a $ref, resolve it first before processing nullable fields
|
|
1496
|
+
if "$ref" in output_schema and schema_definitions:
|
|
1497
|
+
ref_path = output_schema["$ref"]
|
|
1498
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
1499
|
+
schema_name = ref_path.split("/")[-1]
|
|
1500
|
+
if schema_name in schema_definitions:
|
|
1501
|
+
# Replace $ref with the actual schema definition
|
|
1502
|
+
output_schema = schema_definitions[schema_name].copy()
|
|
1503
|
+
|
|
1504
|
+
# Handle OpenAPI nullable fields by converting them to JSON Schema format
|
|
1505
|
+
# This prevents "None is not of type 'string'" validation errors
|
|
1506
|
+
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
1507
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
1508
|
+
output_schema = _handle_nullable_fields(output_schema)
|
|
1509
|
+
|
|
1360
1510
|
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
1361
1511
|
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
1362
1512
|
if output_schema.get("type") != "object":
|
|
@@ -1369,12 +1519,52 @@ def extract_output_schema_from_responses(
|
|
|
1369
1519
|
}
|
|
1370
1520
|
output_schema = wrapped_schema
|
|
1371
1521
|
|
|
1372
|
-
# Add schema definitions if available
|
|
1373
|
-
if
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1522
|
+
# Add schema definitions if available and handle nullable fields in them
|
|
1523
|
+
# Only add $defs if we didn't resolve the $ref inline above
|
|
1524
|
+
if schema_definitions and "$ref" not in schema.copy():
|
|
1525
|
+
processed_defs = {}
|
|
1526
|
+
for def_name, def_schema in schema_definitions.items():
|
|
1527
|
+
# Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
1528
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
1529
|
+
processed_defs[def_name] = _handle_nullable_fields(def_schema)
|
|
1530
|
+
else:
|
|
1531
|
+
processed_defs[def_name] = def_schema
|
|
1532
|
+
output_schema["$defs"] = processed_defs
|
|
1533
|
+
|
|
1534
|
+
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
1535
|
+
if output_schema.get("additionalProperties") is False:
|
|
1536
|
+
output_schema.pop("additionalProperties")
|
|
1537
|
+
|
|
1538
|
+
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
1539
|
+
if "$defs" in output_schema:
|
|
1540
|
+
used_refs = set()
|
|
1541
|
+
|
|
1542
|
+
def find_refs_in_value(value):
|
|
1543
|
+
if isinstance(value, dict):
|
|
1544
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
1545
|
+
ref = value["$ref"]
|
|
1546
|
+
if ref.startswith("#/$defs/"):
|
|
1547
|
+
used_refs.add(ref.split("/")[-1])
|
|
1548
|
+
for v in value.values():
|
|
1549
|
+
find_refs_in_value(v)
|
|
1550
|
+
elif isinstance(value, list):
|
|
1551
|
+
for item in value:
|
|
1552
|
+
find_refs_in_value(item)
|
|
1553
|
+
|
|
1554
|
+
# Find refs in the main schema (excluding $defs section)
|
|
1555
|
+
for key, value in output_schema.items():
|
|
1556
|
+
if key != "$defs":
|
|
1557
|
+
find_refs_in_value(value)
|
|
1558
|
+
|
|
1559
|
+
# Remove unused definitions
|
|
1560
|
+
if used_refs:
|
|
1561
|
+
output_schema["$defs"] = {
|
|
1562
|
+
name: def_schema
|
|
1563
|
+
for name, def_schema in output_schema["$defs"].items()
|
|
1564
|
+
if name in used_refs
|
|
1565
|
+
}
|
|
1566
|
+
else:
|
|
1567
|
+
output_schema.pop("$defs")
|
|
1378
1568
|
|
|
1379
1569
|
# Adjust union types to handle overlapping unions
|
|
1380
1570
|
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}")
|
fastmcp/utilities/types.py
CHANGED
|
@@ -8,11 +8,19 @@ from collections.abc import Callable
|
|
|
8
8
|
from functools import lru_cache
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from types import EllipsisType, UnionType
|
|
11
|
-
from typing import
|
|
11
|
+
from typing import (
|
|
12
|
+
Annotated,
|
|
13
|
+
TypeAlias,
|
|
14
|
+
TypeVar,
|
|
15
|
+
Union,
|
|
16
|
+
get_args,
|
|
17
|
+
get_origin,
|
|
18
|
+
get_type_hints,
|
|
19
|
+
)
|
|
12
20
|
|
|
13
21
|
import mcp.types
|
|
14
22
|
from mcp.types import Annotations
|
|
15
|
-
from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
|
|
23
|
+
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints
|
|
16
24
|
|
|
17
25
|
T = TypeVar("T")
|
|
18
26
|
|
|
@@ -35,6 +43,66 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
|
|
|
35
43
|
However, this isn't feasible for user-generated functions. Instead, we use a
|
|
36
44
|
cache to minimize the cost of creating them as much as possible.
|
|
37
45
|
"""
|
|
46
|
+
# For functions, process annotations to handle forward references and convert
|
|
47
|
+
# Annotated[Type, "string"] to Annotated[Type, Field(description="string")]
|
|
48
|
+
if inspect.isfunction(cls) or inspect.ismethod(cls):
|
|
49
|
+
if hasattr(cls, "__annotations__") and cls.__annotations__:
|
|
50
|
+
try:
|
|
51
|
+
# Resolve forward references first
|
|
52
|
+
resolved_hints = get_type_hints(cls, include_extras=True)
|
|
53
|
+
except Exception:
|
|
54
|
+
# If forward reference resolution fails, use original annotations
|
|
55
|
+
resolved_hints = cls.__annotations__
|
|
56
|
+
|
|
57
|
+
# Process annotations to convert string descriptions to Fields
|
|
58
|
+
processed_hints = {}
|
|
59
|
+
|
|
60
|
+
for name, annotation in resolved_hints.items():
|
|
61
|
+
# Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")]
|
|
62
|
+
if (
|
|
63
|
+
get_origin(annotation) is Annotated
|
|
64
|
+
and len(get_args(annotation)) == 2
|
|
65
|
+
and isinstance(get_args(annotation)[1], str)
|
|
66
|
+
):
|
|
67
|
+
base_type, description = get_args(annotation)
|
|
68
|
+
processed_hints[name] = Annotated[
|
|
69
|
+
base_type, Field(description=description)
|
|
70
|
+
]
|
|
71
|
+
else:
|
|
72
|
+
processed_hints[name] = annotation
|
|
73
|
+
|
|
74
|
+
# Create new function if annotations changed
|
|
75
|
+
if processed_hints != cls.__annotations__:
|
|
76
|
+
import types
|
|
77
|
+
|
|
78
|
+
# Handle both functions and methods
|
|
79
|
+
if inspect.ismethod(cls):
|
|
80
|
+
actual_func = cls.__func__
|
|
81
|
+
code = actual_func.__code__
|
|
82
|
+
globals_dict = actual_func.__globals__
|
|
83
|
+
name = actual_func.__name__
|
|
84
|
+
defaults = actual_func.__defaults__
|
|
85
|
+
closure = actual_func.__closure__
|
|
86
|
+
else:
|
|
87
|
+
code = cls.__code__
|
|
88
|
+
globals_dict = cls.__globals__
|
|
89
|
+
name = cls.__name__
|
|
90
|
+
defaults = cls.__defaults__
|
|
91
|
+
closure = cls.__closure__
|
|
92
|
+
|
|
93
|
+
new_func = types.FunctionType(
|
|
94
|
+
code,
|
|
95
|
+
globals_dict,
|
|
96
|
+
name,
|
|
97
|
+
defaults,
|
|
98
|
+
closure,
|
|
99
|
+
)
|
|
100
|
+
new_func.__dict__.update(cls.__dict__)
|
|
101
|
+
new_func.__module__ = cls.__module__
|
|
102
|
+
new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
|
|
103
|
+
new_func.__annotations__ = processed_hints
|
|
104
|
+
return TypeAdapter(new_func)
|
|
105
|
+
|
|
38
106
|
return TypeAdapter(cls)
|
|
39
107
|
|
|
40
108
|
|
|
@@ -77,12 +145,21 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
|
|
|
77
145
|
Includes union types that contain the kwarg_type, as well as Annotated types.
|
|
78
146
|
"""
|
|
79
147
|
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
|
80
|
-
|
|
81
|
-
else:
|
|
82
|
-
sig = inspect.signature(fn)
|
|
148
|
+
fn = fn.__func__
|
|
83
149
|
|
|
150
|
+
# Try to get resolved type hints
|
|
151
|
+
try:
|
|
152
|
+
# Use include_extras=True to preserve Annotated metadata
|
|
153
|
+
type_hints = get_type_hints(fn, include_extras=True)
|
|
154
|
+
except Exception:
|
|
155
|
+
# If resolution fails, use raw annotations if they exist
|
|
156
|
+
type_hints = getattr(fn, "__annotations__", {})
|
|
157
|
+
|
|
158
|
+
sig = inspect.signature(fn)
|
|
84
159
|
for name, param in sig.parameters.items():
|
|
85
|
-
if
|
|
160
|
+
# Use resolved hint if available, otherwise raw annotation
|
|
161
|
+
annotation = type_hints.get(name, param.annotation)
|
|
162
|
+
if is_class_member_of_type(annotation, kwarg_type):
|
|
86
163
|
return name
|
|
87
164
|
return None
|
|
88
165
|
|
|
@@ -303,12 +380,13 @@ def replace_type(type_, type_map: dict[type, type]):
|
|
|
303
380
|
new_type: The type to replace old_type with.
|
|
304
381
|
|
|
305
382
|
Examples:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
>>> replace_type(list[list[int]], {int: str})
|
|
310
|
-
list[list[str]]
|
|
383
|
+
```python
|
|
384
|
+
>>> replace_type(list[int | bool], {int: str})
|
|
385
|
+
list[str | bool]
|
|
311
386
|
|
|
387
|
+
>>> replace_type(list[list[int]], {int: str})
|
|
388
|
+
list[list[str]]
|
|
389
|
+
```
|
|
312
390
|
"""
|
|
313
391
|
if type_ in type_map:
|
|
314
392
|
return type_map[type_]
|