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 b635f15ed09..deb0b4f9549 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -3,6 +3,8 @@ This module is used to generate MCP tools from OpenAPI specs. """ import json +import asyncio +import os from pathlib import PurePosixPath from typing import Any, Dict, Optional from urllib.parse import quote @@ -45,8 +47,36 @@ def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str: def load_openapi_spec(filepath: str) -> Dict[str, Any]: - """Load OpenAPI specification from JSON file.""" - with open(filepath, "r") as f: + """ + Sync wrapper. For URL specs, use the shared/custom MCP httpx client. + """ + try: + # If we're already inside an event loop, prefer the async function. + asyncio.get_running_loop() + raise RuntimeError( + "load_openapi_spec() was called from within a running event loop. " + "Use 'await load_openapi_spec_async(...)' instead." + ) + except RuntimeError as e: + # "no running event loop" is fine; other RuntimeErrors we re-raise + if "no running event loop" not in str(e).lower(): + raise + return asyncio.run(load_openapi_spec_async(filepath)) + +async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]: + if filepath.startswith("http://") or filepath.startswith("https://"): + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) + # NOTE: do not close shared client if get_async_httpx_client returns a shared singleton. + # If it returns a new client each time, consider wrapping it in an async context manager. + r = await client.get(filepath) + r.raise_for_status() + return r.json() + + # fallback: local file + # Local filesystem path + if not os.path.exists(filepath): + raise FileNotFoundError(f"OpenAPI spec not found at {filepath}") + with open(filepath, "r", encoding="utf-8") as f: return json.load(f) diff --git a/tests/mcp_tests/test_openapi_spec_path_url.py b/tests/mcp_tests/test_openapi_spec_path_url.py new file mode 100644 index 00000000000..03e9db94967 --- /dev/null +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any, Dict + +import httpx +import pytest + +from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator as gen + + +class _FakeAsyncHTTPHandler: + """ + Minimal stand-in for the object returned by get_async_httpx_client(). + openapi_to_mcp_generator.load_openapi_spec_async() calls: + + client = get_async_httpx_client(...) + r = await client.get(url, timeout=30.0) + + So we must implement async get(). + """ + + def __init__(self, response: httpx.Response, expected_url: str): + self._response = response + self._expected_url = expected_url + self.calls = 0 + + async def get(self, request_url: str, timeout: float = 30.0): + self.calls += 1 + assert request_url == self._expected_url + assert timeout == 30.0 + return self._response + + +def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> None: + url = "http://example.local/openapi.json" + expected: Dict[str, Any] = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": {}, + } + + # httpx.Response must include a Request for raise_for_status() to work. + req = httpx.Request("GET", url) + resp = httpx.Response(status_code=200, json=expected, request=req) + + calls = {"get_async_httpx_client": 0} + handler_holder: Dict[str, Any] = {} + + def fake_get_async_httpx_client(*args, **kwargs): + calls["get_async_httpx_client"] += 1 + h = _FakeAsyncHTTPHandler(resp, expected_url=url) + handler_holder["handler"] = h + return h + + # Ensure shared/custom client path is used + monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client) + + # Fail loudly if someone reintroduces direct httpx.get() + def boom(*args, **kwargs): + raise AssertionError("Direct httpx.get() must not be used for URL spec loading") + + monkeypatch.setattr(httpx, "get", boom) + + spec = gen.load_openapi_spec(url) + + assert spec == expected + assert calls["get_async_httpx_client"] == 1 + 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] = { + "openapi": "3.0.0", + "info": {"title": "Local API", "version": "1.0.0"}, + "paths": {}, + } + + p = tmp_path / "openapi.json" + p.write_text( + '{"openapi":"3.0.0","info":{"title":"Local API","version":"1.0.0"},"paths":{}}', + encoding="utf-8", + ) + + # 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") + + monkeypatch.setattr(gen, "get_async_httpx_client", boom_client) + + spec = gen.load_openapi_spec(str(p)) + assert spec == expected +