From 48e8b99752bc62ec0c7aeb4117e0ac3f8ed7da47 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Tue, 1 Sep 2026 22:53:27 +0530 Subject: [PATCH 1/6] 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" From 554f6d50d30decf342598fe75b992ccf5dcb9012 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Wed, 2 Sep 2026 16:09:53 +0530 Subject: [PATCH 2/6] fix(mcp): guard yaml import with clear error for missing PyYAML Addresses greptile-apps review: unguarded 'import yaml' fails with ModuleNotFoundError when litellm is installed with only the 'mcp' extra. Now uses _import_yaml() helper with a clear install message. --- .../mcp_server/openapi_to_mcp_generator.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 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 c0375e9437e..44a954d2a70 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -57,6 +57,19 @@ from litellm.proxy._experimental.mcp_server.tool_registry import ( from litellm.types.mcp import credential_redirect_hook, custom_credential_slot +def _import_yaml(): + """Import and return the yaml module, raising a clear error if missing.""" + try: + import yaml as _yaml + + return _yaml + except ImportError: + raise ImportError( + "PyYAML is required to parse YAML OpenAPI specs. " + "Install it with: pip install pyyaml" + ) from None + + class _OpenAPIJSONSchema(TypedDict, total=False): properties: Mapping[str, object] type: ReadOnly[str] @@ -187,17 +200,13 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None content_type = r.headers.get("content-type", "") if _is_yaml_content(filepath, content_type): - import yaml - - return yaml.safe_load(r.text) + return _import_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) + return _import_yaml().safe_load(r.text) # fallback: local file # Local filesystem path @@ -205,19 +214,15 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None 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) + return _import_yaml().safe_load(f) with open(filepath, "r", encoding="utf-8") as f: try: return json.load(f) except Exception: - import yaml - f.seek(0) - return yaml.safe_load(f) + return _import_yaml().safe_load(f) def get_base_url(spec: Mapping[str, Any], spec_path: str | None = None) -> str: From ad83c98d36dec73178fd4006a3c23e01ddee43b4 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Mon, 14 Sep 2026 12:53:46 +0530 Subject: [PATCH 3/6] fix(mcp): ruff format and lint nits on yaml openapi loader --- .../mcp_server/openapi_to_mcp_generator.py | 11 +++---- tests/mcp_tests/test_openapi_spec_path_url.py | 30 +++++++------------ 2 files changed, 15 insertions(+), 26 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 44a954d2a70..c94bec5bfa0 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -65,8 +65,7 @@ def _import_yaml(): return _yaml except ImportError: raise ImportError( - "PyYAML is required to parse YAML OpenAPI specs. " - "Install it with: pip install pyyaml" + "PyYAML is required to parse YAML OpenAPI specs. Install it with: pip install pyyaml" ) from None @@ -183,9 +182,7 @@ def load_openapi_spec(filepath: str) -> dict[str, Any]: if lower.endswith((".yaml", ".yml")): return True # Check Content-Type header - if content_type and "yaml" in content_type: - return True - return False + return bool(content_type and "yaml" in content_type) async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None) -> dict[str, Any]: @@ -205,7 +202,7 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None # proper Content-Type headers (common with raw GitHub URLs). try: return r.json() - except Exception: + except ValueError: return _import_yaml().safe_load(r.text) # fallback: local file @@ -220,7 +217,7 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None with open(filepath, "r", encoding="utf-8") as f: try: return json.load(f) - except Exception: + except ValueError: f.seek(0) return _import_yaml().safe_load(f) diff --git a/tests/mcp_tests/test_openapi_spec_path_url.py b/tests/mcp_tests/test_openapi_spec_path_url.py index 78e1ff91ae5..3e91bde0333 100644 --- a/tests/mcp_tests/test_openapi_spec_path_url.py +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any import httpx import pytest @@ -33,7 +33,7 @@ class _FakeAsyncHTTPHandler: def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> None: url = "http://example.local/openapi.json" - expected: Dict[str, Any] = { + expected: dict[str, Any] = { "openapi": "3.0.0", "info": {"title": "Test API", "version": "1.0.0"}, "paths": {}, @@ -44,7 +44,7 @@ def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> resp = httpx.Response(status_code=200, json=expected, request=req) calls = {"get_async_httpx_client": 0} - handler_holder: Dict[str, Any] = {} + handler_holder: dict[str, Any] = {} def fake_get_async_httpx_client(*args, **kwargs): calls["get_async_httpx_client"] += 1 @@ -56,9 +56,7 @@ def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client) # Bypass SSRF validation in test (example.local doesn't resolve) - monkeypatch.setattr( - gen, "async_safe_get", lambda client, url, **kw: client.get(url) - ) + monkeypatch.setattr(gen, "async_safe_get", lambda client, url, **kw: client.get(url)) # Fail loudly if someone reintroduces direct httpx.get() def boom(*args, **kwargs): @@ -73,10 +71,8 @@ def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> assert handler_holder["handler"].calls == 1 -def test_load_openapi_spec_supports_local_file_path( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: - expected: Dict[str, Any] = { +def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + expected: dict[str, Any] = { "openapi": "3.0.0", "info": {"title": "Local API", "version": "1.0.0"}, "paths": {}, @@ -90,9 +86,7 @@ def test_load_openapi_spec_supports_local_file_path( # For local files, shared client must NOT be used. def boom_client(*args, **kwargs): - raise AssertionError( - "get_async_httpx_client() must not be called for local file paths" - ) + raise AssertionError("get_async_httpx_client() must not be called for local file paths") monkeypatch.setattr(gen, "get_async_httpx_client", boom_client) @@ -100,11 +94,9 @@ def test_load_openapi_spec_supports_local_file_path( assert spec == expected -def test_load_openapi_spec_supports_yaml_file( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +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] = { + expected: dict[str, Any] = { "openapi": "3.0.0", "info": {"title": "YAML API", "version": "1.0.0"}, "paths": {}, @@ -140,7 +132,7 @@ def test_load_openapi_spec_url_yaml_content_type( request=req, ) - handler_holder: Dict[str, Any] = {} + handler_holder: dict[str, Any] = {} def fake_get_async_httpx_client(*args, **kwargs): h = _FakeAsyncHTTPHandler(resp, expected_url=url) @@ -169,7 +161,7 @@ def test_load_openapi_spec_url_yaml_fallback( request=req, ) - handler_holder: Dict[str, Any] = {} + handler_holder: dict[str, Any] = {} def fake_get_async_httpx_client(*args, **kwargs): h = _FakeAsyncHTTPHandler(resp, expected_url=url) From 0a77248a3133dca3918698018a5a0a627b9dcb58 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Mon, 14 Sep 2026 13:08:39 +0530 Subject: [PATCH 4/6] fix(mcp): clear stray rebase marker in yaml helper --- .../proxy/_experimental/mcp_server/openapi_to_mcp_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c94bec5bfa0..6931c79d144 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -175,7 +175,7 @@ def load_openapi_spec(filepath: str) -> dict[str, Any]: return asyncio.run(load_openapi_spec_async(filepath)) - bool: +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() From 0536358bf0453ccdcaad667e6506d8203c0cba63 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Mon, 14 Sep 2026 23:14:22 +0530 Subject: [PATCH 5/6] fix(mcp): yaml loader returns mapping-only, health stays unhealthy on garbage bodies --- .../mcp_server/mcp_server_manager.py | 2 +- .../mcp_server/openapi_to_mcp_generator.py | 59 +++++++++++++------ tests/mcp_tests/test_openapi_spec_path_url.py | 24 ++++++++ 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index fb0c623473a..539c53b1998 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -905,7 +905,7 @@ async def _openapi_spec_health( return "unhealthy", f"OpenAPI specification request failed (HTTP {exc.response.status_code})" except HTTPResponseLimitError as exc: return "unknown", f"OpenAPI specification probe refused: {exc}" - except (httpx.RequestError, ValueError, OSError) as exc: + except (httpx.RequestError, TypeError, ValueError, OSError) as exc: return "unhealthy", f"OpenAPI specification could not be loaded ({type(exc).__name__})" return "healthy", None 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 6931c79d144..0cf35711e60 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -9,6 +9,7 @@ import os import re from collections.abc import Mapping, Sequence from pathlib import PurePosixPath +from types import ModuleType from typing import Any, Final, TypedDict from urllib.parse import quote @@ -57,7 +58,7 @@ from litellm.proxy._experimental.mcp_server.tool_registry import ( from litellm.types.mcp import credential_redirect_hook, custom_credential_slot -def _import_yaml(): +def _import_yaml() -> ModuleType: """Import and return the yaml module, raising a clear error if missing.""" try: import yaml as _yaml @@ -69,6 +70,18 @@ def _import_yaml(): ) from None +def _load_yaml_mapping(text: str) -> dict[str, Any]: + """Parse YAML text, requiring a mapping at the document root.""" + yaml_mod = _import_yaml() + try: + parsed = yaml_mod.safe_load(text) + except yaml_mod.YAMLError as exc: + raise ValueError(f"Invalid YAML OpenAPI spec: {exc}") from exc + if not isinstance(parsed, dict): + raise TypeError("Invalid OpenAPI spec: expected a JSON/YAML mapping at the document root") + return parsed + + class _OpenAPIJSONSchema(TypedDict, total=False): properties: Mapping[str, object] type: ReadOnly[str] @@ -185,6 +198,23 @@ def _is_yaml_content(filepath: str, content_type: str | None = None) -> bool: return bool(content_type and "yaml" in content_type) +def _load_local_openapi_spec(filepath: str) -> dict[str, Any]: + """Read a local OpenAPI spec file, parsing YAML or JSON.""" + if not os.path.exists(filepath): + raise FileNotFoundError(f"OpenAPI spec not found at {filepath}") + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + if _is_yaml_content(filepath): + return _load_yaml_mapping(content) + try: + return json.loads(content) + except ValueError as json_exc: + try: + return _load_yaml_mapping(content) + except (TypeError, ValueError): + raise json_exc from None + + 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) @@ -197,29 +227,20 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None content_type = r.headers.get("content-type", "") if _is_yaml_content(filepath, content_type): - return _import_yaml().safe_load(r.text) + return _load_yaml_mapping(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 ValueError: - return _import_yaml().safe_load(r.text) + except ValueError as json_exc: + try: + return _load_yaml_mapping(r.text) + except (TypeError, ValueError): + raise json_exc from None - # 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): - with open(filepath, "r", encoding="utf-8") as f: - return _import_yaml().safe_load(f) - - with open(filepath, "r", encoding="utf-8") as f: - try: - return json.load(f) - except ValueError: - f.seek(0) - return _import_yaml().safe_load(f) + # Local files go through a worker thread: the async path must not + # perform blocking disk I/O directly (ruff ASYNC230). + return await asyncio.to_thread(_load_local_openapi_spec, filepath) 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 3e91bde0333..5b231aac09d 100644 --- a/tests/mcp_tests/test_openapi_spec_path_url.py +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -173,3 +173,27 @@ def test_load_openapi_spec_url_yaml_fallback( spec = gen.load_openapi_spec(url) assert spec["info"]["title"] == "Firefly" + + +def test_load_openapi_spec_url_plain_text_body_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 200 body that is neither JSON nor a YAML mapping must raise, not return a string.""" + url = "https://example.local/openapi.json" + + req = httpx.Request("GET", url) + resp = httpx.Response( + status_code=200, + content=b"secret invalid JSON body", + headers={"content-type": "text/plain"}, + request=req, + ) + + def fake_get_async_httpx_client(*args, **kwargs): + return _FakeAsyncHTTPHandler(resp, expected_url=url) + + 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)) + + with pytest.raises(ValueError, match="Expecting value"): + gen.load_openapi_spec(url) From b0869568b070a37e442dd967db9a27d8a0d70d58 Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Wed, 16 Sep 2026 09:24:01 +0530 Subject: [PATCH 6/6] fix(mcp): parse remote openapi bodies off the event loop --- .../mcp_server/openapi_to_mcp_generator.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 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 0cf35711e60..e05c26a0332 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -215,6 +215,21 @@ def _load_local_openapi_spec(filepath: str) -> dict[str, Any]: raise json_exc from None +def _load_remote_openapi_spec(text: str, as_yaml: bool) -> dict[str, Any]: + """Parse a fetched spec body. Runs in a worker thread: YAML parsing is + synchronous CPU work with no nesting/alias limits, so it must not run on + the event loop where a pathological document could stall the proxy.""" + if as_yaml: + return _load_yaml_mapping(text) + try: + return json.loads(text) + except ValueError as json_exc: + try: + return _load_yaml_mapping(text) + except (TypeError, ValueError): + raise json_exc from None + + 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) @@ -226,17 +241,10 @@ async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None r.raise_for_status() content_type = r.headers.get("content-type", "") - if _is_yaml_content(filepath, content_type): - return _load_yaml_mapping(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 ValueError as json_exc: - try: - return _load_yaml_mapping(r.text) - except (TypeError, ValueError): - raise json_exc from None + as_yaml = _is_yaml_content(filepath, content_type) + return await asyncio.to_thread(_load_remote_openapi_spec, r.text, as_yaml) # Local files go through a worker thread: the async path must not # perform blocking disk I/O directly (ruff ASYNC230).