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.
Files changed (65) hide show
  1. fastmcp/__init__.py +7 -2
  2. fastmcp/cli/cli.py +128 -33
  3. fastmcp/cli/install/__init__.py +2 -2
  4. fastmcp/cli/install/claude_code.py +42 -1
  5. fastmcp/cli/install/claude_desktop.py +42 -1
  6. fastmcp/cli/install/cursor.py +42 -1
  7. fastmcp/cli/install/{mcp_config.py → mcp_json.py} +51 -7
  8. fastmcp/cli/run.py +127 -1
  9. fastmcp/client/__init__.py +2 -0
  10. fastmcp/client/auth/oauth.py +68 -99
  11. fastmcp/client/oauth_callback.py +18 -0
  12. fastmcp/client/transports.py +69 -15
  13. fastmcp/contrib/component_manager/example.py +2 -2
  14. fastmcp/experimental/server/openapi/README.md +266 -0
  15. fastmcp/experimental/server/openapi/__init__.py +38 -0
  16. fastmcp/experimental/server/openapi/components.py +348 -0
  17. fastmcp/experimental/server/openapi/routing.py +132 -0
  18. fastmcp/experimental/server/openapi/server.py +466 -0
  19. fastmcp/experimental/utilities/openapi/README.md +239 -0
  20. fastmcp/experimental/utilities/openapi/__init__.py +68 -0
  21. fastmcp/experimental/utilities/openapi/director.py +208 -0
  22. fastmcp/experimental/utilities/openapi/formatters.py +355 -0
  23. fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
  24. fastmcp/experimental/utilities/openapi/models.py +85 -0
  25. fastmcp/experimental/utilities/openapi/parser.py +618 -0
  26. fastmcp/experimental/utilities/openapi/schemas.py +538 -0
  27. fastmcp/mcp_config.py +125 -88
  28. fastmcp/prompts/prompt.py +11 -1
  29. fastmcp/prompts/prompt_manager.py +1 -1
  30. fastmcp/resources/resource.py +21 -1
  31. fastmcp/resources/resource_manager.py +2 -2
  32. fastmcp/resources/template.py +20 -1
  33. fastmcp/server/auth/__init__.py +17 -2
  34. fastmcp/server/auth/auth.py +144 -7
  35. fastmcp/server/auth/providers/bearer.py +25 -473
  36. fastmcp/server/auth/providers/in_memory.py +4 -2
  37. fastmcp/server/auth/providers/jwt.py +538 -0
  38. fastmcp/server/auth/providers/workos.py +170 -0
  39. fastmcp/server/auth/registry.py +52 -0
  40. fastmcp/server/context.py +110 -26
  41. fastmcp/server/dependencies.py +9 -2
  42. fastmcp/server/http.py +62 -30
  43. fastmcp/server/middleware/middleware.py +3 -23
  44. fastmcp/server/openapi.py +26 -13
  45. fastmcp/server/proxy.py +89 -8
  46. fastmcp/server/server.py +170 -62
  47. fastmcp/settings.py +83 -18
  48. fastmcp/tools/tool.py +41 -6
  49. fastmcp/tools/tool_manager.py +39 -3
  50. fastmcp/tools/tool_transform.py +122 -6
  51. fastmcp/utilities/components.py +35 -2
  52. fastmcp/utilities/json_schema.py +136 -98
  53. fastmcp/utilities/json_schema_type.py +1 -3
  54. fastmcp/utilities/mcp_config.py +28 -0
  55. fastmcp/utilities/openapi.py +306 -30
  56. fastmcp/utilities/tests.py +54 -6
  57. fastmcp/utilities/types.py +89 -11
  58. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
  59. fastmcp-2.11.0.dist-info/RECORD +108 -0
  60. fastmcp/server/auth/providers/bearer_env.py +0 -63
  61. fastmcp/utilities/cache.py +0 -26
  62. fastmcp-2.10.5.dist-info/RECORD +0 -93
  63. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
  64. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
  65. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -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.json_schema import compress_schema
26
+ from fastmcp.utilities.logging import get_logger
28
27
  from fastmcp.utilities.types import FastMCPBaseModel
29
28
 
30
- logger = logging.getLogger(__name__)
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&param[type]=user
104
+ For example: `{"id": "123", "type": "user"}` becomes `param[id]=123&param[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.startswith("#/components/schemas/"):
1072
- schema_name = ref_path.split("/")[-1]
1073
- schema["$ref"] = f"#/$defs/{schema_name}"
1074
- elif not ref_path.startswith("#/"):
1075
- raise ValueError(
1076
- f"External or non-local reference not supported: {ref_path}. "
1077
- f"FastMCP only supports local schema references starting with '#/'. "
1078
- f"Please include all schema definitions within the OpenAPI document."
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
- properties[param.name] = _replace_ref_with_defs(
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 compress_schema to remove unused definitions
1186
- result = compress_schema(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 "oneOf" in schema:
1197
- schema["anyOf"] = schema.pop("oneOf")
1198
- for k, v in schema.items():
1199
- schema[k] = _adjust_union_types(v)
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], schema_definitions: dict[str, Any] | None = None
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 schema_definitions:
1288
- output_schema["$defs"] = schema_definitions
1289
-
1290
- # Use compress_schema to remove unused definitions
1291
- output_schema = compress_schema(output_schema)
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))
@@ -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.model_dump())
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
- if not hasattr(settings, attr):
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
- if hasattr(settings, attr):
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}")