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:
moophlo 2026-02-10 07:01:37 +04:00 • committed by Sameer Kankute
parent 59c81a30a0
commit 96206ec141
2 changed files with 124 additions and 2 deletions

View file

@ -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)

View 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