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
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(
|
|
@@ -1068,15 +1073,16 @@ def _replace_ref_with_defs(
|
|
|
1068
1073
|
"""
|
|
1069
1074
|
schema = info.copy()
|
|
1070
1075
|
if ref_path := schema.get("$ref"):
|
|
1071
|
-
if ref_path
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1076
|
+
if isinstance(ref_path, str):
|
|
1077
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
1078
|
+
schema_name = ref_path.split("/")[-1]
|
|
1079
|
+
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
1080
|
+
elif not ref_path.startswith("#/"):
|
|
1081
|
+
raise ValueError(
|
|
1082
|
+
f"External or non-local reference not supported: {ref_path}. "
|
|
1083
|
+
f"FastMCP only supports local schema references starting with '#/'. "
|
|
1084
|
+
f"Please include all schema definitions within the OpenAPI document."
|
|
1085
|
+
)
|
|
1080
1086
|
elif properties := schema.get("properties"):
|
|
1081
1087
|
if "$ref" in properties:
|
|
1082
1088
|
schema["properties"] = _replace_ref_with_defs(properties)
|
|
@@ -1095,6 +1101,156 @@ def _replace_ref_with_defs(
|
|
|
1095
1101
|
return schema
|
|
1096
1102
|
|
|
1097
1103
|
|
|
1104
|
+
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
1105
|
+
"""
|
|
1106
|
+
Make an optional parameter schema nullable to allow None values.
|
|
1107
|
+
|
|
1108
|
+
For optional parameters, we need to allow null values in addition to the
|
|
1109
|
+
specified type to handle cases where None is passed for optional parameters.
|
|
1110
|
+
"""
|
|
1111
|
+
# If schema already has multiple types or is already nullable, don't modify
|
|
1112
|
+
if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
|
|
1113
|
+
return schema
|
|
1114
|
+
|
|
1115
|
+
# If it's already nullable (type includes null), don't modify
|
|
1116
|
+
if isinstance(schema.get("type"), list) and "null" in schema["type"]:
|
|
1117
|
+
return schema
|
|
1118
|
+
|
|
1119
|
+
# Create a new schema that allows null in addition to the original type
|
|
1120
|
+
if "type" in schema:
|
|
1121
|
+
original_type = schema["type"]
|
|
1122
|
+
|
|
1123
|
+
if isinstance(original_type, str):
|
|
1124
|
+
# Single type - make it a union with null
|
|
1125
|
+
# Optimize: avoid full schema copy by building directly
|
|
1126
|
+
nested_non_nullable_schema = {
|
|
1127
|
+
"type": original_type,
|
|
1128
|
+
}
|
|
1129
|
+
nullable_schema = {}
|
|
1130
|
+
|
|
1131
|
+
# Define type-specific properties that should move to nested schema
|
|
1132
|
+
type_specific_properties = set()
|
|
1133
|
+
if original_type == "array":
|
|
1134
|
+
# https://json-schema.org/understanding-json-schema/reference/array
|
|
1135
|
+
type_specific_properties = {
|
|
1136
|
+
"items",
|
|
1137
|
+
"prefixItems",
|
|
1138
|
+
"unevaluatedItems",
|
|
1139
|
+
"contains",
|
|
1140
|
+
"minContains",
|
|
1141
|
+
"maxContains",
|
|
1142
|
+
"minItems",
|
|
1143
|
+
"maxItems",
|
|
1144
|
+
"uniqueItems",
|
|
1145
|
+
}
|
|
1146
|
+
elif original_type == "object":
|
|
1147
|
+
# https://json-schema.org/understanding-json-schema/reference/object
|
|
1148
|
+
type_specific_properties = {
|
|
1149
|
+
"properties",
|
|
1150
|
+
"patternProperties",
|
|
1151
|
+
"additionalProperties",
|
|
1152
|
+
"unevaluatedProperties",
|
|
1153
|
+
"required",
|
|
1154
|
+
"propertyNames",
|
|
1155
|
+
"minProperties",
|
|
1156
|
+
"maxProperties",
|
|
1157
|
+
}
|
|
1158
|
+
|
|
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
|
|
1167
|
+
|
|
1168
|
+
nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
|
|
1169
|
+
return nullable_schema
|
|
1170
|
+
|
|
1171
|
+
return schema
|
|
1172
|
+
|
|
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
|
+
|
|
1098
1254
|
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
1099
1255
|
"""
|
|
1100
1256
|
Combines parameter and request body schemas into a single schema.
|
|
@@ -1156,15 +1312,23 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1156
1312
|
else:
|
|
1157
1313
|
param_schema["description"] = location_desc
|
|
1158
1314
|
|
|
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
|
|
1317
|
+
|
|
1159
1318
|
properties[suffixed_name] = param_schema
|
|
1160
1319
|
else:
|
|
1161
1320
|
# No collision, use original name
|
|
1162
1321
|
if param.required:
|
|
1163
1322
|
required.append(param.name)
|
|
1164
|
-
|
|
1323
|
+
param_schema = _replace_ref_with_defs(
|
|
1165
1324
|
param.schema_.copy(), param.description
|
|
1166
1325
|
)
|
|
1167
1326
|
|
|
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
|
|
1329
|
+
|
|
1330
|
+
properties[param.name] = param_schema
|
|
1331
|
+
|
|
1168
1332
|
# Add request body properties (no suffixes for body parameters)
|
|
1169
1333
|
if route.request_body and route.request_body.content_schema:
|
|
1170
1334
|
for prop_name, prop_schema in body_props.items():
|
|
@@ -1180,10 +1344,42 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1180
1344
|
}
|
|
1181
1345
|
# Add schema definitions if available
|
|
1182
1346
|
if route.schema_definitions:
|
|
1183
|
-
result["$defs"] = route.schema_definitions
|
|
1184
|
-
|
|
1185
|
-
# Use
|
|
1186
|
-
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")
|
|
1187
1383
|
|
|
1188
1384
|
return result
|
|
1189
1385
|
|
|
@@ -1193,17 +1389,41 @@ def _adjust_union_types(
|
|
|
1193
1389
|
) -> dict[str, Any] | list[Any]:
|
|
1194
1390
|
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
|
1195
1391
|
if isinstance(schema, dict):
|
|
1196
|
-
if
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
|
1200
1418
|
elif isinstance(schema, list):
|
|
1201
1419
|
return [_adjust_union_types(item) for item in schema]
|
|
1202
1420
|
return schema
|
|
1203
1421
|
|
|
1204
1422
|
|
|
1205
1423
|
def extract_output_schema_from_responses(
|
|
1206
|
-
responses: dict[str, ResponseInfo],
|
|
1424
|
+
responses: dict[str, ResponseInfo],
|
|
1425
|
+
schema_definitions: dict[str, Any] | None = None,
|
|
1426
|
+
openapi_version: str | None = None,
|
|
1207
1427
|
) -> dict[str, Any] | None:
|
|
1208
1428
|
"""
|
|
1209
1429
|
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
@@ -1215,6 +1435,7 @@ def extract_output_schema_from_responses(
|
|
|
1215
1435
|
Args:
|
|
1216
1436
|
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
1217
1437
|
schema_definitions: Optional schema definitions to include in the output schema
|
|
1438
|
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
|
1218
1439
|
|
|
1219
1440
|
Returns:
|
|
1220
1441
|
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
@@ -1271,6 +1492,21 @@ def extract_output_schema_from_responses(
|
|
|
1271
1492
|
# Clean and copy the schema
|
|
1272
1493
|
output_schema = schema.copy()
|
|
1273
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
|
+
|
|
1274
1510
|
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
1275
1511
|
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
1276
1512
|
if output_schema.get("type") != "object":
|
|
@@ -1283,12 +1519,52 @@ def extract_output_schema_from_responses(
|
|
|
1283
1519
|
}
|
|
1284
1520
|
output_schema = wrapped_schema
|
|
1285
1521
|
|
|
1286
|
-
# Add schema definitions if available
|
|
1287
|
-
if
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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")
|
|
1292
1568
|
|
|
1293
1569
|
# Adjust union types to handle overlapping unions
|
|
1294
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}")
|