mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
mcp: support http(s) URLs for spec_path in OpenAPI MCP loader (#20753)
* fix(responses): preserve streamed tool deltas when id is omitted * fix(responses): guard ambiguous tool-call index reuse * add missing indexes on VerificationToken table * mcp: support http(s) URLs for spec_path in OpenAPI MCP loader * test(mcp): add unit test for OpenAPI spec_path URL support * Fix OpenAPI spec URL loading to use shared MCP httpx client Ensure URL-based OpenAPI loading honors LiteLLM’s custom httpx configuration, add missing imports, and harden tests to prevent regressions or accidental direct httpx usage. * removed unused import urlparse * removed unsupported timeout argument --------- Co-authored-by: Emerson Gomes <emerson.gomes@thalesgroup.com> Co-authored-by: Sameer Kankute <sameer@berri.ai> Co-authored-by: Carlo Alberto Ferraris <cafxx@mercari.com> Co-authored-by: Andrea Odorisio <Andrea@BR-FHH9MWDQ2PMAC.local>
This commit is contained in:
parent
59c81a30a0
commit
96206ec141
2 changed files with 124 additions and 2 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
92
tests/mcp_tests/test_openapi_spec_path_url.py
Normal file
92
tests/mcp_tests/test_openapi_spec_path_url.py
Normal file
|
|
@ -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
|
||||
|
||||
Loading…
Add table
Reference in a new issue