fastmcp 2.3.5__py3-none-any.whl → 2.5.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.
@@ -1,10 +1,8 @@
1
1
  import json
2
2
  import logging
3
- from typing import Any, Literal, cast
3
+ from typing import Any, Generic, Literal, TypeVar
4
4
 
5
- # Using the recommended library: openapi-pydantic
6
5
  from openapi_pydantic import (
7
- MediaType,
8
6
  OpenAPI,
9
7
  Operation,
10
8
  Parameter,
@@ -26,7 +24,7 @@ from openapi_pydantic.v3.v3_0 import Response as Response_30
26
24
  from openapi_pydantic.v3.v3_0 import Schema as Schema_30
27
25
  from pydantic import BaseModel, Field, ValidationError
28
26
 
29
- from fastmcp.utilities import openapi
27
+ from fastmcp.utilities.json_schema import compress_schema
30
28
 
31
29
  logger = logging.getLogger(__name__)
32
30
 
@@ -49,8 +47,6 @@ class ParameterInfo(BaseModel):
49
47
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
50
48
  description: str | None = None
51
49
 
52
- # No model_config needed here if we populate manually after accessing 'in'
53
-
54
50
 
55
51
  class RequestBodyInfo(BaseModel):
56
52
  """Represents the request body for an HTTP operation in our IR."""
@@ -101,60 +97,17 @@ __all__ = [
101
97
  "parse_openapi_to_http_routes",
102
98
  ]
103
99
 
104
- # --- Helper Functions ---
100
+ # Type variables for generic parser
101
+ TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30)
102
+ TSchema = TypeVar("TSchema", Schema, Schema_30)
103
+ TReference = TypeVar("TReference", Reference, Reference_30)
104
+ TParameter = TypeVar("TParameter", Parameter, Parameter_30)
105
+ TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30)
106
+ TResponse = TypeVar("TResponse", Response, Response_30)
107
+ TOperation = TypeVar("TOperation", Operation, Operation_30)
108
+ TPathItem = TypeVar("TPathItem", PathItem, PathItem_30)
105
109
 
106
110
 
107
- def _resolve_ref(
108
- item: Reference | Schema | Parameter | RequestBody | Any, openapi: OpenAPI
109
- ) -> Any:
110
- """Resolves a potential Reference object to its target definition (no changes needed here)."""
111
- if isinstance(item, Reference):
112
- ref_str = item.ref
113
- try:
114
- if not ref_str.startswith("#/"):
115
- raise ValueError(
116
- f"External or non-local reference not supported: {ref_str}"
117
- )
118
- parts = ref_str.strip("#/").split("/")
119
- target = openapi
120
- for part in parts:
121
- if part.isdigit() and isinstance(target, list):
122
- target = target[int(part)]
123
- elif isinstance(target, BaseModel):
124
- # Use model_extra for fields not explicitly defined (like components types)
125
- # Check class fields first, then model_extra
126
- if part in target.__class__.model_fields:
127
- target = getattr(target, part, None)
128
- elif target.model_extra and part in target.model_extra:
129
- target = target.model_extra[part]
130
- else:
131
- # Special handling for components sub-types common structure
132
- if part == "components" and hasattr(target, "components"):
133
- target = getattr(target, "components")
134
- elif hasattr(target, part): # Fallback check
135
- target = getattr(target, part, None)
136
- else:
137
- target = None # Part not found
138
- elif isinstance(target, dict):
139
- target = target.get(part)
140
- else:
141
- raise ValueError(
142
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
143
- )
144
- if target is None:
145
- raise ValueError(
146
- f"Reference part '{part}' not found in path '{ref_str}'"
147
- )
148
- if isinstance(target, Reference):
149
- return _resolve_ref(target, openapi)
150
- return target
151
- except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
152
- raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
153
- return item
154
-
155
-
156
- # --- Main Parsing Function ---
157
- # (No changes needed in the main loop logic, only in the helpers it calls)
158
111
  def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
159
112
  """
160
113
  Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
@@ -172,7 +125,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
172
125
  logger.info(
173
126
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
174
127
  )
175
- parser = OpenAPI30Parser(openapi_30)
128
+ parser = OpenAPIParser(
129
+ openapi_30,
130
+ Reference_30,
131
+ Schema_30,
132
+ Parameter_30,
133
+ RequestBody_30,
134
+ Response_30,
135
+ Operation_30,
136
+ PathItem_30,
137
+ )
176
138
  return parser.parse()
177
139
  else:
178
140
  # Default to OpenAPI 3.1 models
@@ -180,7 +142,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
180
142
  logger.info(
181
143
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
182
144
  )
183
- parser = OpenAPI31Parser(openapi_31)
145
+ parser = OpenAPIParser(
146
+ openapi_31,
147
+ Reference,
148
+ Schema,
149
+ Parameter,
150
+ RequestBody,
151
+ Response,
152
+ Operation,
153
+ PathItem,
154
+ )
184
155
  return parser.parse()
185
156
  except ValidationError as e:
186
157
  logger.error(f"OpenAPI schema validation failed: {e}")
@@ -189,151 +160,72 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
189
160
  raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
190
161
 
191
162
 
192
- # Base parser class for shared functionality
193
- class BaseOpenAPIParser:
194
- """Base class for OpenAPI parsers with common functionality."""
195
-
196
- def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
197
- """Convert string parameter location to our ParameterLocation type."""
198
- if param_in == "path":
199
- return "path"
200
- elif param_in == "query":
201
- return "query"
202
- elif param_in == "header":
203
- return "header"
204
- elif param_in == "cookie":
205
- return "cookie"
206
- else:
207
- logger.warning(
208
- f"Unknown parameter location: {param_in}, defaulting to 'query'"
209
- )
210
- return "query"
211
-
212
-
213
- class OpenAPI31Parser(BaseOpenAPIParser):
214
- """Parser for OpenAPI 3.1 schemas."""
163
+ class OpenAPIParser(
164
+ Generic[
165
+ TOpenAPI,
166
+ TReference,
167
+ TSchema,
168
+ TParameter,
169
+ TRequestBody,
170
+ TResponse,
171
+ TOperation,
172
+ TPathItem,
173
+ ]
174
+ ):
175
+ """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1."""
215
176
 
216
- def __init__(self, openapi: OpenAPI):
177
+ def __init__(
178
+ self,
179
+ openapi: TOpenAPI,
180
+ reference_cls: type[TReference],
181
+ schema_cls: type[TSchema],
182
+ parameter_cls: type[TParameter],
183
+ request_body_cls: type[TRequestBody],
184
+ response_cls: type[TResponse],
185
+ operation_cls: type[TOperation],
186
+ path_item_cls: type[TPathItem],
187
+ ):
188
+ """Initialize the parser with the OpenAPI schema and type classes."""
217
189
  self.openapi = openapi
190
+ self.reference_cls = reference_cls
191
+ self.schema_cls = schema_cls
192
+ self.parameter_cls = parameter_cls
193
+ self.request_body_cls = request_body_cls
194
+ self.response_cls = response_cls
195
+ self.operation_cls = operation_cls
196
+ self.path_item_cls = path_item_cls
218
197
 
219
- def parse(self) -> list[HTTPRoute]:
220
- """Parse an OpenAPI 3.1 schema into HTTP routes."""
221
- routes: list[HTTPRoute] = []
222
-
223
- if not self.openapi.paths:
224
- logger.warning("OpenAPI schema has no paths defined.")
225
- return []
226
-
227
- # Extract component schemas to add to each route
228
- schema_definitions = {}
229
- if hasattr(self.openapi, "components") and self.openapi.components:
230
- components = self.openapi.components
231
- if hasattr(components, "schemas") and components.schemas:
232
- for name, schema in components.schemas.items():
233
- try:
234
- if isinstance(schema, Reference):
235
- resolved_schema = self._resolve_ref(schema)
236
- schema_definitions[name] = self._extract_schema_as_dict(
237
- resolved_schema
238
- )
239
- else:
240
- schema_definitions[name] = self._extract_schema_as_dict(
241
- schema
242
- )
243
- except Exception as e:
244
- logger.warning(
245
- f"Failed to extract schema definition '{name}': {e}"
246
- )
247
-
248
- for path_str, path_item_obj in self.openapi.paths.items():
249
- if not isinstance(path_item_obj, PathItem):
250
- logger.warning(
251
- f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
252
- )
253
- continue
254
-
255
- path_level_params = path_item_obj.parameters
256
-
257
- # Iterate through possible HTTP methods defined in the PathItem model fields
258
- # Use model_fields from the class, not the instance
259
- for method_lower in PathItem.model_fields.keys():
260
- if method_lower not in [
261
- "get",
262
- "put",
263
- "post",
264
- "delete",
265
- "options",
266
- "head",
267
- "patch",
268
- "trace",
269
- ]:
270
- continue
271
-
272
- operation: Operation | None = getattr(path_item_obj, method_lower, None)
273
-
274
- if operation and isinstance(operation, Operation):
275
- method_upper = cast(HttpMethod, method_lower.upper())
276
- logger.debug(f"Processing operation: {method_upper} {path_str}")
277
- try:
278
- parameters = self._extract_parameters(
279
- operation.parameters, path_level_params
280
- )
281
- request_body_info = self._extract_request_body(
282
- operation.requestBody
283
- )
284
- responses = self._extract_responses(operation.responses)
285
-
286
- route = HTTPRoute(
287
- path=path_str,
288
- method=method_upper,
289
- operation_id=operation.operationId,
290
- summary=operation.summary,
291
- description=operation.description,
292
- tags=operation.tags or [],
293
- parameters=parameters,
294
- request_body=request_body_info,
295
- responses=responses,
296
- schema_definitions=schema_definitions,
297
- )
298
- routes.append(route)
299
- logger.info(
300
- f"Successfully extracted route: {method_upper} {path_str}"
301
- )
302
- except Exception as op_error:
303
- op_id = operation.operationId or "unknown"
304
- logger.error(
305
- f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
306
- exc_info=True,
307
- )
308
-
309
- logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
310
- return routes
311
-
312
- def _resolve_ref(
313
- self, item: Reference | Schema | Parameter | RequestBody | Any
314
- ) -> Any:
315
- """Resolves a potential Reference object to its target definition."""
316
- if isinstance(item, Reference):
198
+ def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
199
+ """Convert string parameter location to our ParameterLocation type."""
200
+ if param_in in ["path", "query", "header", "cookie"]:
201
+ return param_in # type: ignore[return-value] # Safe cast since we checked values
202
+ logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
203
+ return "query" # type: ignore[return-value] # Safe cast to default value
204
+
205
+ def _resolve_ref(self, item: Any) -> Any:
206
+ """Resolves a reference to its target definition."""
207
+ if isinstance(item, self.reference_cls):
317
208
  ref_str = item.ref
318
209
  try:
319
210
  if not ref_str.startswith("#/"):
320
211
  raise ValueError(
321
212
  f"External or non-local reference not supported: {ref_str}"
322
213
  )
214
+
323
215
  parts = ref_str.strip("#/").split("/")
324
216
  target = self.openapi
217
+
325
218
  for part in parts:
326
219
  if part.isdigit() and isinstance(target, list):
327
220
  target = target[int(part)]
328
221
  elif isinstance(target, BaseModel):
329
- # Use model_extra for fields not explicitly defined (like components types)
330
222
  # Check class fields first, then model_extra
331
223
  if part in target.__class__.model_fields:
332
224
  target = getattr(target, part, None)
333
225
  elif target.model_extra and part in target.model_extra:
334
226
  target = target.model_extra[part]
335
227
  else:
336
- # Special handling for components sub-types common structure
228
+ # Special handling for components
337
229
  if part == "components" and hasattr(target, "components"):
338
230
  target = getattr(target, "components")
339
231
  elif hasattr(target, part): # Fallback check
@@ -344,123 +236,123 @@ class OpenAPI31Parser(BaseOpenAPIParser):
344
236
  target = target.get(part)
345
237
  else:
346
238
  raise ValueError(
347
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
239
+ f"Cannot traverse part '{part}' in reference '{ref_str}'"
348
240
  )
241
+
349
242
  if target is None:
350
243
  raise ValueError(
351
244
  f"Reference part '{part}' not found in path '{ref_str}'"
352
245
  )
353
- if isinstance(target, Reference):
246
+
247
+ # Handle nested references
248
+ if isinstance(target, self.reference_cls):
354
249
  return self._resolve_ref(target)
250
+
355
251
  return target
356
252
  except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
357
253
  raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
254
+
358
255
  return item
359
256
 
360
- def _extract_schema_as_dict(self, schema_obj: Schema | Reference) -> JsonSchema:
361
- """Resolves a schema/reference and returns it as a dictionary."""
362
- resolved_schema = self._resolve_ref(schema_obj)
363
- if isinstance(resolved_schema, Schema):
364
- # Using exclude_none=True might be better than exclude_unset sometimes
365
- return resolved_schema.model_dump(
366
- mode="json", by_alias=True, exclude_none=True
367
- )
368
- elif isinstance(resolved_schema, dict):
369
- logger.warning(
370
- "Resolved schema reference resulted in a dict, not a Schema model."
371
- )
372
- return resolved_schema
373
- else:
374
- ref_str = getattr(schema_obj, "ref", "unknown")
375
- logger.warning(
376
- f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
377
- )
257
+ def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema:
258
+ """Resolves a schema and returns it as a dictionary."""
259
+ try:
260
+ resolved_schema = self._resolve_ref(schema_obj)
261
+
262
+ if isinstance(resolved_schema, (self.schema_cls)):
263
+ # Convert schema to dictionary
264
+ return resolved_schema.model_dump(
265
+ mode="json", by_alias=True, exclude_none=True
266
+ )
267
+ elif isinstance(resolved_schema, dict):
268
+ return resolved_schema
269
+ else:
270
+ logger.warning(
271
+ f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
272
+ )
273
+ return {}
274
+ except Exception as e:
275
+ logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
378
276
  return {}
379
277
 
380
278
  def _extract_parameters(
381
279
  self,
382
- operation_params: list[Parameter | Reference] | None,
383
- path_item_params: list[Parameter | Reference] | None,
280
+ operation_params: list[Any] | None = None,
281
+ path_item_params: list[Any] | None = None,
384
282
  ) -> list[ParameterInfo]:
385
- """Extracts and resolves parameters using corrected attribute names."""
283
+ """Extract and resolve parameters from operation and path item."""
386
284
  extracted_params: list[ParameterInfo] = []
387
285
  seen_params: dict[
388
286
  tuple[str, str], bool
389
- ] = {} # Use string keys to avoid type issues
390
- all_params_refs = (operation_params or []) + (path_item_params or [])
287
+ ] = {} # Use tuple of (name, location) as key
288
+ all_params = (operation_params or []) + (path_item_params or [])
391
289
 
392
- for param_or_ref in all_params_refs:
290
+ for param_or_ref in all_params:
393
291
  try:
394
- parameter = cast(Parameter, self._resolve_ref(param_or_ref))
395
- if not isinstance(parameter, Parameter):
396
- # ... (error logging remains the same)
292
+ parameter = self._resolve_ref(param_or_ref)
293
+
294
+ if not isinstance(parameter, self.parameter_cls):
295
+ logger.warning(
296
+ f"Expected Parameter after resolving, got {type(parameter)}. Skipping."
297
+ )
397
298
  continue
398
299
 
399
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
400
- param_in = parameter.param_in # CORRECTED: Use 'param_in'
300
+ # Extract parameter info - handle both 3.0 and 3.1 parameter models
301
+ param_in = parameter.param_in # Both use param_in
401
302
  param_location = self._convert_to_parameter_location(param_in)
402
- param_schema_obj = (
403
- parameter.param_schema
404
- ) # CORRECTED: Use 'param_schema'
405
- # --- *** ---
303
+ param_schema_obj = parameter.param_schema # Both use param_schema
406
304
 
305
+ # Skip duplicate parameters (same name and location)
407
306
  param_key = (parameter.name, param_in)
408
307
  if param_key in seen_params:
409
308
  continue
410
309
  seen_params[param_key] = True
411
310
 
311
+ # Extract schema
412
312
  param_schema_dict = {}
413
- if param_schema_obj: # Check if schema exists
414
- # Resolve the schema if it's a reference
415
- resolved_schema = self._resolve_ref(param_schema_obj)
313
+ if param_schema_obj:
314
+ # Process schema object
416
315
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
417
316
 
418
- # Ensure default value is preserved from resolved schema
317
+ # Handle default value
318
+ resolved_schema = self._resolve_ref(param_schema_obj)
419
319
  if (
420
- not isinstance(resolved_schema, Reference)
320
+ not isinstance(resolved_schema, self.reference_cls)
421
321
  and hasattr(resolved_schema, "default")
422
322
  and resolved_schema.default is not None
423
323
  ):
424
324
  param_schema_dict["default"] = resolved_schema.default
425
- elif parameter.content:
426
- # Handle complex parameters with 'content'
325
+
326
+ elif hasattr(parameter, "content") and parameter.content:
327
+ # Handle content-based parameters
427
328
  first_media_type = next(iter(parameter.content.values()), None)
428
329
  if (
429
- first_media_type and first_media_type.media_type_schema
430
- ): # CORRECTED: Use 'media_type_schema'
431
- # Resolve the schema if it's a reference
330
+ first_media_type
331
+ and hasattr(first_media_type, "media_type_schema")
332
+ and first_media_type.media_type_schema
333
+ ):
432
334
  media_schema = first_media_type.media_type_schema
433
- resolved_media_schema = self._resolve_ref(media_schema)
434
335
  param_schema_dict = self._extract_schema_as_dict(media_schema)
435
336
 
436
- # Ensure default value is preserved from resolved schema
337
+ # Handle default value in content schema
338
+ resolved_media_schema = self._resolve_ref(media_schema)
437
339
  if (
438
- not isinstance(resolved_media_schema, Reference)
340
+ not isinstance(resolved_media_schema, self.reference_cls)
439
341
  and hasattr(resolved_media_schema, "default")
440
342
  and resolved_media_schema.default is not None
441
343
  ):
442
344
  param_schema_dict["default"] = resolved_media_schema.default
443
345
 
444
- logger.debug(
445
- f"Parameter '{parameter.name}' using schema from 'content' field."
446
- )
447
-
448
- # Manually create ParameterInfo instance using correct field names
346
+ # Create parameter info object
449
347
  param_info = ParameterInfo(
450
348
  name=parameter.name,
451
- location=param_location, # Use converted parameter location
349
+ location=param_location,
452
350
  required=parameter.required,
453
- schema=param_schema_dict, # Populate 'schema' field in IR
351
+ schema=param_schema_dict,
454
352
  description=parameter.description,
455
353
  )
456
354
  extracted_params.append(param_info)
457
-
458
- except (
459
- ValidationError,
460
- ValueError,
461
- AttributeError,
462
- TypeError,
463
- ) as e: # Added TypeError
355
+ except Exception as e:
464
356
  param_name = getattr(
465
357
  param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
466
358
  )
@@ -470,52 +362,48 @@ class OpenAPI31Parser(BaseOpenAPIParser):
470
362
 
471
363
  return extracted_params
472
364
 
473
- def _extract_request_body(
474
- self, request_body_or_ref: RequestBody | Reference | None
475
- ) -> RequestBodyInfo | None:
476
- """Extracts and resolves the request body using corrected attribute names."""
365
+ def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None:
366
+ """Extract and resolve request body information."""
477
367
  if not request_body_or_ref:
478
368
  return None
369
+
479
370
  try:
480
- request_body = cast(RequestBody, self._resolve_ref(request_body_or_ref))
481
- if not isinstance(request_body, RequestBody):
482
- # ... (error logging remains the same)
371
+ request_body = self._resolve_ref(request_body_or_ref)
372
+
373
+ if not isinstance(request_body, self.request_body_cls):
374
+ logger.warning(
375
+ f"Expected RequestBody after resolving, got {type(request_body)}. Returning None."
376
+ )
483
377
  return None
484
378
 
485
- content_schemas: dict[str, JsonSchema] = {}
486
- if request_body.content:
379
+ # Create request body info
380
+ request_body_info = RequestBodyInfo(
381
+ required=request_body.required,
382
+ description=request_body.description,
383
+ )
384
+
385
+ # Extract content schemas
386
+ if hasattr(request_body, "content") and request_body.content:
487
387
  for media_type_str, media_type_obj in request_body.content.items():
488
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
489
388
  if (
490
- isinstance(media_type_obj, MediaType)
389
+ media_type_obj
390
+ and hasattr(media_type_obj, "media_type_schema")
491
391
  and media_type_obj.media_type_schema
492
- ): # CORRECTED: Use 'media_type_schema'
493
- # --- *** ---
392
+ ):
494
393
  try:
495
- # Use the corrected attribute here as well
496
394
  schema_dict = self._extract_schema_as_dict(
497
395
  media_type_obj.media_type_schema
498
396
  )
499
- content_schemas[media_type_str] = schema_dict
500
- except ValueError as schema_err:
397
+ request_body_info.content_schema[media_type_str] = (
398
+ schema_dict
399
+ )
400
+ except Exception as e:
501
401
  logger.error(
502
- f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}"
402
+ f"Failed to extract schema for media type '{media_type_str}': {e}"
503
403
  )
504
- elif not isinstance(media_type_obj, MediaType):
505
- logger.warning(
506
- f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
507
- )
508
- elif not media_type_obj.media_type_schema: # Corrected check
509
- logger.warning(
510
- f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
511
- )
512
404
 
513
- return RequestBodyInfo(
514
- required=request_body.required,
515
- content_schema=content_schemas,
516
- description=request_body.description,
517
- )
518
- except (ValidationError, ValueError, AttributeError) as e:
405
+ return request_body_info
406
+ except Exception as e:
519
407
  ref_name = getattr(request_body_or_ref, "ref", "unknown")
520
408
  logger.error(
521
409
  f"Failed to extract request body '{ref_name}': {e}", exc_info=False
@@ -523,47 +411,49 @@ class OpenAPI31Parser(BaseOpenAPIParser):
523
411
  return None
524
412
 
525
413
  def _extract_responses(
526
- self,
527
- operation_responses: dict[str, Response | Reference] | None,
414
+ self, operation_responses: dict[str, Any] | None
528
415
  ) -> dict[str, ResponseInfo]:
529
- """Extracts and resolves response information for an operation."""
416
+ """Extract and resolve response information."""
530
417
  extracted_responses: dict[str, ResponseInfo] = {}
418
+
531
419
  if not operation_responses:
532
420
  return extracted_responses
533
421
 
534
422
  for status_code, resp_or_ref in operation_responses.items():
535
423
  try:
536
- response = cast(Response, self._resolve_ref(resp_or_ref))
537
- if not isinstance(response, Response):
538
- ref_str = getattr(resp_or_ref, "ref", "unknown")
424
+ response = self._resolve_ref(resp_or_ref)
425
+
426
+ if not isinstance(response, self.response_cls):
539
427
  logger.warning(
540
- f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping."
428
+ f"Expected Response after resolving for status code {status_code}, "
429
+ f"got {type(response)}. Skipping."
541
430
  )
542
431
  continue
543
432
 
544
- content_schemas: dict[str, JsonSchema] = {}
545
- if response.content:
433
+ # Create response info
434
+ resp_info = ResponseInfo(description=response.description)
435
+
436
+ # Extract content schemas
437
+ if hasattr(response, "content") and response.content:
546
438
  for media_type_str, media_type_obj in response.content.items():
547
439
  if (
548
- isinstance(media_type_obj, MediaType)
440
+ media_type_obj
441
+ and hasattr(media_type_obj, "media_type_schema")
549
442
  and media_type_obj.media_type_schema
550
443
  ):
551
444
  try:
552
445
  schema_dict = self._extract_schema_as_dict(
553
446
  media_type_obj.media_type_schema
554
447
  )
555
- content_schemas[media_type_str] = schema_dict
556
- except ValueError as schema_err:
448
+ resp_info.content_schema[media_type_str] = schema_dict
449
+ except Exception as e:
557
450
  logger.error(
558
- f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}"
451
+ f"Failed to extract schema for media type '{media_type_str}' "
452
+ f"in response {status_code}: {e}"
559
453
  )
560
454
 
561
- resp_info = ResponseInfo(
562
- description=response.description, content_schema=content_schemas
563
- )
564
455
  extracted_responses[str(status_code)] = resp_info
565
-
566
- except (ValidationError, ValueError, AttributeError) as e:
456
+ except Exception as e:
567
457
  ref_name = getattr(resp_or_ref, "ref", "unknown")
568
458
  logger.error(
569
459
  f"Failed to extract response for status code {status_code} "
@@ -573,29 +463,22 @@ class OpenAPI31Parser(BaseOpenAPIParser):
573
463
 
574
464
  return extracted_responses
575
465
 
576
-
577
- class OpenAPI30Parser(BaseOpenAPIParser):
578
- """Parser for OpenAPI 3.0 schemas."""
579
-
580
- def __init__(self, openapi: OpenAPI_30):
581
- self.openapi = openapi
582
-
583
466
  def parse(self) -> list[HTTPRoute]:
584
- """Parse an OpenAPI 3.0 schema into HTTP routes."""
467
+ """Parse the OpenAPI schema into HTTP routes."""
585
468
  routes: list[HTTPRoute] = []
586
469
 
587
- if not self.openapi.paths:
470
+ if not hasattr(self.openapi, "paths") or not self.openapi.paths:
588
471
  logger.warning("OpenAPI schema has no paths defined.")
589
472
  return []
590
473
 
591
- # Extract component schemas to add to each route
474
+ # Extract component schemas
592
475
  schema_definitions = {}
593
476
  if hasattr(self.openapi, "components") and self.openapi.components:
594
477
  components = self.openapi.components
595
478
  if hasattr(components, "schemas") and components.schemas:
596
479
  for name, schema in components.schemas.items():
597
480
  try:
598
- if isinstance(schema, Reference_30):
481
+ if isinstance(schema, self.reference_cls):
599
482
  resolved_schema = self._resolve_ref(schema)
600
483
  schema_definitions[name] = self._extract_schema_as_dict(
601
484
  resolved_schema
@@ -609,53 +492,58 @@ class OpenAPI30Parser(BaseOpenAPIParser):
609
492
  f"Failed to extract schema definition '{name}': {e}"
610
493
  )
611
494
 
495
+ # Process paths and operations
612
496
  for path_str, path_item_obj in self.openapi.paths.items():
613
- if not isinstance(path_item_obj, PathItem_30):
497
+ if not isinstance(path_item_obj, self.path_item_cls):
614
498
  logger.warning(
615
- f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
499
+ f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})"
616
500
  )
617
501
  continue
618
502
 
619
- path_level_params = path_item_obj.parameters
620
-
621
- # Iterate through possible HTTP methods defined in the PathItem model fields
622
- # Use model_fields from the class, not the instance
623
- for method_lower in PathItem_30.model_fields.keys():
624
- if method_lower not in [
625
- "get",
626
- "put",
627
- "post",
628
- "delete",
629
- "options",
630
- "head",
631
- "patch",
632
- "trace",
633
- ]:
634
- continue
503
+ path_level_params = (
504
+ path_item_obj.parameters
505
+ if hasattr(path_item_obj, "parameters")
506
+ else None
507
+ )
635
508
 
636
- operation: Operation_30 | None = getattr(
637
- path_item_obj, method_lower, None
638
- )
509
+ # Get HTTP methods from the path item class fields
510
+ http_methods = [
511
+ "get",
512
+ "put",
513
+ "post",
514
+ "delete",
515
+ "options",
516
+ "head",
517
+ "patch",
518
+ "trace",
519
+ ]
520
+ for method_lower in http_methods:
521
+ operation = getattr(path_item_obj, method_lower, None)
522
+
523
+ if operation and isinstance(operation, self.operation_cls):
524
+ # Cast method to HttpMethod - safe since we only use valid HTTP methods
525
+ method_upper = method_lower.upper()
639
526
 
640
- if operation and isinstance(operation, Operation_30):
641
- method_upper = cast(HttpMethod, method_lower.upper())
642
- logger.debug(f"Processing operation: {method_upper} {path_str}")
643
527
  try:
644
528
  parameters = self._extract_parameters(
645
- operation.parameters, path_level_params
529
+ getattr(operation, "parameters", None), path_level_params
646
530
  )
531
+
647
532
  request_body_info = self._extract_request_body(
648
- operation.requestBody
533
+ getattr(operation, "requestBody", None)
534
+ )
535
+
536
+ responses = self._extract_responses(
537
+ getattr(operation, "responses", None)
649
538
  )
650
- responses = self._extract_responses(operation.responses)
651
539
 
652
540
  route = HTTPRoute(
653
541
  path=path_str,
654
- method=method_upper,
655
- operation_id=operation.operationId,
656
- summary=operation.summary,
657
- description=operation.description,
658
- tags=operation.tags or [],
542
+ method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
543
+ operation_id=getattr(operation, "operationId", None),
544
+ summary=getattr(operation, "summary", None),
545
+ description=getattr(operation, "description", None),
546
+ tags=getattr(operation, "tags", []) or [],
659
547
  parameters=parameters,
660
548
  request_body=request_body_info,
661
549
  responses=responses,
@@ -666,7 +554,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
666
554
  f"Successfully extracted route: {method_upper} {path_str}"
667
555
  )
668
556
  except Exception as op_error:
669
- op_id = operation.operationId or "unknown"
557
+ op_id = getattr(operation, "operationId", "unknown")
670
558
  logger.error(
671
559
  f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
672
560
  exc_info=True,
@@ -675,257 +563,6 @@ class OpenAPI30Parser(BaseOpenAPIParser):
675
563
  logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
676
564
  return routes
677
565
 
678
- def _resolve_ref(
679
- self, item: Reference_30 | Schema_30 | Parameter_30 | RequestBody_30 | Any
680
- ) -> Any:
681
- """Resolves a potential Reference object to its target definition for OpenAPI 3.0."""
682
- if isinstance(item, Reference_30):
683
- ref_str = item.ref
684
- try:
685
- if not ref_str.startswith("#/"):
686
- raise ValueError(
687
- f"External or non-local reference not supported: {ref_str}"
688
- )
689
- parts = ref_str.strip("#/").split("/")
690
- target = self.openapi
691
- for part in parts:
692
- if part.isdigit() and isinstance(target, list):
693
- target = target[int(part)]
694
- elif isinstance(target, BaseModel):
695
- # Use model_extra for fields not explicitly defined (like components types)
696
- # Check class fields first, then model_extra
697
- if part in target.__class__.model_fields:
698
- target = getattr(target, part, None)
699
- elif target.model_extra and part in target.model_extra:
700
- target = target.model_extra[part]
701
- else:
702
- # Special handling for components sub-types common structure
703
- if part == "components" and hasattr(target, "components"):
704
- target = getattr(target, "components")
705
- elif hasattr(target, part): # Fallback check
706
- target = getattr(target, part, None)
707
- else:
708
- target = None # Part not found
709
- elif isinstance(target, dict):
710
- target = target.get(part)
711
- else:
712
- raise ValueError(
713
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
714
- )
715
- if target is None:
716
- raise ValueError(
717
- f"Reference part '{part}' not found in path '{ref_str}'"
718
- )
719
- if isinstance(target, Reference_30):
720
- return self._resolve_ref(target)
721
- return target
722
- except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
723
- raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
724
- return item
725
-
726
- def _extract_schema_as_dict(
727
- self, schema_obj: Schema_30 | Reference_30
728
- ) -> JsonSchema:
729
- """Resolves a schema/reference and returns it as a dictionary for OpenAPI 3.0."""
730
- resolved_schema = self._resolve_ref(schema_obj)
731
- if isinstance(resolved_schema, Schema_30):
732
- # Using exclude_none=True might be better than exclude_unset sometimes
733
- return resolved_schema.model_dump(
734
- mode="json", by_alias=True, exclude_none=True
735
- )
736
- elif isinstance(resolved_schema, dict):
737
- logger.warning(
738
- "Resolved schema reference resulted in a dict, not a Schema model."
739
- )
740
- return resolved_schema
741
- else:
742
- ref_str = getattr(schema_obj, "ref", "unknown")
743
- logger.warning(
744
- f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
745
- )
746
- return {}
747
-
748
- def _extract_parameters(
749
- self,
750
- operation_params: list[Parameter_30 | Reference_30] | None,
751
- path_item_params: list[Parameter_30 | Reference_30] | None,
752
- ) -> list[ParameterInfo]:
753
- """Extracts and resolves parameters for OpenAPI 3.0."""
754
- extracted_params: list[ParameterInfo] = []
755
- seen_params: dict[
756
- tuple[str, str], bool
757
- ] = {} # Use string keys to avoid type issues
758
- all_params_refs = (operation_params or []) + (path_item_params or [])
759
-
760
- for param_or_ref in all_params_refs:
761
- try:
762
- parameter = cast(Parameter_30, self._resolve_ref(param_or_ref))
763
- if not isinstance(parameter, Parameter_30):
764
- logger.warning(
765
- f"Expected Parameter after resolving reference, got {type(parameter)}. Skipping."
766
- )
767
- continue
768
-
769
- # OpenAPI 3.0 uses 'in' field for parameter location
770
- param_in = parameter.param_in
771
- param_location = self._convert_to_parameter_location(param_in)
772
- param_schema_obj = parameter.param_schema
773
-
774
- param_key = (parameter.name, param_in)
775
- if param_key in seen_params:
776
- continue
777
- seen_params[param_key] = True
778
-
779
- param_schema_dict = {}
780
- if param_schema_obj: # Check if schema exists
781
- # Resolve the schema if it's a reference
782
- resolved_schema = self._resolve_ref(param_schema_obj)
783
- param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
784
-
785
- # Ensure default value is preserved from resolved schema
786
- if (
787
- not isinstance(resolved_schema, Reference_30)
788
- and hasattr(resolved_schema, "default")
789
- and resolved_schema.default is not None
790
- ):
791
- param_schema_dict["default"] = resolved_schema.default
792
- elif parameter.content:
793
- # Handle complex parameters with 'content'
794
- first_media_type = next(iter(parameter.content.values()), None)
795
- if first_media_type and first_media_type.media_type_schema:
796
- # Resolve the schema if it's a reference
797
- media_schema = first_media_type.media_type_schema
798
- resolved_media_schema = self._resolve_ref(media_schema)
799
- param_schema_dict = self._extract_schema_as_dict(media_schema)
800
-
801
- # Ensure default value is preserved from resolved schema
802
- if (
803
- not isinstance(resolved_media_schema, Reference_30)
804
- and hasattr(resolved_media_schema, "default")
805
- and resolved_media_schema.default is not None
806
- ):
807
- param_schema_dict["default"] = resolved_media_schema.default
808
-
809
- logger.debug(
810
- f"Parameter '{parameter.name}' using schema from 'content' field."
811
- )
812
-
813
- # Manually create ParameterInfo instance using correct field names
814
- param_info = ParameterInfo(
815
- name=parameter.name,
816
- location=param_location, # Use converted parameter location
817
- required=parameter.required,
818
- schema=param_schema_dict, # Populate 'schema' field in IR
819
- description=parameter.description,
820
- )
821
- extracted_params.append(param_info)
822
-
823
- except (
824
- ValidationError,
825
- ValueError,
826
- AttributeError,
827
- TypeError,
828
- ) as e: # Added TypeError
829
- param_name = getattr(
830
- param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
831
- )
832
- logger.error(
833
- f"Failed to extract parameter '{param_name}': {e}", exc_info=False
834
- )
835
-
836
- return extracted_params
837
-
838
- def _extract_request_body(
839
- self, request_body_or_ref: RequestBody_30 | Reference_30 | None
840
- ) -> RequestBodyInfo | None:
841
- """Extracts request body information for OpenAPI 3.0 using correct attribute names."""
842
- if request_body_or_ref is None:
843
- return None
844
-
845
- try:
846
- request_body = cast(RequestBody_30, self._resolve_ref(request_body_or_ref))
847
-
848
- if not isinstance(request_body, RequestBody_30):
849
- logger.warning(
850
- f"Expected RequestBody after resolving reference, got {type(request_body)}. Returning None."
851
- )
852
- return None
853
-
854
- request_body_info = RequestBodyInfo(
855
- required=request_body.required,
856
- description=request_body.description,
857
- )
858
-
859
- # Process content field for request body schemas
860
- if request_body.content:
861
- for media_type_key, media_type_obj in request_body.content.items():
862
- if (
863
- media_type_obj and media_type_obj.media_type_schema
864
- ): # CORRECTED: Use 'media_type_schema'
865
- schema_dict = self._extract_schema_as_dict(
866
- media_type_obj.media_type_schema
867
- )
868
- request_body_info.content_schema[media_type_key] = schema_dict
869
-
870
- return request_body_info
871
-
872
- except (ValidationError, ValueError, AttributeError) as e:
873
- ref_str = getattr(request_body_or_ref, "ref", "unknown")
874
- logger.error(
875
- f"Failed to extract request body info from reference '{ref_str}': {e}",
876
- exc_info=False,
877
- )
878
- return None
879
-
880
- def _extract_responses(
881
- self,
882
- operation_responses: dict[str, Response_30 | Reference_30] | None,
883
- ) -> dict[str, ResponseInfo]:
884
- """Extracts response information from an OpenAPI 3.0 operation's responses."""
885
- extracted_responses: dict[str, ResponseInfo] = {}
886
- if not operation_responses:
887
- return extracted_responses
888
-
889
- for status_code, response_or_ref in operation_responses.items():
890
- try:
891
- # Skip 'default' response for simplicity if needed
892
- # if status_code == "default":
893
- # continue
894
-
895
- response = cast(Response_30, self._resolve_ref(response_or_ref))
896
-
897
- if not isinstance(response, Response_30):
898
- logger.warning(
899
- f"Expected Response after resolving reference for status code {status_code}, "
900
- f"got {type(response)}. Skipping."
901
- )
902
- continue
903
-
904
- response_info = ResponseInfo(description=response.description)
905
-
906
- # Extract content schemas if present
907
- if response.content:
908
- for media_type_key, media_type_obj in response.content.items():
909
- if (
910
- media_type_obj and media_type_obj.media_type_schema
911
- ): # CORRECTED: Use 'media_type_schema'
912
- schema_dict = self._extract_schema_as_dict(
913
- media_type_obj.media_type_schema
914
- )
915
- response_info.content_schema[media_type_key] = schema_dict
916
-
917
- extracted_responses[status_code] = response_info
918
-
919
- except (ValidationError, ValueError, AttributeError) as e:
920
- ref_str = getattr(response_or_ref, "ref", "unknown")
921
- logger.error(
922
- f"Failed to extract response info for status code {status_code} "
923
- f"from reference '{ref_str}': {e}",
924
- exc_info=False,
925
- )
926
-
927
- return extracted_responses
928
-
929
566
 
930
567
  def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
931
568
  """
@@ -956,6 +593,7 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
956
593
  # "multipleOf", "minItems", "maxItems", "uniqueItems",
957
594
  # "minProperties", "maxProperties"
958
595
  ]
596
+
959
597
  for field in fields_to_remove:
960
598
  if field in cleaned:
961
599
  cleaned.pop(field)
@@ -985,11 +623,6 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
985
623
  # Maybe keep 'true' or represent as 'Allows additional properties' text?
986
624
  pass # Keep simple boolean for now
987
625
 
988
- # Remove title if it just repeats the property name (heuristic)
989
- # This requires knowing the property name, so better done when formatting properties dict
990
-
991
- return cleaned
992
-
993
626
 
994
627
  def generate_example_from_schema(schema: JsonSchema | None) -> Any:
995
628
  """
@@ -1088,8 +721,8 @@ def format_description_with_responses(
1088
721
  responses: dict[
1089
722
  str, Any
1090
723
  ], # Changed from specific ResponseInfo type to avoid circular imports
1091
- parameters: list[openapi.ParameterInfo] | None = None, # Add parameters parameter
1092
- request_body: openapi.RequestBodyInfo | None = None, # Add request_body parameter
724
+ parameters: list[ParameterInfo] | None = None, # Add parameters parameter
725
+ request_body: RequestBodyInfo | None = None, # Add request_body parameter
1093
726
  ) -> str:
1094
727
  """
1095
728
  Formats the base description string with response, parameter, and request body information.
@@ -1097,10 +730,10 @@ def format_description_with_responses(
1097
730
  Args:
1098
731
  base_description (str): The initial description to be formatted.
1099
732
  responses (dict[str, Any]): A dictionary of response information, keyed by status code.
1100
- parameters (list[openapi.ParameterInfo] | None, optional): A list of parameter information,
733
+ parameters (list[ParameterInfo] | None, optional): A list of parameter information,
1101
734
  including path and query parameters. Each parameter includes details such as name,
1102
735
  location, whether it is required, and a description.
1103
- request_body (openapi.RequestBodyInfo | None, optional): Information about the request body,
736
+ request_body (RequestBodyInfo | None, optional): Information about the request body,
1104
737
  including its description, whether it is required, and its content schema.
1105
738
 
1106
739
  Returns:
@@ -1239,7 +872,7 @@ def format_description_with_responses(
1239
872
  return "\n".join(desc_parts)
1240
873
 
1241
874
 
1242
- def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
875
+ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1243
876
  """
1244
877
  Combines parameter and request body schemas into a single schema.
1245
878
 
@@ -1308,8 +941,6 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1308
941
  result["$defs"] = route.schema_definitions
1309
942
 
1310
943
  # Use compress_schema to remove unused definitions
1311
- from fastmcp.utilities.json_schema import compress_schema
1312
-
1313
944
  result = compress_schema(result)
1314
945
 
1315
946
  return result