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
@@ -0,0 +1,538 @@
1
+ """Schema manipulation utilities for OpenAPI operations."""
2
+
3
+ from typing import Any
4
+
5
+ from fastmcp.utilities.logging import get_logger
6
+
7
+ from .models import HTTPRoute, JsonSchema, ResponseInfo
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
13
+ """
14
+ Clean up a schema dictionary for display by removing internal/complex fields.
15
+ """
16
+ if not schema or not isinstance(schema, dict):
17
+ return schema
18
+
19
+ # Make a copy to avoid modifying the input schema
20
+ cleaned = schema.copy()
21
+
22
+ # Fields commonly removed for simpler display to LLMs or users
23
+ fields_to_remove = [
24
+ "allOf",
25
+ "anyOf",
26
+ "oneOf",
27
+ "not", # Composition keywords
28
+ "nullable", # Handled by type unions usually
29
+ "discriminator",
30
+ "readOnly",
31
+ "writeOnly",
32
+ "deprecated",
33
+ "xml",
34
+ "externalDocs",
35
+ # Can be verbose, maybe remove based on flag?
36
+ # "pattern", "minLength", "maxLength",
37
+ # "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
38
+ # "multipleOf", "minItems", "maxItems", "uniqueItems",
39
+ # "minProperties", "maxProperties"
40
+ ]
41
+
42
+ for field in fields_to_remove:
43
+ if field in cleaned:
44
+ cleaned.pop(field)
45
+
46
+ # Recursively clean properties and items
47
+ if "properties" in cleaned:
48
+ cleaned["properties"] = {
49
+ k: clean_schema_for_display(v) for k, v in cleaned["properties"].items()
50
+ }
51
+ # Remove properties section if empty after cleaning
52
+ if not cleaned["properties"]:
53
+ cleaned.pop("properties")
54
+
55
+ if "items" in cleaned:
56
+ cleaned["items"] = clean_schema_for_display(cleaned["items"])
57
+ # Remove items section if empty after cleaning
58
+ if not cleaned["items"]:
59
+ cleaned.pop("items")
60
+
61
+ if "additionalProperties" in cleaned:
62
+ # Often verbose, can be simplified
63
+ if isinstance(cleaned["additionalProperties"], dict):
64
+ cleaned["additionalProperties"] = clean_schema_for_display(
65
+ cleaned["additionalProperties"]
66
+ )
67
+ elif cleaned["additionalProperties"] is True:
68
+ # Maybe keep 'true' or represent as 'Allows additional properties' text?
69
+ pass # Keep simple boolean for now
70
+
71
+ return cleaned
72
+
73
+
74
+ def _replace_ref_with_defs(
75
+ info: dict[str, Any], description: str | None = None
76
+ ) -> dict[str, Any]:
77
+ """
78
+ Replace openapi $ref with jsonschema $defs
79
+
80
+ Examples:
81
+ - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
82
+ - {"$ref": "#/components/schemas/..."}
83
+ - {"items": {"$ref": "#/components/schemas/..."}}
84
+ - {"anyOf": [{"$ref": "#/components/schemas/..."}]}
85
+ - {"allOf": [{"$ref": "#/components/schemas/..."}]}
86
+ - {"oneOf": [{"$ref": "#/components/schemas/..."}]}
87
+
88
+ Args:
89
+ info: dict[str, Any]
90
+ description: str | None
91
+
92
+ Returns:
93
+ dict[str, Any]
94
+ """
95
+ schema = info.copy()
96
+ if ref_path := schema.get("$ref"):
97
+ if isinstance(ref_path, str):
98
+ if ref_path.startswith("#/components/schemas/"):
99
+ schema_name = ref_path.split("/")[-1]
100
+ schema["$ref"] = f"#/$defs/{schema_name}"
101
+ elif not ref_path.startswith("#/"):
102
+ raise ValueError(
103
+ f"External or non-local reference not supported: {ref_path}. "
104
+ f"FastMCP only supports local schema references starting with '#/'. "
105
+ f"Please include all schema definitions within the OpenAPI document."
106
+ )
107
+ elif properties := schema.get("properties"):
108
+ if "$ref" in properties:
109
+ schema["properties"] = _replace_ref_with_defs(properties)
110
+ else:
111
+ schema["properties"] = {
112
+ prop_name: _replace_ref_with_defs(prop_schema)
113
+ for prop_name, prop_schema in properties.items()
114
+ }
115
+ elif item_schema := schema.get("items"):
116
+ schema["items"] = _replace_ref_with_defs(item_schema)
117
+ for section in ["anyOf", "allOf", "oneOf"]:
118
+ for i, item in enumerate(schema.get(section, [])):
119
+ schema[section][i] = _replace_ref_with_defs(item)
120
+ if info.get("description", description) and not schema.get("description"):
121
+ schema["description"] = description
122
+ return schema
123
+
124
+
125
+ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
126
+ """
127
+ Make an optional parameter schema nullable to allow None values.
128
+
129
+ For optional parameters, we need to allow null values in addition to the
130
+ specified type to handle cases where None is passed for optional parameters.
131
+ """
132
+ # If schema already has multiple types or is already nullable, don't modify
133
+ if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
134
+ return schema
135
+
136
+ # If it's already nullable (type includes null), don't modify
137
+ if isinstance(schema.get("type"), list) and "null" in schema["type"]:
138
+ return schema
139
+
140
+ # Create a new schema that allows null in addition to the original type
141
+ if "type" in schema:
142
+ original_type = schema["type"]
143
+ if isinstance(original_type, str):
144
+ # Handle different types appropriately
145
+ if original_type in ("array", "object"):
146
+ # For complex types (array/object), preserve the full structure
147
+ # and allow null as an alternative
148
+ if original_type == "array" and "items" in schema:
149
+ # Array with items - preserve items in anyOf branch
150
+ array_schema = schema.copy()
151
+ top_level_fields = ["default", "description", "title", "example"]
152
+ nullable_schema = {}
153
+
154
+ # Move top-level fields to the root
155
+ for field in top_level_fields:
156
+ if field in array_schema:
157
+ nullable_schema[field] = array_schema.pop(field)
158
+
159
+ nullable_schema["anyOf"] = [array_schema, {"type": "null"}]
160
+ return nullable_schema
161
+
162
+ elif original_type == "object" and "properties" in schema:
163
+ # Object with properties - preserve properties in anyOf branch
164
+ object_schema = schema.copy()
165
+ top_level_fields = ["default", "description", "title", "example"]
166
+ nullable_schema = {}
167
+
168
+ # Move top-level fields to the root
169
+ for field in top_level_fields:
170
+ if field in object_schema:
171
+ nullable_schema[field] = object_schema.pop(field)
172
+
173
+ nullable_schema["anyOf"] = [object_schema, {"type": "null"}]
174
+ return nullable_schema
175
+ else:
176
+ # Simple object/array without items/properties
177
+ nullable_schema = {}
178
+ original_schema = schema.copy()
179
+ top_level_fields = ["default", "description", "title", "example"]
180
+
181
+ for field in top_level_fields:
182
+ if field in original_schema:
183
+ nullable_schema[field] = original_schema.pop(field)
184
+
185
+ nullable_schema["anyOf"] = [original_schema, {"type": "null"}]
186
+ return nullable_schema
187
+ else:
188
+ # Simple types (string, integer, number, boolean)
189
+ top_level_fields = ["default", "description", "title", "example"]
190
+ nullable_schema = {}
191
+ original_schema = schema.copy()
192
+
193
+ for field in top_level_fields:
194
+ if field in original_schema:
195
+ nullable_schema[field] = original_schema.pop(field)
196
+
197
+ nullable_schema["anyOf"] = [original_schema, {"type": "null"}]
198
+ return nullable_schema
199
+
200
+ return schema
201
+
202
+
203
+ def _combine_schemas_and_map_params(
204
+ route: HTTPRoute,
205
+ ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
206
+ """
207
+ Combines parameter and request body schemas into a single schema.
208
+ Handles parameter name collisions by adding location suffixes.
209
+ Also returns parameter mapping for request director.
210
+
211
+ Args:
212
+ route: HTTPRoute object
213
+
214
+ Returns:
215
+ Tuple of (combined schema dictionary, parameter mapping)
216
+ Parameter mapping format: {'flat_arg_name': {'location': 'path', 'openapi_name': 'id'}}
217
+ """
218
+ properties = {}
219
+ required = []
220
+ parameter_map = {} # Track mapping from flat arg names to OpenAPI locations
221
+
222
+ # First pass: collect parameter names by location and body properties
223
+ param_names_by_location = {
224
+ "path": set(),
225
+ "query": set(),
226
+ "header": set(),
227
+ "cookie": set(),
228
+ }
229
+ body_props = {}
230
+
231
+ for param in route.parameters:
232
+ param_names_by_location[param.location].add(param.name)
233
+
234
+ if route.request_body and route.request_body.content_schema:
235
+ content_type = next(iter(route.request_body.content_schema))
236
+ body_schema = _replace_ref_with_defs(
237
+ route.request_body.content_schema[content_type].copy(),
238
+ route.request_body.description,
239
+ )
240
+ body_props = body_schema.get("properties", {})
241
+
242
+ # Detect collisions: parameters that exist in both body and path/query/header
243
+ all_non_body_params = set()
244
+ for location_params in param_names_by_location.values():
245
+ all_non_body_params.update(location_params)
246
+
247
+ body_param_names = set(body_props.keys())
248
+ colliding_params = all_non_body_params & body_param_names
249
+
250
+ # Add parameters with suffixes for collisions
251
+ for param in route.parameters:
252
+ if param.name in colliding_params:
253
+ # Add suffix for non-body parameters when collision detected
254
+ suffixed_name = f"{param.name}__{param.location}"
255
+ if param.required:
256
+ required.append(suffixed_name)
257
+
258
+ # Track parameter mapping
259
+ parameter_map[suffixed_name] = {
260
+ "location": param.location,
261
+ "openapi_name": param.name,
262
+ }
263
+
264
+ # Add location info to description
265
+ param_schema = _replace_ref_with_defs(
266
+ param.schema_.copy(), param.description
267
+ )
268
+ original_desc = param_schema.get("description", "")
269
+ location_desc = f"({param.location.capitalize()} parameter)"
270
+ if original_desc:
271
+ param_schema["description"] = f"{original_desc} {location_desc}"
272
+ else:
273
+ param_schema["description"] = location_desc
274
+
275
+ # Don't make optional parameters nullable - they can simply be omitted
276
+ # The OpenAPI specification doesn't require optional parameters to accept null values
277
+
278
+ properties[suffixed_name] = param_schema
279
+ else:
280
+ # No collision, use original name
281
+ if param.required:
282
+ required.append(param.name)
283
+
284
+ # Track parameter mapping
285
+ parameter_map[param.name] = {
286
+ "location": param.location,
287
+ "openapi_name": param.name,
288
+ }
289
+
290
+ param_schema = _replace_ref_with_defs(
291
+ param.schema_.copy(), param.description
292
+ )
293
+
294
+ # Don't make optional parameters nullable - they can simply be omitted
295
+ # The OpenAPI specification doesn't require optional parameters to accept null values
296
+
297
+ properties[param.name] = param_schema
298
+
299
+ # Add request body properties (no suffixes for body parameters)
300
+ if route.request_body and route.request_body.content_schema:
301
+ for prop_name, prop_schema in body_props.items():
302
+ properties[prop_name] = prop_schema
303
+
304
+ # Track parameter mapping for body properties
305
+ parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name}
306
+
307
+ if route.request_body.required:
308
+ required.extend(body_schema.get("required", []))
309
+
310
+ result = {
311
+ "type": "object",
312
+ "properties": properties,
313
+ "required": required,
314
+ }
315
+ # Add schema definitions if available
316
+ if route.schema_definitions:
317
+ result["$defs"] = route.schema_definitions.copy()
318
+
319
+ # Use lightweight compression - prune additionalProperties and unused definitions
320
+ if result.get("additionalProperties") is False:
321
+ result.pop("additionalProperties")
322
+
323
+ # Remove unused definitions (lightweight approach - just check direct $ref usage)
324
+ if "$defs" in result:
325
+ used_refs = set()
326
+
327
+ def find_refs_in_value(value):
328
+ if isinstance(value, dict):
329
+ if "$ref" in value and isinstance(value["$ref"], str):
330
+ ref = value["$ref"]
331
+ if ref.startswith("#/$defs/"):
332
+ used_refs.add(ref.split("/")[-1])
333
+ for v in value.values():
334
+ find_refs_in_value(v)
335
+ elif isinstance(value, list):
336
+ for item in value:
337
+ find_refs_in_value(item)
338
+
339
+ # Find refs in the main schema (excluding $defs section)
340
+ for key, value in result.items():
341
+ if key != "$defs":
342
+ find_refs_in_value(value)
343
+
344
+ # Remove unused definitions
345
+ if used_refs:
346
+ result["$defs"] = {
347
+ name: def_schema
348
+ for name, def_schema in result["$defs"].items()
349
+ if name in used_refs
350
+ }
351
+ else:
352
+ result.pop("$defs")
353
+
354
+ return result, parameter_map
355
+
356
+
357
+ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
358
+ """
359
+ Combines parameter and request body schemas into a single schema.
360
+ Handles parameter name collisions by adding location suffixes.
361
+
362
+ This is a backward compatibility wrapper around _combine_schemas_and_map_params.
363
+
364
+ Args:
365
+ route: HTTPRoute object
366
+
367
+ Returns:
368
+ Combined schema dictionary
369
+ """
370
+ schema, _ = _combine_schemas_and_map_params(route)
371
+ return schema
372
+
373
+
374
+ def extract_output_schema_from_responses(
375
+ responses: dict[str, ResponseInfo],
376
+ schema_definitions: dict[str, Any] | None = None,
377
+ openapi_version: str | None = None,
378
+ ) -> dict[str, Any] | None:
379
+ """
380
+ Extract output schema from OpenAPI responses for use as MCP tool output schema.
381
+
382
+ This function finds the first successful response (200, 201, 202, 204) with a
383
+ JSON-compatible content type and extracts its schema. If the schema is not an
384
+ object type, it wraps it to comply with MCP requirements.
385
+
386
+ Args:
387
+ responses: Dictionary of ResponseInfo objects keyed by status code
388
+ schema_definitions: Optional schema definitions to include in the output schema
389
+ openapi_version: OpenAPI version string, used to optimize nullable field handling
390
+
391
+ Returns:
392
+ dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
393
+ """
394
+ if not responses:
395
+ return None
396
+
397
+ # Priority order for success status codes
398
+ success_codes = ["200", "201", "202", "204"]
399
+
400
+ # Find the first successful response
401
+ response_info = None
402
+ for status_code in success_codes:
403
+ if status_code in responses:
404
+ response_info = responses[status_code]
405
+ break
406
+
407
+ # If no explicit success codes, try any 2xx response
408
+ if response_info is None:
409
+ for status_code, resp_info in responses.items():
410
+ if status_code.startswith("2"):
411
+ response_info = resp_info
412
+ break
413
+
414
+ if response_info is None or not response_info.content_schema:
415
+ return None
416
+
417
+ # Prefer application/json, then fall back to other JSON-compatible types
418
+ json_compatible_types = [
419
+ "application/json",
420
+ "application/vnd.api+json",
421
+ "application/hal+json",
422
+ "application/ld+json",
423
+ "text/json",
424
+ ]
425
+
426
+ schema = None
427
+ for content_type in json_compatible_types:
428
+ if content_type in response_info.content_schema:
429
+ schema = response_info.content_schema[content_type]
430
+ break
431
+
432
+ # If no JSON-compatible type found, try the first available content type
433
+ if schema is None and response_info.content_schema:
434
+ first_content_type = next(iter(response_info.content_schema))
435
+ schema = response_info.content_schema[first_content_type]
436
+ logger.debug(
437
+ f"Using non-JSON content type for output schema: {first_content_type}"
438
+ )
439
+
440
+ if not schema or not isinstance(schema, dict):
441
+ return None
442
+
443
+ # Clean and copy the schema
444
+ output_schema = schema.copy()
445
+
446
+ # If schema has a $ref, resolve it first before processing nullable fields
447
+ if "$ref" in output_schema and schema_definitions:
448
+ ref_path = output_schema["$ref"]
449
+ if ref_path.startswith("#/components/schemas/"):
450
+ schema_name = ref_path.split("/")[-1]
451
+ if schema_name in schema_definitions:
452
+ # Replace $ref with the actual schema definition
453
+ output_schema = schema_definitions[schema_name].copy()
454
+
455
+ # Convert OpenAPI schema to JSON Schema format
456
+ # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
457
+ if openapi_version and openapi_version.startswith("3.0"):
458
+ from .json_schema_converter import convert_openapi_schema_to_json_schema
459
+
460
+ output_schema = convert_openapi_schema_to_json_schema(
461
+ output_schema, openapi_version
462
+ )
463
+
464
+ # MCP requires output schemas to be objects. If this schema is not an object,
465
+ # we need to wrap it similar to how ParsedFunction.from_function() does it
466
+ if output_schema.get("type") != "object":
467
+ # Create a wrapped schema that contains the original schema under a "result" key
468
+ wrapped_schema = {
469
+ "type": "object",
470
+ "properties": {"result": output_schema},
471
+ "required": ["result"],
472
+ "x-fastmcp-wrap-result": True,
473
+ }
474
+ output_schema = wrapped_schema
475
+
476
+ # Add schema definitions if available and handle nullable fields in them
477
+ # Only add $defs if we didn't resolve the $ref inline above
478
+ if schema_definitions and "$ref" not in schema.copy():
479
+ processed_defs = {}
480
+ for def_name, def_schema in schema_definitions.items():
481
+ # Convert OpenAPI schema definitions to JSON Schema format
482
+ if openapi_version and openapi_version.startswith("3.0"):
483
+ from .json_schema_converter import convert_openapi_schema_to_json_schema
484
+
485
+ processed_defs[def_name] = convert_openapi_schema_to_json_schema(
486
+ def_schema, openapi_version
487
+ )
488
+ else:
489
+ processed_defs[def_name] = def_schema
490
+ output_schema["$defs"] = processed_defs
491
+
492
+ # Use lightweight compression - prune additionalProperties and unused definitions
493
+ if output_schema.get("additionalProperties") is False:
494
+ output_schema.pop("additionalProperties")
495
+
496
+ # Remove unused definitions (lightweight approach - just check direct $ref usage)
497
+ if "$defs" in output_schema:
498
+ used_refs = set()
499
+
500
+ def find_refs_in_value(value):
501
+ if isinstance(value, dict):
502
+ if "$ref" in value and isinstance(value["$ref"], str):
503
+ ref = value["$ref"]
504
+ if ref.startswith("#/$defs/"):
505
+ used_refs.add(ref.split("/")[-1])
506
+ for v in value.values():
507
+ find_refs_in_value(v)
508
+ elif isinstance(value, list):
509
+ for item in value:
510
+ find_refs_in_value(item)
511
+
512
+ # Find refs in the main schema (excluding $defs section)
513
+ for key, value in output_schema.items():
514
+ if key != "$defs":
515
+ find_refs_in_value(value)
516
+
517
+ # Remove unused definitions
518
+ if used_refs:
519
+ output_schema["$defs"] = {
520
+ name: def_schema
521
+ for name, def_schema in output_schema["$defs"].items()
522
+ if name in used_refs
523
+ }
524
+ else:
525
+ output_schema.pop("$defs")
526
+
527
+ return output_schema
528
+
529
+
530
+ # Export public symbols
531
+ __all__ = [
532
+ "clean_schema_for_display",
533
+ "_combine_schemas",
534
+ "_combine_schemas_and_map_params",
535
+ "extract_output_schema_from_responses",
536
+ "_replace_ref_with_defs",
537
+ "_make_optional_parameter_nullable",
538
+ ]