Spaces:
Running
Running
Jeremiah Lowin Claude commited on
Commit ·
80215f2
1
Parent(s): 029001b
Fix external schema reference handling in OpenAPI parser
Browse filesPreviously, external schema references (URLs) in OpenAPI schemas were
silently passed through and only failed during JSON schema validation
with confusing "failed to match exactly one schema" errors.
This change:
- Detects external references in _replace_ref_with_defs() and raises clear error messages
- Updates exception handlers to propagate external reference errors while preserving other error handling
- Adds comprehensive test coverage for external reference detection
- Provides helpful error messages explaining that FastMCP only supports local schema references
Fixes #926
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
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 {}
|
|
@@ -400,12 +406,30 @@ class OpenAPIParser(
|
|
| 400 |
request_body_info.content_schema[media_type_str] = (
|
| 401 |
schema_dict
|
| 402 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
except Exception as e:
|
| 404 |
logger.error(
|
| 405 |
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 406 |
)
|
| 407 |
|
| 408 |
return request_body_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
except Exception as e:
|
| 410 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 411 |
logger.error(
|
|
@@ -449,6 +473,17 @@ class OpenAPIParser(
|
|
| 449 |
media_type_obj.media_type_schema
|
| 450 |
)
|
| 451 |
resp_info.content_schema[media_type_str] = schema_dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
except Exception as e:
|
| 453 |
logger.error(
|
| 454 |
f"Failed to extract schema for media type '{media_type_str}' "
|
|
@@ -456,6 +491,16 @@ class OpenAPIParser(
|
|
| 456 |
)
|
| 457 |
|
| 458 |
extracted_responses[str(status_code)] = resp_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
except Exception as e:
|
| 460 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 461 |
logger.error(
|
|
@@ -556,6 +601,17 @@ class OpenAPIParser(
|
|
| 556 |
logger.info(
|
| 557 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 558 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
except Exception as op_error:
|
| 560 |
op_id = getattr(operation, "operationId", "unknown")
|
| 561 |
logger.error(
|
|
@@ -901,6 +957,12 @@ def _replace_ref_with_defs(
|
|
| 901 |
if ref_path.startswith("#/components/schemas/"):
|
| 902 |
schema_name = ref_path.split("/")[-1]
|
| 903 |
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
elif properties := schema.get("properties"):
|
| 905 |
if "$ref" in properties:
|
| 906 |
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 {}
|
|
|
|
| 406 |
request_body_info.content_schema[media_type_str] = (
|
| 407 |
schema_dict
|
| 408 |
)
|
| 409 |
+
except ValueError as e:
|
| 410 |
+
# Re-raise ValueError for external reference errors
|
| 411 |
+
if "External or non-local reference not supported" in str(
|
| 412 |
+
e
|
| 413 |
+
):
|
| 414 |
+
raise
|
| 415 |
+
logger.error(
|
| 416 |
+
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 417 |
+
)
|
| 418 |
except Exception as e:
|
| 419 |
logger.error(
|
| 420 |
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 421 |
)
|
| 422 |
|
| 423 |
return request_body_info
|
| 424 |
+
except ValueError as e:
|
| 425 |
+
# Re-raise ValueError for external reference errors
|
| 426 |
+
if "External or non-local reference not supported" in str(e):
|
| 427 |
+
raise
|
| 428 |
+
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 429 |
+
logger.error(
|
| 430 |
+
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
| 431 |
+
)
|
| 432 |
+
return None
|
| 433 |
except Exception as e:
|
| 434 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 435 |
logger.error(
|
|
|
|
| 473 |
media_type_obj.media_type_schema
|
| 474 |
)
|
| 475 |
resp_info.content_schema[media_type_str] = schema_dict
|
| 476 |
+
except ValueError as e:
|
| 477 |
+
# Re-raise ValueError for external reference errors
|
| 478 |
+
if (
|
| 479 |
+
"External or non-local reference not supported"
|
| 480 |
+
in str(e)
|
| 481 |
+
):
|
| 482 |
+
raise
|
| 483 |
+
logger.error(
|
| 484 |
+
f"Failed to extract schema for media type '{media_type_str}' "
|
| 485 |
+
f"in response {status_code}: {e}"
|
| 486 |
+
)
|
| 487 |
except Exception as e:
|
| 488 |
logger.error(
|
| 489 |
f"Failed to extract schema for media type '{media_type_str}' "
|
|
|
|
| 491 |
)
|
| 492 |
|
| 493 |
extracted_responses[str(status_code)] = resp_info
|
| 494 |
+
except ValueError as e:
|
| 495 |
+
# Re-raise ValueError for external reference errors
|
| 496 |
+
if "External or non-local reference not supported" in str(e):
|
| 497 |
+
raise
|
| 498 |
+
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 499 |
+
logger.error(
|
| 500 |
+
f"Failed to extract response for status code {status_code} "
|
| 501 |
+
f"from reference '{ref_name}': {e}",
|
| 502 |
+
exc_info=False,
|
| 503 |
+
)
|
| 504 |
except Exception as e:
|
| 505 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 506 |
logger.error(
|
|
|
|
| 601 |
logger.info(
|
| 602 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 603 |
)
|
| 604 |
+
except ValueError as op_error:
|
| 605 |
+
# Re-raise ValueError for external reference errors
|
| 606 |
+
if "External or non-local reference not supported" in str(
|
| 607 |
+
op_error
|
| 608 |
+
):
|
| 609 |
+
raise
|
| 610 |
+
op_id = getattr(operation, "operationId", "unknown")
|
| 611 |
+
logger.error(
|
| 612 |
+
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
| 613 |
+
exc_info=True,
|
| 614 |
+
)
|
| 615 |
except Exception as op_error:
|
| 616 |
op_id = getattr(operation, "operationId", "unknown")
|
| 617 |
logger.error(
|
|
|
|
| 957 |
if ref_path.startswith("#/components/schemas/"):
|
| 958 |
schema_name = ref_path.split("/")[-1]
|
| 959 |
schema["$ref"] = f"#/$defs/{schema_name}"
|
| 960 |
+
elif not ref_path.startswith("#/"):
|
| 961 |
+
raise ValueError(
|
| 962 |
+
f"External or non-local reference not supported: {ref_path}. "
|
| 963 |
+
f"FastMCP only supports local schema references starting with '#/'. "
|
| 964 |
+
f"Please include all schema definitions within the OpenAPI document."
|
| 965 |
+
)
|
| 966 |
elif properties := schema.get("properties"):
|
| 967 |
if "$ref" in properties:
|
| 968 |
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
|