From 48e8b99752bc62ec0c7aeb4117e0ac3f8ed7da47 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Tue, 1 Sep 2026 22:53:27 +0530 Subject: [PATCH] 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 --- .../mcp_server/openapi_to_mcp_generator.py | 42 +++++++++- tests/mcp_tests/test_openapi_spec_path_url.py | 83 +++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index d115eb8b3c1..c0375e9437e 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -163,6 +163,18 @@ def load_openapi_spec(filepath: str) -> dict[str, Any]: return asyncio.run(load_openapi_spec_async(filepath)) + 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: diff --git a/tests/mcp_tests/test_openapi_spec_path_url.py b/tests/mcp_tests/test_openapi_spec_path_url.py index 17a0022046e..78e1ff91ae5 100644 --- a/tests/mcp_tests/test_openapi_spec_path_url.py +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -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"