fix(mcp): list OpenAPI tools by exact prefix boundary so overlapping server prefixes stay separate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-12 19:16:55 +00:00
parent eba05f4333
commit 4296a5d5d7
2 changed files with 41 additions and 9 deletions

View file

@ -4436,21 +4436,15 @@ class MCPServerManager:
if server.spec_path:
# OpenAPI tools were stored in the registry under the prefix
# active at registration time — fetch by that same prefix.
_tools: Final = global_mcp_tool_registry.list_tools(tool_prefix=get_server_prefix(server))
registry_prefix: Final = normalize_server_name(get_server_prefix(server)) + MCP_TOOL_PREFIX_SEPARATOR
_tools: Final = global_mcp_tool_registry.list_tools(tool_prefix=registry_prefix)
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(_tools)
# OpenAPI tools are stored in the registry with their prefix already
# applied (e.g. "test_petstore-getinventory"). Do NOT pass them
# through _create_prefixed_tools — that would add the prefix a second
# time producing "test_petstore-test_petstore-getinventory".
prefix: Final = get_server_prefix(server)
sep: Final = MCP_TOOL_PREFIX_SEPARATOR
unprefixed_tools: Final = [ # mutable-ok: returned through the list[MCPTool] listing contract
(
t.model_copy(update={"name": t.name[len(prefix) + len(sep) :]})
if t.name.startswith(f"{prefix}{sep}")
else t
)
for t in tools
t.model_copy(update={"name": t.name[len(registry_prefix) :]}) for t in tools
]
self._listed_tools_by_server_id[server.server_id] = MappingProxyType(
{t.name: t for t in unprefixed_tools}

View file

@ -6666,6 +6666,44 @@ class TestMCPServerManager:
assert tool is not None and tool.description == "List pets"
assert tool.inputSchema["properties"] == {"limit": {"type": "integer"}}
@pytest.mark.asyncio
async def test_openapi_listing_ignores_overlapping_server_prefix(self):
from litellm.proxy._experimental.mcp_server.tool_registry import global_mcp_tool_registry
server = MCPServer(
server_id="pet-id",
name="pet",
alias="pet",
transport=MCPTransport.http,
url=None,
spec_path="/spec.yaml",
)
manager = MCPServerManager()
manager._create_mcp_client = AsyncMock(return_value=AsyncMock())
async def _handler(**kwargs):
return None
with patch.dict(global_mcp_tool_registry.tools, {}, clear=True):
global_mcp_tool_registry.register_tool(
name="pet-petstore-list",
description="Local pet tool",
input_schema={"type": "object", "properties": {"limit": {"type": "integer"}}},
handler=_handler,
)
global_mcp_tool_registry.register_tool(
name="petstore-list",
description="Foreign petstore tool",
input_schema={"type": "object", "properties": {"status": {"type": "string"}}},
handler=_handler,
)
listed = await manager._get_tools_from_server(server=server, add_prefix=True)
assert [t.name for t in listed] == ["pet-petstore-list"]
tool = manager.get_listed_tool(server, "petstore-list")
assert tool is not None and tool.description == "Local pet tool"
assert tool.inputSchema["properties"] == {"limit": {"type": "integer"}}
@pytest.mark.asyncio
async def test_get_allowed_mcp_servers_with_user_api_key_auth(self):
"""