fix(mcp): OpenAPI tool listing and execution for relative URLs and camelCase

- Fix case-insensitive tool name matching in _tool_name_matches() so that
  OpenAPI operationIds (camelCase) match lowercase registered tool names
  when filtering by allowed_tools
- Fix get_base_url() to resolve relative server URLs (e.g. /api/v3) by
  deriving full base URL from spec_path when OpenAPI spec has relative URLs
- Add tests for case-insensitive matching and filter_tools_by_allowed_tools

Made-with: Cursor
This commit is contained in:
Sameer Kankute
2026-03-10 11:34:23 +05:30
parent cf84072662
commit db99fdeff3
3 changed files with 172 additions and 5 deletions
@@ -92,7 +92,24 @@ def get_base_url(spec: Dict[str, Any], spec_path: Optional[str] = None) -> str:
"""Extract base URL from OpenAPI spec."""
# OpenAPI 3.x
if "servers" in spec and spec["servers"]:
return spec["servers"][0]["url"]
server_url = spec["servers"][0]["url"]
# If the server URL is relative (starts with /), derive base from spec_path
if server_url.startswith("/") and spec_path:
if spec_path.startswith("http://") or spec_path.startswith("https://"):
# Extract base URL from spec_path (e.g., https://petstore3.swagger.io/api/v3/openapi.json)
# Combine domain with the relative server URL
from urllib.parse import urlparse
parsed = urlparse(spec_path)
base_domain = f"{parsed.scheme}://{parsed.netloc}"
full_base_url = base_domain + server_url
verbose_logger.info(
f"OpenAPI spec has relative server URL '{server_url}'. "
f"Deriving base from spec_path: {full_base_url}"
)
return full_base_url
return server_url
# OpenAPI 2.x (Swagger)
elif "host" in spec:
scheme = spec.get("schemes", ["https"])[0]
@@ -711,6 +711,7 @@ if MCP_AVAILABLE:
Checks both the full tool name and unprefixed version (without server prefix).
This allows users to configure simple tool names regardless of prefixing.
Comparison is case-insensitive to handle OpenAPI operationIds that may be in camelCase.
Args:
tool_name: The tool name to check (may be prefixed like "server-tool_name")
@@ -723,13 +724,15 @@ if MCP_AVAILABLE:
split_server_prefix_from_name,
)
# Check if the full name is in the list
if tool_name in filter_list:
# Normalize filter list to lowercase for case-insensitive comparison
filter_list_lower = [f.lower() for f in filter_list]
if tool_name.lower() in filter_list_lower:
return True
# Check if the unprefixed name is in the list
# Check if the unprefixed name is in the list (case-insensitive)
unprefixed_name, _ = split_server_prefix_from_name(tool_name)
return unprefixed_name in filter_list
return unprefixed_name.lower() in filter_list_lower
def filter_tools_by_allowed_tools(
tools: List[MCPTool],
@@ -2093,3 +2093,150 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab
assert spend_meta["tool_count_total"] == 1
assert spend_meta["allowed_server_count"] == 1
assert spend_meta["per_server_tool_counts"]["server_a"] == 1
def test_tool_name_matches_case_insensitive():
"""Test that _tool_name_matches performs case-insensitive comparison.
This is critical for OpenAPI-based MCP servers where:
1. operationIds are often in camelCase (e.g., 'addPet', 'updatePet')
2. Tool names are lowercased during registration (e.g., 'addpet', 'updatepet')
3. allowed_tools configuration may use the original camelCase names
Without case-insensitive matching, all tools would be filtered out.
"""
try:
from litellm.proxy._experimental.mcp_server.server import _tool_name_matches
except ImportError:
pytest.skip("MCP server not available")
# Test case 1: Unprefixed tool name with camelCase in filter list
assert _tool_name_matches("addpet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("updatepet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("deletepet", ["addPet", "updatePet"]) is False
# Test case 2: Prefixed tool name with camelCase in filter list
assert _tool_name_matches("per_store-addpet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("per_store-updatepet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("per_store-deletepet", ["addPet", "updatePet"]) is False
# Test case 3: Mixed case variations
assert _tool_name_matches("findPetsByStatus", ["findpetsbystatus"]) is True
assert _tool_name_matches("findpetsbystatus", ["findPetsByStatus"]) is True
assert _tool_name_matches("FINDPETSBYSTATUS", ["findPetsByStatus"]) is True
# Test case 4: Full prefixed name in filter list (case-insensitive)
assert _tool_name_matches("server-addPet", ["server-addpet"]) is True
assert _tool_name_matches("server-addpet", ["server-addPet"]) is True
# Test case 5: Ensure non-matching names still don't match
assert _tool_name_matches("addpet", ["deletePet", "updatePet"]) is False
assert _tool_name_matches("server-addpet", ["deletePet", "updatePet"]) is False
def test_filter_tools_by_allowed_tools_case_insensitive():
"""Test that filter_tools_by_allowed_tools handles case-insensitive matching.
Ensures that OpenAPI tools with lowercase names can be filtered using
camelCase allowed_tools configuration from the OpenAPI spec.
"""
try:
from litellm.proxy._experimental.mcp_server.server import (
filter_tools_by_allowed_tools,
)
from litellm.types.mcp_server.tool_registry import MCPTool
except ImportError:
pytest.skip("MCP server not available")
# Mock handler function
def mock_handler(**kwargs):
return kwargs
# Create mock tools with lowercase names (as registered from OpenAPI)
tools = [
MCPTool(
name="per_store-addpet",
description="Add a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-updatepet",
description="Update a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-deletepet",
description="Delete a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-findpetsbystatus",
description="Find pets by status",
input_schema={"type": "object"},
handler=mock_handler,
),
]
# Create mock server with camelCase allowed_tools (as from OpenAPI spec)
server = MCPServer(
server_id="test-server",
name="per_store",
transport=MCPTransport.http,
allowed_tools=["addPet", "updatePet", "findPetsByStatus"],
)
# Filter tools
filtered_tools = filter_tools_by_allowed_tools(tools, server)
# Should return 3 tools (case-insensitive match)
assert len(filtered_tools) == 3
assert any(t.name == "per_store-addpet" for t in filtered_tools)
assert any(t.name == "per_store-updatepet" for t in filtered_tools)
assert any(t.name == "per_store-findpetsbystatus" for t in filtered_tools)
assert not any(t.name == "per_store-deletepet" for t in filtered_tools)
def test_filter_tools_by_allowed_tools_no_filter():
"""Test that filter_tools_by_allowed_tools returns all tools when no filter is set."""
try:
from litellm.proxy._experimental.mcp_server.server import (
filter_tools_by_allowed_tools,
)
from litellm.types.mcp_server.tool_registry import MCPTool
except ImportError:
pytest.skip("MCP server not available")
# Mock handler function
def mock_handler(**kwargs):
return kwargs
tools = [
MCPTool(
name="fusion_litellm_mcp-model_list",
description="List models",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="fusion_litellm_mcp-chat_completion",
description="Chat completion",
input_schema={"type": "object"},
handler=mock_handler,
),
]
# Server with no allowed_tools filter
server = MCPServer(
server_id="test-server",
name="fusion_litellm_mcp",
transport=MCPTransport.http,
allowed_tools=None,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
# Should return all tools when no filter is configured
assert len(filtered_tools) == 2