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.
Files changed (61) hide show
  1. fastmcp/cli/cli.py +128 -33
  2. fastmcp/cli/install/claude_code.py +42 -1
  3. fastmcp/cli/install/claude_desktop.py +42 -1
  4. fastmcp/cli/install/cursor.py +42 -1
  5. fastmcp/cli/install/mcp_json.py +41 -0
  6. fastmcp/cli/run.py +127 -1
  7. fastmcp/client/__init__.py +2 -0
  8. fastmcp/client/auth/oauth.py +68 -99
  9. fastmcp/client/oauth_callback.py +18 -0
  10. fastmcp/client/transports.py +69 -15
  11. fastmcp/contrib/component_manager/example.py +2 -2
  12. fastmcp/experimental/server/openapi/README.md +266 -0
  13. fastmcp/experimental/server/openapi/__init__.py +38 -0
  14. fastmcp/experimental/server/openapi/components.py +348 -0
  15. fastmcp/experimental/server/openapi/routing.py +132 -0
  16. fastmcp/experimental/server/openapi/server.py +466 -0
  17. fastmcp/experimental/utilities/openapi/README.md +239 -0
  18. fastmcp/experimental/utilities/openapi/__init__.py +68 -0
  19. fastmcp/experimental/utilities/openapi/director.py +208 -0
  20. fastmcp/experimental/utilities/openapi/formatters.py +355 -0
  21. fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
  22. fastmcp/experimental/utilities/openapi/models.py +85 -0
  23. fastmcp/experimental/utilities/openapi/parser.py +618 -0
  24. fastmcp/experimental/utilities/openapi/schemas.py +538 -0
  25. fastmcp/mcp_config.py +125 -88
  26. fastmcp/prompts/prompt.py +11 -1
  27. fastmcp/resources/resource.py +21 -1
  28. fastmcp/resources/template.py +20 -1
  29. fastmcp/server/auth/__init__.py +18 -2
  30. fastmcp/server/auth/auth.py +225 -7
  31. fastmcp/server/auth/providers/bearer.py +25 -473
  32. fastmcp/server/auth/providers/in_memory.py +4 -2
  33. fastmcp/server/auth/providers/jwt.py +538 -0
  34. fastmcp/server/auth/providers/workos.py +151 -0
  35. fastmcp/server/auth/registry.py +52 -0
  36. fastmcp/server/context.py +107 -26
  37. fastmcp/server/dependencies.py +9 -2
  38. fastmcp/server/http.py +48 -57
  39. fastmcp/server/middleware/middleware.py +3 -23
  40. fastmcp/server/openapi.py +1 -1
  41. fastmcp/server/proxy.py +50 -11
  42. fastmcp/server/server.py +168 -59
  43. fastmcp/settings.py +73 -6
  44. fastmcp/tools/tool.py +36 -3
  45. fastmcp/tools/tool_manager.py +38 -2
  46. fastmcp/tools/tool_transform.py +112 -3
  47. fastmcp/utilities/components.py +41 -3
  48. fastmcp/utilities/json_schema.py +136 -98
  49. fastmcp/utilities/json_schema_type.py +1 -3
  50. fastmcp/utilities/mcp_config.py +28 -0
  51. fastmcp/utilities/openapi.py +243 -57
  52. fastmcp/utilities/tests.py +54 -6
  53. fastmcp/utilities/types.py +94 -11
  54. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/METADATA +4 -3
  55. fastmcp-2.11.1.dist-info/RECORD +108 -0
  56. fastmcp/server/auth/providers/bearer_env.py +0 -63
  57. fastmcp/utilities/cache.py +0 -26
  58. fastmcp-2.10.6.dist-info/RECORD +0 -93
  59. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/WHEEL +0 -0
  60. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.dist-info}/entry_points.txt +0 -0
  61. {fastmcp-2.10.6.dist-info → fastmcp-2.11.1.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)
@@ -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
- str_value = (
87
- str(values)
88
- .replace("[", "")
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&param[type]=user
100
+ For example: `{"id": "123", "type": "user"}` becomes `param[id]=123&param[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
- nullable_schema = schema.copy()
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
- # If the original type is an array, move the array-specific properties into the now-nested schema
1127
- # https://json-schema.org/understanding-json-schema/reference/array
1127
+ # Define type-specific properties that should move to nested schema
1128
+ type_specific_properties = set()
1128
1129
  if original_type == "array":
1129
- for array_property in [
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
- for object_property in [
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
- nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
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
- # Remove the original type since we're using anyOf
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
- # Make optional parameters nullable to allow None values
1236
- if not param.required:
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
- # Make optional parameters nullable to allow None values
1249
- if not param.required:
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 compress_schema to remove unused definitions
1272
- result = compress_schema(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 "oneOf" in schema:
1283
- schema["anyOf"] = schema.pop("oneOf")
1284
- for k, v in schema.items():
1285
- schema[k] = _adjust_union_types(v)
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], schema_definitions: dict[str, Any] | None = None
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 schema_definitions:
1374
- output_schema["$defs"] = schema_definitions
1375
-
1376
- # Use compress_schema to remove unused definitions
1377
- output_schema = compress_schema(output_schema)
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))
@@ -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}")