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.
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 +17 -2
  30. fastmcp/server/auth/auth.py +144 -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 +170 -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 +62 -30
  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 +35 -2
  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 +240 -50
  52. fastmcp/utilities/tests.py +54 -6
  53. fastmcp/utilities/types.py +89 -11
  54. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
  55. fastmcp-2.11.0.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.0.dist-info}/WHEEL +0 -0
  60. {fastmcp-2.10.6.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
  61. {fastmcp-2.10.6.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(
@@ -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
- nullable_schema = schema.copy()
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
- # 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
1131
+ # Define type-specific properties that should move to nested schema
1132
+ type_specific_properties = set()
1128
1133
  if original_type == "array":
1129
- for array_property in [
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
- for object_property in [
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
- nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
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
- # Remove the original type since we're using anyOf
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
- # Make optional parameters nullable to allow None values
1236
- if not param.required:
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
- # Make optional parameters nullable to allow None values
1249
- if not param.required:
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 compress_schema to remove unused definitions
1272
- 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")
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 "oneOf" in schema:
1283
- schema["anyOf"] = schema.pop("oneOf")
1284
- for k, v in schema.items():
1285
- 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
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], 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,
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 schema_definitions:
1374
- output_schema["$defs"] = schema_definitions
1375
-
1376
- # Use compress_schema to remove unused definitions
1377
- 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")
1378
1568
 
1379
1569
  # Adjust union types to handle overlapping unions
1380
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}")
@@ -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 Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
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
- sig = inspect.signature(fn.__func__)
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 is_class_member_of_type(param.annotation, kwarg_type):
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
- >>> replace_type(list[int | bool], {int: str})
307
- list[str | bool]
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_]