fix(mcp): support YAML OpenAPI specs in MCP server

load_openapi_spec_async() hardcoded json.loads() / r.json() for both
URL and local-file paths. YAML OpenAPI specs (common in the wild,
e.g. firefly-iii, Petstore) fail with 'Expecting value: line 1 column
1' because YAML is not valid JSON.

Detect YAML via file extension (.yaml/.yml) or Content-Type header and
use yaml.safe_load(). For URLs without a YAML indicator, fall back to
YAML parsing when JSON fails (handles raw GitHub URLs that serve YAML
with text/plain Content-Type).

Added 3 new tests: local YAML file, URL with YAML Content-Type, and
URL with .yaml extension but plain text Content-Type. All 5 tests pass.

Fixes #38951

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
bunnysayzz 2026-09-01 22:53:27 +05:30
parent 30f33a949b
commit 48e8b99752
2 changed files with 123 additions and 2 deletions

View file

@ -163,6 +163,18 @@ def load_openapi_spec(filepath: str) -> dict[str, Any]:
return asyncio.run(load_openapi_spec_async(filepath))
<def _is_yaml_content(filepath: str, content_type: str | None = None) -> bool:
"""Determine if the content should be parsed as YAML."""
# Check file extension
lower = filepath.lower()
if lower.endswith((".yaml", ".yml")):
return True
# Check Content-Type header
if content_type and "yaml" in content_type:
return True
return False
async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None) -> dict[str, Any]:
if filepath.startswith("http://") or filepath.startswith("https://"):
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
@ -172,14 +184,40 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None
else await async_safe_get(client, filepath, max_response_bytes=max_bytes)
)
r.raise_for_status()
return r.json()
content_type = r.headers.get("content-type", "")
if _is_yaml_content(filepath, content_type):
import yaml
return yaml.safe_load(r.text)
# Try JSON first; fall back to YAML for specs served without
# proper Content-Type headers (common with raw GitHub URLs).
try:
return r.json()
except Exception:
import yaml
return yaml.safe_load(r.text)
# fallback: local file
# Local filesystem path
if not os.path.exists(filepath):
raise FileNotFoundError(f"OpenAPI spec not found at {filepath}")
if _is_yaml_content(filepath):
import yaml
with open(filepath, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
try:
return json.load(f)
except Exception:
import yaml
f.seek(0)
return yaml.safe_load(f)
def get_base_url(spec: Mapping[str, Any], spec_path: str | None = None) -> str:

View file

@ -98,3 +98,86 @@ def test_load_openapi_spec_supports_local_file_path(
spec = gen.load_openapi_spec(str(p))
assert spec == expected
def test_load_openapi_spec_supports_yaml_file(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""YAML OpenAPI spec files should be parsed correctly."""
expected: Dict[str, Any] = {
"openapi": "3.0.0",
"info": {"title": "YAML API", "version": "1.0.0"},
"paths": {},
}
p = tmp_path / "openapi.yaml"
p.write_text(
"openapi: '3.0.0'\ninfo:\n title: YAML API\n version: '1.0.0'\npaths: {}",
encoding="utf-8",
)
def boom_client(*args, **kwargs):
raise AssertionError("get_async_httpx_client() must not be called for local file paths")
monkeypatch.setattr(gen, "get_async_httpx_client", boom_client)
spec = gen.load_openapi_spec(str(p))
assert spec == expected
def test_load_openapi_spec_url_yaml_content_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""URL returning YAML with Content-Type: text/yaml should be parsed as YAML."""
url = "http://example.local/openapi.yaml"
yaml_body = "openapi: '3.0.0'\ninfo:\n title: Remote YAML\n version: '1.0.0'\npaths: {}"
req = httpx.Request("GET", url)
resp = httpx.Response(
status_code=200,
content=yaml_body.encode(),
headers={"content-type": "text/yaml"},
request=req,
)
handler_holder: Dict[str, Any] = {}
def fake_get_async_httpx_client(*args, **kwargs):
h = _FakeAsyncHTTPHandler(resp, expected_url=url)
handler_holder["handler"] = h
return h
monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client)
monkeypatch.setattr(gen, "async_safe_get", lambda client, url, **kw: client.get(url))
spec = gen.load_openapi_spec(url)
assert spec["info"]["title"] == "Remote YAML"
def test_load_openapi_spec_url_yaml_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""URL with .yaml extension but no YAML Content-Type should still parse as YAML."""
url = "https://raw.githubusercontent.com/firefly-iii/api-docs/refs/heads/v6.6.6/dist/firefly-iii-v6.6.6-v1.yaml"
yaml_body = "openapi: '3.0.0'\ninfo:\n title: Firefly\n version: '6.6.6'\npaths: {}"
req = httpx.Request("GET", url)
resp = httpx.Response(
status_code=200,
content=yaml_body.encode(),
headers={"content-type": "text/plain"},
request=req,
)
handler_holder: Dict[str, Any] = {}
def fake_get_async_httpx_client(*args, **kwargs):
h = _FakeAsyncHTTPHandler(resp, expected_url=url)
handler_holder["handler"] = h
return h
monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client)
monkeypatch.setattr(gen, "async_safe_get", lambda client, url, **kw: client.get(url))
spec = gen.load_openapi_spec(url)
assert spec["info"]["title"] == "Firefly"