Spaces:
Running
Running
Merge pull request #954 from jlowin/claude-wt-20250625-210215
Browse files
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -274,6 +274,12 @@ class OpenAPIParser(
|
|
| 274 |
result = {}
|
| 275 |
|
| 276 |
return _replace_ref_with_defs(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
except Exception as e:
|
| 278 |
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
| 279 |
return {}
|
|
@@ -406,12 +412,30 @@ class OpenAPIParser(
|
|
| 406 |
request_body_info.content_schema[media_type_str] = (
|
| 407 |
schema_dict
|
| 408 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
except Exception as e:
|
| 410 |
logger.error(
|
| 411 |
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 412 |
)
|
| 413 |
|
| 414 |
return request_body_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
except Exception as e:
|
| 416 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 417 |
logger.error(
|
|
@@ -455,6 +479,17 @@ class OpenAPIParser(
|
|
| 455 |
media_type_obj.media_type_schema
|
| 456 |
)
|
| 457 |
resp_info.content_schema[media_type_str] = schema_dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
except Exception as e:
|
| 459 |
logger.error(
|
| 460 |
f"Failed to extract schema for media type '{media_type_str}' "
|
|
@@ -462,6 +497,16 @@ class OpenAPIParser(
|
|
| 462 |
)
|
| 463 |
|
| 464 |
extracted_responses[str(status_code)] = resp_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
except Exception as e:
|
| 466 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 467 |
logger.error(
|
|
@@ -562,6 +607,17 @@ class OpenAPIParser(
|
|
| 562 |
logger.info(
|
| 563 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 564 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
except Exception as op_error:
|
| 566 |
op_id = getattr(operation, "operationId", "unknown")
|
| 567 |
logger.error(
|
|
@@ -907,6 +963,12 @@ def _replace_ref_with_defs(
|
|
| 907 |
if ref_path.startswith("#/components/schemas/"):
|
| 908 |
schema_name = ref_path.split("/")[-1]
|
| 909 |
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
elif properties := schema.get("properties"):
|
| 911 |
if "$ref" in properties:
|
| 912 |
schema["properties"] = _replace_ref_with_defs(properties)
|
|
|
|
| 274 |
result = {}
|
| 275 |
|
| 276 |
return _replace_ref_with_defs(result)
|
| 277 |
+
except ValueError as e:
|
| 278 |
+
# Re-raise ValueError for external reference errors and other validation issues
|
| 279 |
+
if "External or non-local reference not supported" in str(e):
|
| 280 |
+
raise
|
| 281 |
+
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
| 282 |
+
return {}
|
| 283 |
except Exception as e:
|
| 284 |
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
| 285 |
return {}
|
|
|
|
| 412 |
request_body_info.content_schema[media_type_str] = (
|
| 413 |
schema_dict
|
| 414 |
)
|
| 415 |
+
except ValueError as e:
|
| 416 |
+
# Re-raise ValueError for external reference errors
|
| 417 |
+
if "External or non-local reference not supported" in str(
|
| 418 |
+
e
|
| 419 |
+
):
|
| 420 |
+
raise
|
| 421 |
+
logger.error(
|
| 422 |
+
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 423 |
+
)
|
| 424 |
except Exception as e:
|
| 425 |
logger.error(
|
| 426 |
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 427 |
)
|
| 428 |
|
| 429 |
return request_body_info
|
| 430 |
+
except ValueError as e:
|
| 431 |
+
# Re-raise ValueError for external reference errors
|
| 432 |
+
if "External or non-local reference not supported" in str(e):
|
| 433 |
+
raise
|
| 434 |
+
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 435 |
+
logger.error(
|
| 436 |
+
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
| 437 |
+
)
|
| 438 |
+
return None
|
| 439 |
except Exception as e:
|
| 440 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 441 |
logger.error(
|
|
|
|
| 479 |
media_type_obj.media_type_schema
|
| 480 |
)
|
| 481 |
resp_info.content_schema[media_type_str] = schema_dict
|
| 482 |
+
except ValueError as e:
|
| 483 |
+
# Re-raise ValueError for external reference errors
|
| 484 |
+
if (
|
| 485 |
+
"External or non-local reference not supported"
|
| 486 |
+
in str(e)
|
| 487 |
+
):
|
| 488 |
+
raise
|
| 489 |
+
logger.error(
|
| 490 |
+
f"Failed to extract schema for media type '{media_type_str}' "
|
| 491 |
+
f"in response {status_code}: {e}"
|
| 492 |
+
)
|
| 493 |
except Exception as e:
|
| 494 |
logger.error(
|
| 495 |
f"Failed to extract schema for media type '{media_type_str}' "
|
|
|
|
| 497 |
)
|
| 498 |
|
| 499 |
extracted_responses[str(status_code)] = resp_info
|
| 500 |
+
except ValueError as e:
|
| 501 |
+
# Re-raise ValueError for external reference errors
|
| 502 |
+
if "External or non-local reference not supported" in str(e):
|
| 503 |
+
raise
|
| 504 |
+
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 505 |
+
logger.error(
|
| 506 |
+
f"Failed to extract response for status code {status_code} "
|
| 507 |
+
f"from reference '{ref_name}': {e}",
|
| 508 |
+
exc_info=False,
|
| 509 |
+
)
|
| 510 |
except Exception as e:
|
| 511 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 512 |
logger.error(
|
|
|
|
| 607 |
logger.info(
|
| 608 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 609 |
)
|
| 610 |
+
except ValueError as op_error:
|
| 611 |
+
# Re-raise ValueError for external reference errors
|
| 612 |
+
if "External or non-local reference not supported" in str(
|
| 613 |
+
op_error
|
| 614 |
+
):
|
| 615 |
+
raise
|
| 616 |
+
op_id = getattr(operation, "operationId", "unknown")
|
| 617 |
+
logger.error(
|
| 618 |
+
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
| 619 |
+
exc_info=True,
|
| 620 |
+
)
|
| 621 |
except Exception as op_error:
|
| 622 |
op_id = getattr(operation, "operationId", "unknown")
|
| 623 |
logger.error(
|
|
|
|
| 963 |
if ref_path.startswith("#/components/schemas/"):
|
| 964 |
schema_name = ref_path.split("/")[-1]
|
| 965 |
schema["$ref"] = f"#/$defs/{schema_name}"
|
| 966 |
+
elif not ref_path.startswith("#/"):
|
| 967 |
+
raise ValueError(
|
| 968 |
+
f"External or non-local reference not supported: {ref_path}. "
|
| 969 |
+
f"FastMCP only supports local schema references starting with '#/'. "
|
| 970 |
+
f"Please include all schema definitions within the OpenAPI document."
|
| 971 |
+
)
|
| 972 |
elif properties := schema.get("properties"):
|
| 973 |
if "$ref" in properties:
|
| 974 |
schema["properties"] = _replace_ref_with_defs(properties)
|
tests/utilities/openapi/test_openapi_advanced.py
CHANGED
|
@@ -614,3 +614,52 @@ def test_http_trace_method_path(parsed_http_methods_routes):
|
|
| 614 |
|
| 615 |
assert trace_route is not None
|
| 616 |
assert trace_route.path == "/resource"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
assert trace_route is not None
|
| 616 |
assert trace_route.path == "/resource"
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
@pytest.fixture
|
| 620 |
+
def schema_with_external_reference() -> dict[str, Any]:
|
| 621 |
+
"""Fixture that returns a schema with external schema references like in issue #926."""
|
| 622 |
+
return {
|
| 623 |
+
"openapi": "3.0.0",
|
| 624 |
+
"info": {"title": "External Reference API", "version": "1.0.0"},
|
| 625 |
+
"paths": {
|
| 626 |
+
"/products": {
|
| 627 |
+
"post": {
|
| 628 |
+
"summary": "Create a product",
|
| 629 |
+
"operationId": "createProduct",
|
| 630 |
+
"requestBody": {
|
| 631 |
+
"required": True,
|
| 632 |
+
"content": {
|
| 633 |
+
"application/json": {
|
| 634 |
+
"schema": {
|
| 635 |
+
"type": "object",
|
| 636 |
+
"properties": {
|
| 637 |
+
"obj": {
|
| 638 |
+
"$ref": "http://cyaninc.com/json-schemas/market-v1/product-constraints"
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
},
|
| 644 |
+
},
|
| 645 |
+
"responses": {"201": {"description": "Product created"}},
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
},
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
# --- Tests for external schema reference handling --- #
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def test_external_reference_raises_clear_error(schema_with_external_reference):
|
| 656 |
+
"""Test that external schema references raise a clear, helpful error message."""
|
| 657 |
+
with pytest.raises(ValueError) as exc_info:
|
| 658 |
+
parse_openapi_to_http_routes(schema_with_external_reference)
|
| 659 |
+
|
| 660 |
+
error_message = str(exc_info.value)
|
| 661 |
+
assert "External or non-local reference not supported" in error_message
|
| 662 |
+
assert (
|
| 663 |
+
"http://cyaninc.com/json-schemas/market-v1/product-constraints" in error_message
|
| 664 |
+
)
|
| 665 |
+
assert "FastMCP only supports local schema references" in error_message
|