From ecc31e789933cd4f044e0278900fd874a4b83212 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 11:26:18 -0700 Subject: [PATCH 01/27] init mcp client manager --- .../mcp_server/mcp_client_manager.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 litellm/proxy/_experimental/mcp_server/mcp_client_manager.py diff --git a/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py new file mode 100644 index 0000000000..c1a2310775 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py @@ -0,0 +1,89 @@ +""" +MCP Client Manager + +This class is responsible for managing MCP SSE clients. + +This is a Proxy +""" + +from typing import Any, Dict, List, Optional + +from mcp import ClientSession +from mcp.client.sse import sse_client +from pydantic import BaseModel, ConfigDict + +from litellm._logging import verbose_logger + + +class MCPSSEServer(BaseModel): + name: str + url: str + client_session: Optional[ClientSession] = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class MCPServerManager: + def __init__(self, mcp_servers: List[MCPSSEServer]): + self.mcp_servers: List[MCPSSEServer] = mcp_servers + """ + eg. + [ + { + "name": "zapier_mcp_server", + "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" + }, + { + "name": "google_drive_mcp_server", + "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" + } + ] + """ + + self.tool_name_to_mcp_server_name_mapping: Dict[str, str] = {} + """ + { + "gmail_send_email": "zapier_mcp_server", + } + """ + + async def list_tools(self): + """ + List all tools available in all the MCP Servers + """ + for server in self.mcp_servers: + async with sse_client(url=server.url) as (read, write): + async with ClientSession(read, write) as session: + server.client_session = session + await server.client_session.initialize() + list_tools_result = await server.client_session.list_tools() + verbose_logger.debug( + f"Tools from {server.name}: {list_tools_result}" + ) + for tool in list_tools_result.tools: + self.tool_name_to_mcp_server_name_mapping[tool.name] = ( + server.name + ) + + async def call_tool(self, name: str, arguments: Dict[str, Any]): + """ + Call a tool with the given name and arguments + """ + mcp_server = self._get_mcp_server_from_tool_name(name) + if mcp_server is None: + raise ValueError(f"Tool {name} not found") + async with sse_client(url=mcp_server.url) as (read, write): + async with ClientSession(read, write) as session: + mcp_server.client_session = session + await mcp_server.client_session.initialize() + return await mcp_server.client_session.call_tool(name, arguments) + + def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPSSEServer]: + """ + Get the MCP Server from the tool name + """ + if tool_name in self.tool_name_to_mcp_server_name_mapping: + for server in self.mcp_servers: + if server.name == self.tool_name_to_mcp_server_name_mapping[tool_name]: + return server + return None From c0d07987c486ec8f2acb59ca12ff7bfd6e493b0c Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 12:46:13 -0700 Subject: [PATCH 02/27] use global_mcp_server_manager --- .../mcp_server/mcp_client_manager.py | 89 ------------------- litellm/proxy/proxy_server.py | 7 ++ 2 files changed, 7 insertions(+), 89 deletions(-) delete mode 100644 litellm/proxy/_experimental/mcp_server/mcp_client_manager.py diff --git a/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py deleted file mode 100644 index c1a2310775..0000000000 --- a/litellm/proxy/_experimental/mcp_server/mcp_client_manager.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -MCP Client Manager - -This class is responsible for managing MCP SSE clients. - -This is a Proxy -""" - -from typing import Any, Dict, List, Optional - -from mcp import ClientSession -from mcp.client.sse import sse_client -from pydantic import BaseModel, ConfigDict - -from litellm._logging import verbose_logger - - -class MCPSSEServer(BaseModel): - name: str - url: str - client_session: Optional[ClientSession] = None - - model_config = ConfigDict(arbitrary_types_allowed=True) - - -class MCPServerManager: - def __init__(self, mcp_servers: List[MCPSSEServer]): - self.mcp_servers: List[MCPSSEServer] = mcp_servers - """ - eg. - [ - { - "name": "zapier_mcp_server", - "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" - }, - { - "name": "google_drive_mcp_server", - "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" - } - ] - """ - - self.tool_name_to_mcp_server_name_mapping: Dict[str, str] = {} - """ - { - "gmail_send_email": "zapier_mcp_server", - } - """ - - async def list_tools(self): - """ - List all tools available in all the MCP Servers - """ - for server in self.mcp_servers: - async with sse_client(url=server.url) as (read, write): - async with ClientSession(read, write) as session: - server.client_session = session - await server.client_session.initialize() - list_tools_result = await server.client_session.list_tools() - verbose_logger.debug( - f"Tools from {server.name}: {list_tools_result}" - ) - for tool in list_tools_result.tools: - self.tool_name_to_mcp_server_name_mapping[tool.name] = ( - server.name - ) - - async def call_tool(self, name: str, arguments: Dict[str, Any]): - """ - Call a tool with the given name and arguments - """ - mcp_server = self._get_mcp_server_from_tool_name(name) - if mcp_server is None: - raise ValueError(f"Tool {name} not found") - async with sse_client(url=mcp_server.url) as (read, write): - async with ClientSession(read, write) as session: - mcp_server.client_session = session - await mcp_server.client_session.initialize() - return await mcp_server.client_session.call_tool(name, arguments) - - def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPSSEServer]: - """ - Get the MCP Server from the tool name - """ - if tool_name in self.tool_name_to_mcp_server_name_mapping: - for server in self.mcp_servers: - if server.name == self.tool_name_to_mcp_server_name_mapping[tool_name]: - return server - return None diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 89c15f413d..1a469d2c27 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -127,6 +127,9 @@ from litellm.litellm_core_utils.core_helpers import ( from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, +) from litellm.proxy._experimental.mcp_server.server import router as mcp_router from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, @@ -1961,6 +1964,10 @@ class ProxyConfig: if mcp_tools_config: global_mcp_tool_registry.load_tools_from_config(mcp_tools_config) + mcp_servers_config = config.get("mcp_servers", None) + if mcp_servers_config: + global_mcp_server_manager.load_servers_from_config(mcp_servers_config) + ## CREDENTIALS credential_list_dict = self.load_credential_list(config=config) litellm.credential_list = credential_list_dict From 061f98c5708c94a87dbbd8988bdbb5f2ecd89c90 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 12:51:16 -0700 Subject: [PATCH 03/27] mcp server manager --- .../mcp_server/mcp_server_manager.py | 125 ++++++++++++++++++ .../proxy/_experimental/mcp_server/server.py | 51 ++++++- .../types/mcp_server/mcp_server_manager.py | 11 ++ 3 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 litellm/proxy/_experimental/mcp_server/mcp_server_manager.py create mode 100644 litellm/types/mcp_server/mcp_server_manager.py diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py new file mode 100644 index 0000000000..344fb39f79 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -0,0 +1,125 @@ +""" +MCP Client Manager + +This class is responsible for managing MCP SSE clients. + +This is a Proxy +""" + +import json +from typing import Any, Dict, List, Optional + +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.types import Tool as MCPTool + +from litellm._logging import verbose_logger +from litellm.types.mcp_server.mcp_server_manager import MCPSSEServer + + +class MCPServerManager: + def __init__(self): + self.mcp_servers: List[MCPSSEServer] = [] + """ + eg. + [ + { + "name": "zapier_mcp_server", + "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" + }, + { + "name": "google_drive_mcp_server", + "url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse" + } + ] + """ + + self.tool_name_to_mcp_server_name_mapping: Dict[str, str] = {} + """ + { + "gmail_send_email": "zapier_mcp_server", + } + """ + + def load_servers_from_config(self, mcp_servers_config: Dict[str, Any]): + """ + Load the MCP Servers from the config + """ + for server_name, server_config in mcp_servers_config.items(): + self.mcp_servers.append( + MCPSSEServer( + name=server_name, + url=server_config["url"], + ) + ) + verbose_logger.debug( + f"Loaded MCP Servers: {json.dumps(self.mcp_servers, indent=4, default=str)}" + ) + + async def list_tools(self) -> List[MCPTool]: + """ + List all tools available across all MCP Servers. + + Returns: + List[MCPTool]: Combined list of tools from all servers + """ + list_tools_result: List[MCPTool] = [] + verbose_logger.debug("SSE SERVER MANAGER LISTING TOOLS") + + for server in self.mcp_servers: + tools = await self._get_tools_from_server(server) + list_tools_result.extend(tools) + + return list_tools_result + + async def _get_tools_from_server(self, server: MCPSSEServer) -> List[MCPTool]: + """ + Helper method to get tools from a single MCP server. + + Args: + server (MCPSSEServer): The server to query tools from + + Returns: + List[MCPTool]: List of tools available on the server + """ + verbose_logger.debug(f"Connecting to url: {server.url}") + + async with sse_client(url=server.url) as (read, write): + async with ClientSession(read, write) as session: + server.client_session = session + await server.client_session.initialize() + + tools_result = await server.client_session.list_tools() + verbose_logger.debug(f"Tools from {server.name}: {tools_result}") + + # Update tool to server mapping + for tool in tools_result.tools: + self.tool_name_to_mcp_server_name_mapping[tool.name] = server.name + + return tools_result.tools + + async def call_tool(self, name: str, arguments: Dict[str, Any]): + """ + Call a tool with the given name and arguments + """ + mcp_server = self._get_mcp_server_from_tool_name(name) + if mcp_server is None: + raise ValueError(f"Tool {name} not found") + async with sse_client(url=mcp_server.url) as (read, write): + async with ClientSession(read, write) as session: + mcp_server.client_session = session + await mcp_server.client_session.initialize() + return await mcp_server.client_session.call_tool(name, arguments) + + def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPSSEServer]: + """ + Get the MCP Server from the tool name + """ + if tool_name in self.tool_name_to_mcp_server_name_mapping: + for server in self.mcp_servers: + if server.name == self.tool_name_to_mcp_server_name_mapping[tool_name]: + return server + return None + + +global_mcp_server_manager: MCPServerManager = MCPServerManager() diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index f617312d5a..a1de700d50 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -36,6 +36,7 @@ if MCP_AVAILABLE: from mcp.types import TextContent as MCPTextContent from mcp.types import Tool as MCPTool + from .mcp_server_manager import global_mcp_server_manager from .sse_transport import SseServerTransport from .tool_registry import global_mcp_tool_registry @@ -67,24 +68,64 @@ if MCP_AVAILABLE: inputSchema=tool.input_schema, ) ) - + verbose_logger.debug( + "GLOBAL MCP TOOLS: %s", global_mcp_tool_registry.list_tools() + ) + sse_tools: List[MCPTool] = await global_mcp_server_manager.list_tools() + verbose_logger.debug("SSE TOOLS: %s", sse_tools) + if sse_tools is not None: + tools.extend(sse_tools) return tools @server.call_tool() - async def handle_call_tool( + async def mcp_server_tool_call( name: str, arguments: Dict[str, Any] | None ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: """ Call a specific tool with the provided arguments + + Args: + name (str): Name of the tool to call + arguments (Dict[str, Any] | None): Arguments to pass to the tool + + Returns: + List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: Tool execution results + + Raises: + HTTPException: If tool not found or arguments missing """ - tool = global_mcp_tool_registry.get_tool(name) - if not tool: - raise HTTPException(status_code=404, detail=f"Tool '{name}' not found") + # Validate arguments if arguments is None: raise HTTPException( status_code=400, detail="Request arguments are required" ) + # Try managed server tool first + if name in global_mcp_server_manager.tool_name_to_mcp_server_name_mapping: + return await _handle_managed_mcp_tool(name, arguments) + + # Fall back to local tool registry + return await _handle_local_mcp_tool(name, arguments) + + async def _handle_managed_mcp_tool( + name: str, arguments: Dict[str, Any] + ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: + """Handle tool execution for managed server tools""" + call_tool_result = await global_mcp_server_manager.call_tool( + name=name, + arguments=arguments, + ) + verbose_logger.debug("CALL TOOL RESULT: %s", call_tool_result) + return call_tool_result.content + + async def _handle_local_mcp_tool( + name: str, arguments: Dict[str, Any] + ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: + """Handle tool execution for local registry tools""" + tool = global_mcp_tool_registry.get_tool(name) + if not tool: + raise HTTPException(status_code=404, detail=f"Tool '{name}' not found") + try: result = tool.handler(**arguments) return [MCPTextContent(text=str(result), type="text")] diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py new file mode 100644 index 0000000000..a106557fb6 --- /dev/null +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -0,0 +1,11 @@ +from typing import Optional + +from mcp import ClientSession +from pydantic import BaseModel, ConfigDict + + +class MCPSSEServer(BaseModel): + name: str + url: str + client_session: Optional[ClientSession] = None + model_config = ConfigDict(arbitrary_types_allowed=True) From 790febde4780e7f639590c4cd1097f9a70efff51 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 12:52:46 -0700 Subject: [PATCH 04/27] add testing mcp server --- litellm/proxy/proxy_config.yaml | 14 +++-- tests/mcp_tests/test_mcp_server.py | 37 ++++++++++++ tests/pass_through_tests/test_mcp_routes.py | 66 ++++++++++++++++++--- 3 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 tests/mcp_tests/test_mcp_server.py diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 106003f996..9da28ff51f 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -1,7 +1,13 @@ model_list: - - model_name: fake-openai-endpoint + - model_name: gpt-4o litellm_params: - model: openai/fake - api_key: fake-key - api_base: https://exampleopenaiendpoint-production.up.railway.app/ + model: openai/gpt-4o +mcp_servers: + { + "Zapier MCP": { + "url": "os.environ/ZAPIER_MCP_SERVER_URL", + }, + } + +} \ No newline at end of file diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py new file mode 100644 index 0000000000..17abd1d6a5 --- /dev/null +++ b/tests/mcp_tests/test_mcp_server.py @@ -0,0 +1,37 @@ +# Create server parameters for stdio connection +import os +import sys +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy._experimental.mcp_server.mcp_client_manager import ( + MCPServerManager, + MCPSSEServer, +) + + +MCP_SERVERS = [ + MCPSSEServer(name="zapier_mcp_server", url=os.environ.get("ZAPIER_MCP_SERVER_URL")), +] + +mcp_server_manager = MCPServerManager(mcp_servers=MCP_SERVERS) + + +@pytest.mark.asyncio +async def test_mcp_server_manager(): + tools = await mcp_server_manager.list_tools() + print("TOOLS FROM MCP SERVER MANAGER== ", tools) + + result = await mcp_server_manager.call_tool( + name="gmail_send_email", arguments={"body": "Test"} + ) + print("RESULT FROM CALLING TOOL FROM MCP SERVER MANAGER== ", result) + + +""" +TODO test with multiple MCP servers and calling a specific + +""" diff --git a/tests/pass_through_tests/test_mcp_routes.py b/tests/pass_through_tests/test_mcp_routes.py index 687efe6195..d496a430af 100644 --- a/tests/pass_through_tests/test_mcp_routes.py +++ b/tests/pass_through_tests/test_mcp_routes.py @@ -2,15 +2,24 @@ import asyncio import os +import pytest from langchain_mcp_adapters.tools import load_mcp_tools from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent from mcp import ClientSession from mcp.client.sse import sse_client +from litellm.experimental_mcp_client.tools import ( + transform_mcp_tool_to_openai_tool, + _transform_openai_tool_call_to_mcp_tool_call_request, +) +import json -async def main(): - model = ChatOpenAI(model="gpt-4o", api_key="sk-12") +@pytest.mark.asyncio +async def test_mcp_routes(): + model = ChatOpenAI( + model="gpt-4o", api_key="sk-1234", base_url="http://localhost:4000" + ) async with sse_client(url="http://localhost:4000/mcp/") as (read, write): async with ClientSession(read, write) as session: @@ -25,11 +34,52 @@ async def main(): print("Tools loaded") print(tools) - # # Create and run the agent - # agent = create_react_agent(model, tools) - # agent_response = await agent.ainvoke({"messages": "what's (3 + 5) x 12?"}) + # Create and run the agent + agent = create_react_agent(model, tools) + agent_response = await agent.ainvoke({"messages": "Send an "}) + print(agent_response) -# Run the async function -if __name__ == "__main__": - asyncio.run(main()) +@pytest.mark.asyncio +async def test_mcp_routes_with_vertex_ai(): + # Create and run the agent + from openai import AsyncOpenAI + + openai_client = AsyncOpenAI(api_key="sk-1234", base_url="http://localhost:4000") + async with sse_client(url="http://localhost:4000/mcp/") as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + MCP_TOOLS = await session.list_tools() + + print("MCP TOOLS from litellm proxy: ", MCP_TOOLS) + messages = [ + { + "role": "user", + "content": "send an email about litellm supporting MCP and send it to krrish@berri.ai", + } + ] + llm_response = await openai_client.chat.completions.create( + model="gpt-4o", + messages=messages, + tools=[ + transform_mcp_tool_to_openai_tool(tool) for tool in MCP_TOOLS.tools + ], + tool_choice="required", + ) + print("LLM RESPONSE: ", json.dumps(llm_response, indent=4, default=str)) + + # Add assertions to verify the response + openai_tool = llm_response.choices[0].message.tool_calls[0] + + # Call the tool using MCP client + mcp_tool_call_request = ( + _transform_openai_tool_call_to_mcp_tool_call_request( + openai_tool.model_dump() + ) + ) + call_result = await session.call_tool( + name=mcp_tool_call_request.name, + arguments=mcp_tool_call_request.arguments, + ) + print("CALL RESULT: ", call_result) + pass From 6c7b42f575e2a69560d61cc2347b1b62aed9c9b7 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 13:35:46 -0700 Subject: [PATCH 05/27] REST API endpoint for MCP --- .../mcp_server/mcp_server_manager.py | 1 + .../proxy/_experimental/mcp_server/server.py | 82 ++++++++++++++++++- litellm/proxy/proxy_config.yaml | 11 +-- .../types/mcp_server/mcp_server_manager.py | 14 +++- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 344fb39f79..aa99a11318 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -50,6 +50,7 @@ class MCPServerManager: MCPSSEServer( name=server_name, url=server_config["url"], + mcp_info=server_config.get("mcp_info", None), ) ) verbose_logger.debug( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index a1de700d50..4fc91342c2 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -6,11 +6,15 @@ import asyncio from typing import Any, Dict, List, Union from anyio import BrokenResourceError -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import ValidationError from litellm._logging import verbose_logger +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.mcp_server.mcp_server_manager import ( + ListMCPToolsRestAPIResponseObject, +) # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8 @@ -53,9 +57,14 @@ if MCP_AVAILABLE: ######################################################## ############### MCP Server Routes ####################### ######################################################## - @server.list_tools() async def list_tools() -> list[MCPTool]: + """ + List all available tools + """ + return await _list_mcp_tools() + + async def _list_mcp_tools() -> List[MCPTool]: """ List all available tools """ @@ -95,6 +104,18 @@ if MCP_AVAILABLE: HTTPException: If tool not found or arguments missing """ # Validate arguments + response = await call_mcp_tool( + name=name, + arguments=arguments, + ) + return response + + async def call_mcp_tool( + name: str, arguments: Dict[str, Any] | None + ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: + """ + Call a specific tool with the provided arguments + """ if arguments is None: raise HTTPException( status_code=400, detail="Request arguments are required" @@ -154,6 +175,63 @@ if MCP_AVAILABLE: await sse.handle_post_message(request.scope, request.receive, request._send) await request.close() + ######################################################## + ############ MCP Server REST API Routes ################# + ######################################################## + @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)]) + async def list_tool_rest_api() -> ( + List[Dict[str, ListMCPToolsRestAPIResponseObject]] + ): + """ + List all available tools with information about the server they belong to. + + Example response: + Tools: + [ + "zapier": { + "tools": [ + { + "name": "create_zap", + "description": "Create a new zap", + "inputSchema": "tool_input_schema", + } + ], + "mcp_info": { + "logo_url": "https://www.zapier.com/logo.png", + } + }, + "fetch": { + "tools": [ + { + "name": "fetch_data", + "description": "Fetch data from a URL", + } + ], + "mcp_info": { + "logo_url": "https://www.fetch.com/logo.png", + } + } + """ + list_tools_result: List[Dict[str, ListMCPToolsRestAPIResponseObject]] = [] + for server in global_mcp_server_manager.mcp_servers: + tools = await global_mcp_server_manager._get_tools_from_server(server) + list_tools_result.append( + { + server.name: ListMCPToolsRestAPIResponseObject( + tools=tools, + mcp_info=server.mcp_info, + ) + } + ) + return list_tools_result + + @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) + async def call_tool_rest_api(name: str, arguments: Dict[str, Any]): + return await call_mcp_tool( + name=name, + arguments=arguments, + ) + options = InitializationOptions( server_name="litellm-mcp-server", server_version="0.1.0", diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 9da28ff51f..bffb39a1b8 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -5,9 +5,10 @@ model_list: mcp_servers: { - "Zapier MCP": { - "url": "os.environ/ZAPIER_MCP_SERVER_URL", - }, + "Zapier MCP": { + "url": "os.environ/ZAPIER_MCP_SERVER_URL", + "mcp_info": { + "logo_url": "https://www.zapier.com/logo.png", + } + } } - -} \ No newline at end of file diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index a106557fb6..0996e62b3a 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -1,6 +1,7 @@ -from typing import Optional +from typing import Any, Dict, List, Optional from mcp import ClientSession +from mcp.types import Tool as MCPTool from pydantic import BaseModel, ConfigDict @@ -8,4 +9,15 @@ class MCPSSEServer(BaseModel): name: str url: str client_session: Optional[ClientSession] = None + mcp_info: Optional[Dict[str, Any]] = None + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class ListMCPToolsRestAPIResponseObject(BaseModel): + """ + Object returned by the /tools/list REST API route. + """ + + tools: List[MCPTool] + mcp_info: Optional[Dict[str, Any]] = None model_config = ConfigDict(arbitrary_types_allowed=True) From 9503a536388fec11074b95619331988c51311f7f Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 14:30:34 -0700 Subject: [PATCH 06/27] basic UI rendering of MCP tools --- ui/litellm-dashboard/src/app/page.tsx | 8 + .../src/components/leftnav.tsx | 4 +- .../src/components/mcp_tools/columns.tsx | 306 ++++++++++++++++++ .../src/components/mcp_tools/types.tsx | 71 ++++ .../src/components/networking.tsx | 70 ++++ 5 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/columns.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/types.tsx diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index f480501b58..a4256b3f4b 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -32,6 +32,8 @@ import GuardrailsPanel from "@/components/guardrails"; import TransformRequestPanel from "@/components/transform_request"; import { fetchUserModels } from "@/components/create_key_button"; import { fetchTeams } from "@/components/common_components/fetch_teams"; +import MCPToolsViewer from "@/components/mcp_tools"; + function getCookie(name: string) { const cookieValue = document.cookie .split("; ") @@ -347,6 +349,12 @@ export default function CreateKeyPage() { accessToken={accessToken} allTeams={teams as Team[] ?? []} /> + ) : page == "mcp-tools" ? ( + ) : page == "new_usage" ? ( , roles: all_admin_roles }, { key: "11", page: "guardrails", label: "Guardrails", icon: , roles: all_admin_roles }, { key: "12", page: "new_usage", label: "New Usage", icon: , roles: all_admin_roles }, + { key: "18", page: "mcp-tools", label: "MCP Tools", icon: , roles: all_admin_roles }, ] }, { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx new file mode 100644 index 0000000000..5e347a7e1e --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { MCPTool, InputSchema } from "./types"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "mcp_info.server_name", + header: "Provider", + cell: ({ row }) => { + const serverName = row.original.mcp_info.server_name; + const logoUrl = row.original.mcp_info.logo_url; + + return ( +
+ {logoUrl && ( + {`${serverName} + )} + {serverName} +
+ ); + }, + }, + { + accessorKey: "name", + header: "Tool Name", + cell: ({ row }) => { + const name = row.getValue("name") as string; + return ( +
+ {name} +
+ ); + }, + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => { + const description = row.getValue("description") as string; + return ( +
+ {description} +
+ ); + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const tool = row.original; + + return ( +
+ +
+ ); + }, + }, +]; + +// Tool Panel component to display when a tool is selected +export function ToolTestPanel({ + tool, + onSubmit, + isLoading, + result, + error, + onClose +}: { + tool: MCPTool; + onSubmit: (args: Record) => void; + isLoading: boolean; + result: any | null; + error: Error | null; + onClose: () => void; +}) { + const [formState, setFormState] = React.useState>({}); + + // Create a placeholder schema if we only have the "tool_input_schema" string + const schema: InputSchema = React.useMemo(() => { + if (typeof tool.inputSchema === 'string') { + // Default schema with a single text field + return { + type: "object", + properties: { + input: { + type: "string", + description: "Input for this tool" + } + }, + required: ["input"] + }; + } + return tool.inputSchema as InputSchema; + }, [tool.inputSchema]); + + const handleInputChange = (key: string, value: any) => { + setFormState(prev => ({ + ...prev, + [key]: value + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formState); + }; + + return ( +
+
+
+

Test Tool: {tool.name}

+

{tool.description}

+

Provider: {tool.mcp_info.server_name}

+
+ +
+ +
+ {/* Form Section */} +
+

Input Parameters

+
+ {typeof tool.inputSchema === 'string' ? ( +
+

This tool uses a dynamic input schema.

+
+ + handleInputChange("input", e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + /> +
+
+ ) : ( + Object.entries(schema.properties).map(([key, prop]) => ( +
+ + {prop.description && ( +

{prop.description}

+ )} + + {/* Render appropriate input based on type */} + {prop.type === "string" && ( + handleInputChange(key, e.target.value)} + required={schema.required?.includes(key)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + /> + )} + + {prop.type === "number" && ( + handleInputChange(key, parseFloat(e.target.value))} + required={schema.required?.includes(key)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + /> + )} + + {prop.type === "boolean" && ( +
+ handleInputChange(key, e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + Enable +
+ )} +
+ )) + )} + +
+ +
+
+
+ + {/* Result Section */} +
+

Result

+ + {isLoading && ( +
+
+
+ )} + + {error && ( +
+

Error

+
{error.message}
+
+ )} + + {result && !isLoading && !error && ( +
+ {result.map((content: any, idx: number) => ( +
+ {content.type === "text" && ( +
+

{content.text}

+
+ )} + + {content.type === "image" && content.url && ( +
+ Tool result +
+ )} + + {content.type === "embedded_resource" && ( +
+

Embedded Resource

+

Type: {content.resource_type}

+ {content.url && ( + + View Resource + + )} +
+ )} +
+ ))} + +
+
+ Raw JSON Response +
+                    {JSON.stringify(result, null, 2)}
+                  
+
+
+
+ )} + + {!result && !isLoading && !error && ( +
+

The result will appear here after you call the tool.

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx new file mode 100644 index 0000000000..7bbb76fa23 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -0,0 +1,71 @@ +// Define the structure for tool input schema properties +export interface InputSchemaProperty { + type: string; + description?: string; + } + + // Define the structure for the input schema of a tool + export interface InputSchema { + type: "object"; + properties: Record; + required?: string[]; + } + + // Define MCP provider info + export interface MCPInfo { + server_name: string; + logo_url?: string; + } + + // Define the structure for a single MCP tool + export interface MCPTool { + name: string; + description: string; + inputSchema: InputSchema | string; // API returns string "tool_input_schema" or the actual schema + mcp_info: MCPInfo; + // Function to select a tool (added in the component) + onToolSelect?: (tool: MCPTool) => void; + } + + // Define the response structure for the listMCPTools endpoint - now a flat array + export type ListMCPToolsResponse = MCPTool[]; + + // Define the argument structure for calling an MCP tool + export interface CallMCPToolArgs { + name: string; + arguments: Record | null; + server_name?: string; // Now using server_name from mcp_info + } + + // Define the possible content types in the response + export interface MCPTextContent { + type: "text"; + text: string; + annotations?: any; + } + + export interface MCPImageContent { + type: "image"; + url?: string; + data?: string; + } + + export interface MCPEmbeddedResource { + type: "embedded_resource"; + resource_type?: string; + url?: string; + data?: any; + } + + // Define the union type for the content array in the response + export type MCPContent = MCPTextContent | MCPImageContent | MCPEmbeddedResource; + + // Define the response structure for the callMCPTool endpoint + export type CallMCPToolResponse = MCPContent[]; + + // Props for the main component + export interface MCPToolsViewerProps { + accessToken: string | null; + userRole: string | null; + userID: string | null; + } \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 83d258e532..57a2dd3331 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4084,3 +4084,73 @@ export const updateInternalUserSettings = async (accessToken: string, settings: throw error; } }; + + +export const listMCPTools = async (accessToken: string) => { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/mcp/tools/list` + : `/mcp/tools/list`; + + console.log("Fetching MCP tools from:", url); + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("Fetched MCP tools:", data); + return data; + } catch (error) { + console.error("Failed to fetch MCP tools:", error); + throw error; + } +}; + + +export const callMCPTool = async (accessToken: string, toolName: string, toolArguments: Record) => { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/mcp/tools/call` + : `/mcp/tools/call`; + + console.log("Calling MCP tool:", toolName, "with arguments:", toolArguments); + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tool_name: toolName, + tool_arguments: toolArguments, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("MCP tool call response:", data); + return data; + } catch (error) { + console.error("Failed to call MCP tool:", error); + throw error; + } +}; \ No newline at end of file From 7d00abc37fdf7f15abc8c1db000e114782489b64 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 14:31:35 -0700 Subject: [PATCH 07/27] endpoints to list and call tools --- .../mcp_server/mcp_server_manager.py | 7 +- .../proxy/_experimental/mcp_server/server.py | 45 +++--- litellm/proxy/proxy_config.yaml | 2 +- .../types/mcp_server/mcp_server_manager.py | 13 +- .../src/components/mcp_tools/index.tsx | 147 ++++++++++++++++++ 5 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/index.tsx diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index aa99a11318..a2b378fe09 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -14,7 +14,7 @@ from mcp.client.sse import sse_client from mcp.types import Tool as MCPTool from litellm._logging import verbose_logger -from litellm.types.mcp_server.mcp_server_manager import MCPSSEServer +from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPSSEServer class MCPServerManager: @@ -46,11 +46,14 @@ class MCPServerManager: Load the MCP Servers from the config """ for server_name, server_config in mcp_servers_config.items(): + _mcp_info: dict = server_config.get("mcp_info", None) or {} + mcp_info = MCPInfo(**_mcp_info) + mcp_info["server_name"] = server_name self.mcp_servers.append( MCPSSEServer( name=server_name, url=server_config["url"], - mcp_info=server_config.get("mcp_info", None), + mcp_info=mcp_info, ) ) verbose_logger.debug( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 4fc91342c2..698a556a01 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -179,50 +179,45 @@ if MCP_AVAILABLE: ############ MCP Server REST API Routes ################# ######################################################## @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)]) - async def list_tool_rest_api() -> ( - List[Dict[str, ListMCPToolsRestAPIResponseObject]] - ): + async def list_tool_rest_api() -> List[ListMCPToolsRestAPIResponseObject]: """ List all available tools with information about the server they belong to. Example response: Tools: [ - "zapier": { - "tools": [ - { - "name": "create_zap", - "description": "Create a new zap", - "inputSchema": "tool_input_schema", - } - ], + { + "name": "create_zap", + "description": "Create a new zap", + "inputSchema": "tool_input_schema", "mcp_info": { + "server_name": "zapier", "logo_url": "https://www.zapier.com/logo.png", } }, - "fetch": { - "tools": [ - { - "name": "fetch_data", - "description": "Fetch data from a URL", - } - ], + { + "name": "fetch_data", + "description": "Fetch data from a URL", + "inputSchema": "tool_input_schema", "mcp_info": { + "server_name": "fetch", "logo_url": "https://www.fetch.com/logo.png", } } + ] """ - list_tools_result: List[Dict[str, ListMCPToolsRestAPIResponseObject]] = [] + list_tools_result: List[ListMCPToolsRestAPIResponseObject] = [] for server in global_mcp_server_manager.mcp_servers: tools = await global_mcp_server_manager._get_tools_from_server(server) - list_tools_result.append( - { - server.name: ListMCPToolsRestAPIResponseObject( - tools=tools, + for tool in tools: + list_tools_result.append( + ListMCPToolsRestAPIResponseObject( + name=tool.name, + description=tool.description, + inputSchema=tool.inputSchema, mcp_info=server.mcp_info, ) - } - ) + ) return list_tools_result @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index bffb39a1b8..3956eab23f 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -8,7 +8,7 @@ mcp_servers: "Zapier MCP": { "url": "os.environ/ZAPIER_MCP_SERVER_URL", "mcp_info": { - "logo_url": "https://www.zapier.com/logo.png", + "logo_url": "https://espysys.com/wp-content/uploads/2024/08/zapier-logo.webp", } } } diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 0996e62b3a..981926df3c 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -3,21 +3,26 @@ from typing import Any, Dict, List, Optional from mcp import ClientSession from mcp.types import Tool as MCPTool from pydantic import BaseModel, ConfigDict +from typing_extensions import TypedDict + + +class MCPInfo(TypedDict, total=False): + server_name: str + logo_url: Optional[str] class MCPSSEServer(BaseModel): name: str url: str client_session: Optional[ClientSession] = None - mcp_info: Optional[Dict[str, Any]] = None + mcp_info: Optional[MCPInfo] = None model_config = ConfigDict(arbitrary_types_allowed=True) -class ListMCPToolsRestAPIResponseObject(BaseModel): +class ListMCPToolsRestAPIResponseObject(MCPTool): """ Object returned by the /tools/list REST API route. """ - tools: List[MCPTool] - mcp_info: Optional[Dict[str, Any]] = None + mcp_info: Optional[MCPInfo] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx new file mode 100644 index 0000000000..9018752705 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { DataTable } from '../view_logs/table'; +import { columns, ToolTestPanel } from './columns'; +import { MCPTool, MCPToolsViewerProps, CallMCPToolResponse } from './types'; +import { listMCPTools, callMCPTool } from '../networking'; + +export default function MCPToolsViewer({ + accessToken, + userRole, + userID, +}: MCPToolsViewerProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedTool, setSelectedTool] = useState(null); + const [toolResult, setToolResult] = useState(null); + const [toolError, setToolError] = useState(null); + + // Query to fetch MCP tools + const { data: mcpTools, isLoading: isLoadingTools } = useQuery({ + queryKey: ['mcpTools'], + queryFn: () => { + if (!accessToken) throw new Error('Access Token required'); + return listMCPTools(accessToken); + }, + enabled: !!accessToken, + }); + + // Mutation for calling a tool + const { mutate: executeTool, isPending: isCallingTool } = useMutation({ + mutationFn: (args: { tool: MCPTool; arguments: Record }) => { + if (!accessToken) throw new Error('Access Token required'); + return callMCPTool( + accessToken, + args.tool.name, + args.arguments + ); + }, + onSuccess: (data) => { + setToolResult(data); + setToolError(null); + }, + onError: (error: Error) => { + setToolError(error); + setToolResult(null); + }, + }); + + // Add onToolSelect handler to each tool + const toolsData = React.useMemo(() => { + if (!mcpTools) return []; + + return mcpTools.map(tool => ({ + ...tool, + onToolSelect: (tool: MCPTool) => { + setSelectedTool(tool); + setToolResult(null); + setToolError(null); + } + })); + }, [mcpTools]); + + // Filter tools based on search term + const filteredTools = React.useMemo(() => { + return toolsData.filter(tool => { + const searchLower = searchTerm.toLowerCase(); + return ( + tool.name.toLowerCase().includes(searchLower) || + tool.description.toLowerCase().includes(searchLower) || + tool.mcp_info.server_name.toLowerCase().includes(searchLower) + ); + }); + }, [toolsData, searchTerm]); + + // Handle tool call submission + const handleToolSubmit = (args: Record) => { + if (!selectedTool) return; + + executeTool({ + tool: selectedTool, + arguments: args, + }); + }; + + if (!accessToken || !userRole || !userID) { + return
Missing required authentication parameters.
; + } + + return ( +
+
+

MCP Tools

+
+ +
+
+
+
+ setSearchTerm(e.target.value)} + /> + + + +
+
+ {filteredTools.length} tool{filteredTools.length !== 1 ? "s" : ""} available +
+
+
+ + +
+ + {/* Tool Test Panel - Show when a tool is selected */} + {selectedTool && ( +
+ setSelectedTool(null)} + /> +
+ )} +
+ ); +} \ No newline at end of file From db026d385c4c03d8e0a858a3a92a7da6565d269e Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 14:42:19 -0700 Subject: [PATCH 08/27] working MCP call tool method --- litellm/proxy/_experimental/mcp_server/server.py | 5 ++++- ui/litellm-dashboard/src/components/networking.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 698a556a01..3e94576efb 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -221,7 +221,10 @@ if MCP_AVAILABLE: return list_tools_result @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) - async def call_tool_rest_api(name: str, arguments: Dict[str, Any]): + async def call_tool_rest_api(request: Request): + data = await request.json() + name = data.get("name") + arguments = data.get("arguments") return await call_mcp_tool( name=name, arguments=arguments, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 57a2dd3331..9190319cc9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4135,8 +4135,8 @@ export const callMCPTool = async (accessToken: string, toolName: string, toolArg "Content-Type": "application/json", }, body: JSON.stringify({ - tool_name: toolName, - tool_arguments: toolArguments, + name: toolName, + arguments: toolArguments, }), }); From f1e1cdf73058a679d8e1155a1d2508f42ef98868 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 15:20:52 -0700 Subject: [PATCH 09/27] working MCP tool call logging --- litellm/litellm_core_utils/litellm_logging.py | 2 +- .../mcp_server/mcp_server_manager.py | 26 +++++++++++++++++ .../proxy/_experimental/mcp_server/server.py | 28 +++++++++++++------ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 3565c4468c..8e83aea69a 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1099,7 +1099,7 @@ class Logging(LiteLLMLoggingBaseClass): standard_built_in_tools_params=self.standard_built_in_tools_params, ) ) - elif isinstance(result, dict): # pass-through endpoints + elif isinstance(result, dict) or isinstance(result, list): ## STANDARDIZED LOGGING PAYLOAD self.model_call_details["standard_logging_object"] = ( get_standard_logging_object_payload( diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index a2b378fe09..c73eabd629 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -6,6 +6,7 @@ This class is responsible for managing MCP SSE clients. This is a Proxy """ +import asyncio import json from typing import Any, Dict, List, Optional @@ -60,6 +61,8 @@ class MCPServerManager: f"Loaded MCP Servers: {json.dumps(self.mcp_servers, indent=4, default=str)}" ) + self.initialize_tool_name_to_mcp_server_name_mapping() + async def list_tools(self) -> List[MCPTool]: """ List all tools available across all MCP Servers. @@ -102,6 +105,29 @@ class MCPServerManager: return tools_result.tools + def initialize_tool_name_to_mcp_server_name_mapping(self): + """ + On startup, initialize the tool name to MCP server name mapping + """ + try: + if asyncio.get_running_loop(): + asyncio.create_task( + self._initialize_tool_name_to_mcp_server_name_mapping() + ) + except RuntimeError as e: # no running event loop + verbose_logger.exception( + f"No running event loop - skipping tool name to MCP server name mapping initialization: {str(e)}" + ) + + async def _initialize_tool_name_to_mcp_server_name_mapping(self): + """ + Call list_tools for each server and update the tool name to MCP server name mapping + """ + for server in self.mcp_servers: + tools = await self._get_tools_from_server(server) + for tool in tools: + self.tool_name_to_mcp_server_name_mapping[tool.name] = server.name + async def call_tool(self, name: str, arguments: Dict[str, Any]): """ Call a tool with the given name and arguments diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 3e94576efb..32c33904a3 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3,7 +3,7 @@ LiteLLM MCP Server Routes """ import asyncio -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union from anyio import BrokenResourceError from fastapi import APIRouter, Depends, HTTPException, Request @@ -11,10 +11,12 @@ from fastapi.responses import StreamingResponse from pydantic import ValidationError from litellm._logging import verbose_logger +from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.mcp_server.mcp_server_manager import ( ListMCPToolsRestAPIResponseObject, ) +from litellm.utils import client # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8 @@ -110,8 +112,9 @@ if MCP_AVAILABLE: ) return response + @client async def call_mcp_tool( - name: str, arguments: Dict[str, Any] | None + name: str, arguments: Optional[Dict[str, Any]] = None, **kwargs: Any ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: """ Call a specific tool with the provided arguments @@ -221,14 +224,23 @@ if MCP_AVAILABLE: return list_tools_result @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) - async def call_tool_rest_api(request: Request): + async def call_tool_rest_api( + request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + REST API to call a specific MCP tool with the provided arguments + """ + from litellm.proxy.proxy_server import add_litellm_data_to_request, proxy_config + data = await request.json() - name = data.get("name") - arguments = data.get("arguments") - return await call_mcp_tool( - name=name, - arguments=arguments, + data = await add_litellm_data_to_request( + data=data, + request=request, + user_api_key_dict=user_api_key_dict, + proxy_config=proxy_config, ) + return await call_mcp_tool(**data) options = InitializationOptions( server_name="litellm-mcp-server", From 2dfd302a821ca640214ee8edc948731ef80e780d Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 15:50:13 -0700 Subject: [PATCH 10/27] log MCP tool call metadata in SLP --- litellm/litellm_core_utils/litellm_logging.py | 5 +++ .../proxy/_experimental/mcp_server/server.py | 35 +++++++++++++++++++ litellm/proxy/_types.py | 2 ++ .../spend_tracking/spend_tracking_utils.py | 10 +++++- litellm/types/utils.py | 28 +++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 8e83aea69a..8dcd732686 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -67,6 +67,7 @@ from litellm.types.utils import ( StandardCallbackDynamicParams, StandardLoggingAdditionalHeaders, StandardLoggingHiddenParams, + StandardLoggingMCPToolCall, StandardLoggingMetadata, StandardLoggingModelCostFailureDebugInformation, StandardLoggingModelInformation, @@ -3114,6 +3115,7 @@ class StandardLoggingPayloadSetup: litellm_params: Optional[dict] = None, prompt_integration: Optional[str] = None, applied_guardrails: Optional[List[str]] = None, + mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall] = None, ) -> StandardLoggingMetadata: """ Clean and filter the metadata dictionary to include only the specified keys in StandardLoggingMetadata. @@ -3160,6 +3162,7 @@ class StandardLoggingPayloadSetup: user_api_key_end_user_id=None, prompt_management_metadata=prompt_management_metadata, applied_guardrails=applied_guardrails, + mcp_tool_call_metadata=mcp_tool_call_metadata, ) if isinstance(metadata, dict): # Filter the metadata dictionary to include only the specified keys @@ -3486,6 +3489,7 @@ def get_standard_logging_object_payload( litellm_params=litellm_params, prompt_integration=kwargs.get("prompt_integration", None), applied_guardrails=kwargs.get("applied_guardrails", None), + mcp_tool_call_metadata=kwargs.get("mcp_tool_call_metadata", None), ) _request_body = proxy_server_request.get("body", {}) @@ -3626,6 +3630,7 @@ def get_standard_logging_metadata( user_api_key_end_user_id=None, prompt_management_metadata=None, applied_guardrails=None, + mcp_tool_call_metadata=None, ) if isinstance(metadata, dict): # Filter the metadata dictionary to include only the specified keys diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 32c33904a3..0a0ec13340 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -11,11 +11,13 @@ from fastapi.responses import StreamingResponse from pydantic import ValidationError from litellm._logging import verbose_logger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.mcp_server.mcp_server_manager import ( ListMCPToolsRestAPIResponseObject, ) +from litellm.types.utils import StandardLoggingMCPToolCall from litellm.utils import client # Check if MCP is available @@ -124,6 +126,20 @@ if MCP_AVAILABLE: status_code=400, detail="Request arguments are required" ) + standard_logging_mcp_tool_call: StandardLoggingMCPToolCall = ( + _get_standard_logging_mcp_tool_call( + name=name, + arguments=arguments, + ) + ) + litellm_logging_obj: Optional[LiteLLMLoggingObj] = kwargs.get( + "litellm_logging_obj", None + ) + if litellm_logging_obj: + litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = ( + standard_logging_mcp_tool_call + ) + # Try managed server tool first if name in global_mcp_server_manager.tool_name_to_mcp_server_name_mapping: return await _handle_managed_mcp_tool(name, arguments) @@ -131,6 +147,25 @@ if MCP_AVAILABLE: # Fall back to local tool registry return await _handle_local_mcp_tool(name, arguments) + def _get_standard_logging_mcp_tool_call( + name: str, + arguments: Dict[str, Any], + ) -> StandardLoggingMCPToolCall: + mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name) + if mcp_server: + mcp_info = mcp_server.mcp_info or {} + return StandardLoggingMCPToolCall( + name=name, + arguments=arguments, + mcp_server_name=mcp_info.get("server_name"), + mcp_server_logo_url=mcp_info.get("logo_url"), + ) + else: + return StandardLoggingMCPToolCall( + name=name, + arguments=arguments, + ) + async def _handle_managed_mcp_tool( name: str, arguments: Dict[str, Any] ) -> List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]]: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e7d6bec004..963d6762bf 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -27,6 +27,7 @@ from litellm.types.utils import ( ModelResponse, ProviderField, StandardCallbackDynamicParams, + StandardLoggingMCPToolCall, StandardLoggingPayloadErrorInformation, StandardLoggingPayloadStatus, StandardPassThroughResponseObject, @@ -1928,6 +1929,7 @@ class SpendLogsMetadata(TypedDict): ] # special param to log k,v pairs to spendlogs for a call requester_ip_address: Optional[str] applied_guardrails: Optional[List[str]] + mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall] status: StandardLoggingPayloadStatus proxy_server_request: Optional[str] batch_models: Optional[List[str]] diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 6e9a088077..096c5191b1 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -13,7 +13,7 @@ from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload from litellm.proxy.utils import PrismaClient, hash_token -from litellm.types.utils import StandardLoggingPayload +from litellm.types.utils import StandardLoggingMCPToolCall, StandardLoggingPayload from litellm.utils import get_end_user_id_for_cost_tracking @@ -38,6 +38,7 @@ def _get_spend_logs_metadata( metadata: Optional[dict], applied_guardrails: Optional[List[str]] = None, batch_models: Optional[List[str]] = None, + mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall] = None, ) -> SpendLogsMetadata: if metadata is None: return SpendLogsMetadata( @@ -55,6 +56,7 @@ def _get_spend_logs_metadata( error_information=None, proxy_server_request=None, batch_models=None, + mcp_tool_call_metadata=None, ) verbose_proxy_logger.debug( "getting payload for SpendLogs, available keys in metadata: " @@ -71,6 +73,7 @@ def _get_spend_logs_metadata( ) clean_metadata["applied_guardrails"] = applied_guardrails clean_metadata["batch_models"] = batch_models + clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata return clean_metadata @@ -200,6 +203,11 @@ def get_logging_payload( # noqa: PLR0915 if standard_logging_payload is not None else None ), + mcp_tool_call_metadata=( + standard_logging_payload["metadata"].get("mcp_tool_call_metadata", None) + if standard_logging_payload is not None + else None + ), ) special_usage_fields = ["completion_tokens", "prompt_tokens", "total_tokens"] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index fe6330f8bd..33522abe06 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1647,6 +1647,33 @@ class StandardLoggingUserAPIKeyMetadata(TypedDict): user_api_key_end_user_id: Optional[str] +class StandardLoggingMCPToolCall(TypedDict, total=False): + name: str + """ + Name of the tool to call + """ + arguments: dict + """ + Arguments to pass to the tool + """ + result: dict + """ + Result of the tool call + """ + + mcp_server_name: Optional[str] + """ + Name of the MCP server that the tool call was made to + """ + + mcp_server_logo_url: Optional[str] + """ + Optional logo URL of the MCP server that the tool call was made to + + (this is to render the logo on the logs page on litellm ui) + """ + + class StandardBuiltInToolsParams(TypedDict, total=False): """ Standard built-in OpenAItools parameters @@ -1677,6 +1704,7 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata): requester_ip_address: Optional[str] requester_metadata: Optional[dict] prompt_management_metadata: Optional[StandardLoggingPromptManagementMetadata] + mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall] applied_guardrails: Optional[List[str]] From 759303687a8fe24e77b540af88544d243ba440f4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 16:01:34 -0700 Subject: [PATCH 11/27] render MCP tools on ui logs page --- litellm/constants.py | 1 + litellm/proxy/_experimental/mcp_server/server.py | 7 +++++++ .../src/components/view_logs/columns.tsx | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index e224b3d33e..d5e0215ebf 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -418,6 +418,7 @@ RESPONSE_FORMAT_TOOL_NAME = "json_tool_call" # default tool name used when conv ########################### Logging Callback Constants ########################### AZURE_STORAGE_MSFT_VERSION = "2019-07-07" +MCP_TOOL_NAME_PREFIX = "mcp_tool" ########################### LiteLLM Proxy Specific Constants ########################### ######################################################################################## diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 0a0ec13340..1469f523b4 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse from pydantic import ValidationError from litellm._logging import verbose_logger +from litellm.constants import MCP_TOOL_NAME_PREFIX from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -139,6 +140,12 @@ if MCP_AVAILABLE: litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = ( standard_logging_mcp_tool_call ) + litellm_logging_obj.model_call_details["model"] = ( + f"{MCP_TOOL_NAME_PREFIX}: {standard_logging_mcp_tool_call.get('name') or ''}" + ) + litellm_logging_obj.model_call_details["custom_llm_provider"] = ( + standard_logging_mcp_tool_call.get("mcp_server_name") + ) # Try managed server tool first if name in global_mcp_server_manager.tool_name_to_mcp_server_name_mapping: diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 98f5bfbc9d..2732fdaa77 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -8,6 +8,19 @@ import { Tooltip } from "antd"; import { TimeCell } from "./time_cell"; import { Button } from "@tremor/react"; +// Helper to get the appropriate logo URL +const getLogoUrl = ( + row: LogEntry, + provider: string +) => { + // Check if mcp_tool_call_metadata exists and contains mcp_server_logo_url + if (row.metadata?.mcp_tool_call_metadata?.mcp_server_logo_url) { + return row.metadata.mcp_tool_call_metadata.mcp_server_logo_url; + } + // Fall back to default provider logo + return provider ? getProviderLogoAndName(provider).logo : ''; +}; + export type LogEntry = { request_id: string; api_key: string; @@ -177,7 +190,7 @@ export const columns: ColumnDef[] = [
{provider && ( { From 6fa5de6353146263cf75e9f2a2411fdee43c3fbe Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 16:23:58 -0700 Subject: [PATCH 12/27] fix showing list of MCP tools --- .../proxy/_experimental/mcp_server/server.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 1469f523b4..4cfed9a603 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -253,16 +253,20 @@ if MCP_AVAILABLE: """ list_tools_result: List[ListMCPToolsRestAPIResponseObject] = [] for server in global_mcp_server_manager.mcp_servers: - tools = await global_mcp_server_manager._get_tools_from_server(server) - for tool in tools: - list_tools_result.append( - ListMCPToolsRestAPIResponseObject( - name=tool.name, - description=tool.description, - inputSchema=tool.inputSchema, - mcp_info=server.mcp_info, + try: + tools = await global_mcp_server_manager._get_tools_from_server(server) + for tool in tools: + list_tools_result.append( + ListMCPToolsRestAPIResponseObject( + name=tool.name, + description=tool.description, + inputSchema=tool.inputSchema, + mcp_info=server.mcp_info, + ) ) - ) + except Exception as e: + verbose_logger.error(f"Error getting tools from {server.name}: {e}") + continue return list_tools_result @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) From 2f8ee94827fb55d0ee3cf340ac8850465e040419 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 16:28:23 -0700 Subject: [PATCH 13/27] rename transform_openai_tool_call_request_to_mcp_tool_call_request --- litellm/experimental_mcp_client/tools.py | 8 +++++--- tests/litellm/experimental_mcp_client/test_tools.py | 6 +++--- tests/pass_through_tests/test_mcp_routes.py | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/litellm/experimental_mcp_client/tools.py b/litellm/experimental_mcp_client/tools.py index f4ebbf4af4..592b0767fd 100644 --- a/litellm/experimental_mcp_client/tools.py +++ b/litellm/experimental_mcp_client/tools.py @@ -76,7 +76,7 @@ def _get_function_arguments(function: FunctionDefinition) -> dict: return arguments if isinstance(arguments, dict) else {} -def _transform_openai_tool_call_to_mcp_tool_call_request( +def transform_openai_tool_call_request_to_mcp_tool_call_request( openai_tool: ChatCompletionMessageToolCall, ) -> MCPCallToolRequestParams: """Convert an OpenAI ChatCompletionMessageToolCall to an MCP CallToolRequestParams.""" @@ -100,8 +100,10 @@ async def call_openai_tool( Returns: The result of the MCP tool call. """ - mcp_tool_call_request_params = _transform_openai_tool_call_to_mcp_tool_call_request( - openai_tool=openai_tool, + mcp_tool_call_request_params = ( + transform_openai_tool_call_request_to_mcp_tool_call_request( + openai_tool=openai_tool, + ) ) return await call_mcp_tool( session=session, diff --git a/tests/litellm/experimental_mcp_client/test_tools.py b/tests/litellm/experimental_mcp_client/test_tools.py index 7089d83217..ec430ecc9b 100644 --- a/tests/litellm/experimental_mcp_client/test_tools.py +++ b/tests/litellm/experimental_mcp_client/test_tools.py @@ -19,11 +19,11 @@ from mcp.types import Tool as MCPTool from litellm.experimental_mcp_client.tools import ( _get_function_arguments, - _transform_openai_tool_call_to_mcp_tool_call_request, call_mcp_tool, call_openai_tool, load_mcp_tools, transform_mcp_tool_to_openai_tool, + transform_openai_tool_call_request_to_mcp_tool_call_request, ) @@ -76,11 +76,11 @@ def test_transform_mcp_tool_to_openai_tool(mock_mcp_tool): } -def test_transform_openai_tool_call_to_mcp_tool_call_request(mock_mcp_tool): +def testtransform_openai_tool_call_request_to_mcp_tool_call_request(mock_mcp_tool): openai_tool = { "function": {"name": "test_tool", "arguments": json.dumps({"test": "value"})} } - mcp_tool_call_request = _transform_openai_tool_call_to_mcp_tool_call_request( + mcp_tool_call_request = transform_openai_tool_call_request_to_mcp_tool_call_request( openai_tool ) assert mcp_tool_call_request.name == "test_tool" diff --git a/tests/pass_through_tests/test_mcp_routes.py b/tests/pass_through_tests/test_mcp_routes.py index d496a430af..3435d53585 100644 --- a/tests/pass_through_tests/test_mcp_routes.py +++ b/tests/pass_through_tests/test_mcp_routes.py @@ -10,7 +10,7 @@ from mcp import ClientSession from mcp.client.sse import sse_client from litellm.experimental_mcp_client.tools import ( transform_mcp_tool_to_openai_tool, - _transform_openai_tool_call_to_mcp_tool_call_request, + transform_openai_tool_call_request_to_mcp_tool_call_request, ) import json @@ -73,7 +73,7 @@ async def test_mcp_routes_with_vertex_ai(): # Call the tool using MCP client mcp_tool_call_request = ( - _transform_openai_tool_call_to_mcp_tool_call_request( + transform_openai_tool_call_request_to_mcp_tool_call_request( openai_tool.model_dump() ) ) From 19aa86168d1af57bd16904f2a18c777a0a73b497 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 16:48:15 -0700 Subject: [PATCH 14/27] fix types on tools.py --- litellm/experimental_mcp_client/tools.py | 4 +- tests/pass_through_tests/test_mcp_routes.py | 101 ++++++++------------ 2 files changed, 40 insertions(+), 65 deletions(-) diff --git a/litellm/experimental_mcp_client/tools.py b/litellm/experimental_mcp_client/tools.py index 592b0767fd..cdc26af4b7 100644 --- a/litellm/experimental_mcp_client/tools.py +++ b/litellm/experimental_mcp_client/tools.py @@ -1,5 +1,5 @@ import json -from typing import List, Literal, Union +from typing import Dict, List, Literal, Union from mcp import ClientSession from mcp.types import CallToolRequestParams as MCPCallToolRequestParams @@ -77,7 +77,7 @@ def _get_function_arguments(function: FunctionDefinition) -> dict: def transform_openai_tool_call_request_to_mcp_tool_call_request( - openai_tool: ChatCompletionMessageToolCall, + openai_tool: Union[ChatCompletionMessageToolCall, Dict], ) -> MCPCallToolRequestParams: """Convert an OpenAI ChatCompletionMessageToolCall to an MCP CallToolRequestParams.""" function = openai_tool["function"] diff --git a/tests/pass_through_tests/test_mcp_routes.py b/tests/pass_through_tests/test_mcp_routes.py index 3435d53585..f7f0a0d17f 100644 --- a/tests/pass_through_tests/test_mcp_routes.py +++ b/tests/pass_through_tests/test_mcp_routes.py @@ -1,85 +1,60 @@ -# Create server parameters for stdio connection import asyncio -import os - -import pytest -from langchain_mcp_adapters.tools import load_mcp_tools -from langchain_openai import ChatOpenAI -from langgraph.prebuilt import create_react_agent +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionUserMessageParam from mcp import ClientSession from mcp.client.sse import sse_client from litellm.experimental_mcp_client.tools import ( transform_mcp_tool_to_openai_tool, transform_openai_tool_call_request_to_mcp_tool_call_request, ) -import json -@pytest.mark.asyncio -async def test_mcp_routes(): - model = ChatOpenAI( - model="gpt-4o", api_key="sk-1234", base_url="http://localhost:4000" - ) +async def main(): + # Initialize clients + client = AsyncOpenAI(api_key="sk-1234", base_url="http://localhost:4000") - async with sse_client(url="http://localhost:4000/mcp/") as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - print("Initializing session") - await session.initialize() - print("Session initialized") - - # Get tools - print("Loading tools") - tools = await load_mcp_tools(session) - print("Tools loaded") - print(tools) - - # Create and run the agent - agent = create_react_agent(model, tools) - agent_response = await agent.ainvoke({"messages": "Send an "}) - print(agent_response) - - -@pytest.mark.asyncio -async def test_mcp_routes_with_vertex_ai(): - # Create and run the agent - from openai import AsyncOpenAI - - openai_client = AsyncOpenAI(api_key="sk-1234", base_url="http://localhost:4000") - async with sse_client(url="http://localhost:4000/mcp/") as (read, write): + # Connect to MCP + async with sse_client("http://localhost:4000/mcp/") as (read, write): async with ClientSession(read, write) as session: await session.initialize() - MCP_TOOLS = await session.list_tools() + mcp_tools = await session.list_tools() + print("List of MCP tools for MCP server:", mcp_tools.tools) - print("MCP TOOLS from litellm proxy: ", MCP_TOOLS) + # Create message messages = [ - { - "role": "user", - "content": "send an email about litellm supporting MCP and send it to krrish@berri.ai", - } + ChatCompletionUserMessageParam( + content="Send an email about LiteLLM supporting MCP", role="user" + ) ] - llm_response = await openai_client.chat.completions.create( + + # Request with tools + response = await client.chat.completions.create( model="gpt-4o", messages=messages, tools=[ - transform_mcp_tool_to_openai_tool(tool) for tool in MCP_TOOLS.tools + transform_mcp_tool_to_openai_tool(tool) for tool in mcp_tools.tools ], - tool_choice="required", + tool_choice="auto", ) - print("LLM RESPONSE: ", json.dumps(llm_response, indent=4, default=str)) - # Add assertions to verify the response - openai_tool = llm_response.choices[0].message.tool_calls[0] + # Handle tool call + if response.choices[0].message.tool_calls: + tool_call = response.choices[0].message.tool_calls[0] + if tool_call: + # Convert format + mcp_call = ( + transform_openai_tool_call_request_to_mcp_tool_call_request( + openai_tool=tool_call.model_dump() + ) + ) - # Call the tool using MCP client - mcp_tool_call_request = ( - transform_openai_tool_call_request_to_mcp_tool_call_request( - openai_tool.model_dump() - ) - ) - call_result = await session.call_tool( - name=mcp_tool_call_request.name, - arguments=mcp_tool_call_request.arguments, - ) - print("CALL RESULT: ", call_result) - pass + # Execute tool + result = await session.call_tool( + name=mcp_call.name, arguments=mcp_call.arguments + ) + + print("Result:", result) + + +# Run it +asyncio.run(main()) From 494140cb064a3159224b4783a6e385b75e9cf57d Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 16:48:38 -0700 Subject: [PATCH 15/27] add code example --- .../src/components/mcp_tools/code-example.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/code-example.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/code-example.tsx b/ui/litellm-dashboard/src/components/mcp_tools/code-example.tsx new file mode 100644 index 0000000000..5e0b170530 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/code-example.tsx @@ -0,0 +1,92 @@ +import React from 'react'; + +const codeString = `import asyncio +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionUserMessageParam +from mcp import ClientSession +from mcp.client.sse import sse_client +from litellm.experimental_mcp_client.tools import ( + transform_mcp_tool_to_openai_tool, + transform_openai_tool_call_request_to_mcp_tool_call_request, +) + +async def main(): + # Initialize clients + client = AsyncOpenAI( + api_key="sk-1234", + base_url="http://localhost:4000" + ) + + # Connect to MCP + async with sse_client("http://localhost:4000/mcp/") as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + mcp_tools = await session.list_tools() + print("List of MCP tools for MCP server:", mcp_tools.tools) + + # Create message + messages = [ + ChatCompletionUserMessageParam( + content="Send an email about LiteLLM supporting MCP", + role="user" + ) + ] + + # Request with tools + response = await client.chat.completions.create( + model="gpt-4o", + messages=messages, + tools=[transform_mcp_tool_to_openai_tool(tool) for tool in mcp_tools.tools], + tool_choice="auto" + ) + + # Handle tool call + if response.choices[0].message.tool_calls: + tool_call = response.choices[0].message.tool_calls[0] + if tool_call: + # Convert format + mcp_call = transform_openai_tool_call_request_to_mcp_tool_call_request( + openai_tool=tool_call.model_dump() + ) + + # Execute tool + result = await session.call_tool( + name=mcp_call.name, + arguments=mcp_call.arguments + ) + + print("Result:", result) + +# Run it +asyncio.run(main())`; + +export const CodeExample: React.FC = () => { + return ( +
+
+

Using MCP Tools

+
+
+
+
+
Python integration
+
+ +
+ +
+
+            {codeString}
+          
+
+
+
+ ); +}; \ No newline at end of file From e3d5cde7dd81852b4a7f1125d456096cb01177cd Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:04:32 -0700 Subject: [PATCH 16/27] ui mcp tools --- .../src/components/mcp_tools/columns.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx index 5e347a7e1e..b99ecbd9f0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx @@ -1,6 +1,7 @@ import React from "react"; import { ColumnDef } from "@tanstack/react-table"; import { MCPTool, InputSchema } from "./types"; +import { Button } from "@tremor/react" export const columns: ColumnDef[] = [ { @@ -56,16 +57,18 @@ export const columns: ColumnDef[] = [ return (
- +
); }, @@ -220,13 +223,13 @@ export function ToolTestPanel({ )}
- +
From c6235a00f90fb608985edf216768612ba8e452d0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:06:36 -0700 Subject: [PATCH 17/27] fix tests for gcs pub sub --- .../gcs_pub_sub_body/spend_logs_payload.json | 2 +- .../gcs_pub_sub_body/standard_logging_payload.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json b/tests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json index a4c0f3f58b..656cb6d589 100644 --- a/tests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json +++ b/tests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json @@ -9,7 +9,7 @@ "model": "gpt-4o", "user": "", "team_id": "", - "metadata": "{\"applied_guardrails\": [], \"batch_models\": null, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}}", + "metadata": "{\"applied_guardrails\": [], \"batch_models\": null, \"mcp_tool_call_metadata\": null, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}}", "cache_key": "Cache OFF", "spend": 0.00022500000000000002, "total_tokens": 30, diff --git a/tests/logging_callback_tests/gcs_pub_sub_body/standard_logging_payload.json b/tests/logging_callback_tests/gcs_pub_sub_body/standard_logging_payload.json index eb57387120..1dc72b704f 100644 --- a/tests/logging_callback_tests/gcs_pub_sub_body/standard_logging_payload.json +++ b/tests/logging_callback_tests/gcs_pub_sub_body/standard_logging_payload.json @@ -25,7 +25,8 @@ "requester_metadata": null, "user_api_key_end_user_id": null, "prompt_management_metadata": null, - "applied_guardrails": [] + "applied_guardrails": [], + "mcp_tool_call_metadata": null }, "cache_key": null, "response_cost": 0.00022500000000000002, From b178e6b8551124421faf2a2b7a47f4940bf8cee5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:07:36 -0700 Subject: [PATCH 18/27] async def test_spend_logs_payload_e2e(self): --- .../proxy/spend_tracking/test_spend_management_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py index c1e94138a3..57706c94f7 100644 --- a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -457,7 +457,7 @@ class TestSpendLogsPayload: "model": "gpt-4o", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', "cache_key": "Cache OFF", "spend": 0.00022500000000000002, "total_tokens": 30, From 85333914775474809c4d10ef993c86683bcc9b11 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:11:46 -0700 Subject: [PATCH 19/27] fix test --- tests/mcp_tests/test_mcp_server.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 17abd1d6a5..cf64d16029 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -7,21 +7,24 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path -from litellm.proxy._experimental.mcp_server.mcp_client_manager import ( +from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, MCPSSEServer, ) -MCP_SERVERS = [ - MCPSSEServer(name="zapier_mcp_server", url=os.environ.get("ZAPIER_MCP_SERVER_URL")), -] - -mcp_server_manager = MCPServerManager(mcp_servers=MCP_SERVERS) +mcp_server_manager = MCPServerManager() @pytest.mark.asyncio async def test_mcp_server_manager(): + mcp_server_manager.load_servers_from_config( + { + "zapier_mcp_server": { + "url": os.environ.get("ZAPIER_MCP_SERVER_URL"), + } + } + ) tools = await mcp_server_manager.list_tools() print("TOOLS FROM MCP SERVER MANAGER== ", tools) @@ -29,9 +32,3 @@ async def test_mcp_server_manager(): name="gmail_send_email", arguments={"body": "Test"} ) print("RESULT FROM CALLING TOOL FROM MCP SERVER MANAGER== ", result) - - -""" -TODO test with multiple MCP servers and calling a specific - -""" From 71e6a81180cf545a134d3b95100a7bb3b3c2e7ab Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:38:24 -0700 Subject: [PATCH 20/27] fix tests --- .../proxy/spend_tracking/test_spend_management_endpoints.py | 2 +- tests/logging_callback_tests/test_otel_logging.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 57706c94f7..9ced59e4dc 100644 --- a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -555,7 +555,7 @@ class TestSpendLogsPayload: "model": "claude-3-7-sonnet-20250219", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', "cache_key": "Cache OFF", "spend": 0.01383, "total_tokens": 2598, diff --git a/tests/logging_callback_tests/test_otel_logging.py b/tests/logging_callback_tests/test_otel_logging.py index aeec20be23..2e102ec46c 100644 --- a/tests/logging_callback_tests/test_otel_logging.py +++ b/tests/logging_callback_tests/test_otel_logging.py @@ -274,6 +274,7 @@ def validate_redacted_message_span_attributes(span): "metadata.user_api_key_end_user_id", "metadata.user_api_key_user_email", "metadata.applied_guardrails", + "metadata.mcp_tool_call_metadata", ] _all_attributes = set( From 55ad7cbda08627cbcf6f804c29bfb28d814a0d3b Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:44:32 -0700 Subject: [PATCH 21/27] fix import errors without mcp --- .../mcp_server/mcp_server_manager.py | 24 +++++++++++-------- .../types/mcp_server/mcp_server_manager.py | 13 ++++++---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index c73eabd629..9eb44f5597 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -8,11 +8,17 @@ This is a Proxy import asyncio import json -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from mcp import ClientSession -from mcp.client.sse import sse_client -from mcp.types import Tool as MCPTool +if TYPE_CHECKING: + from mcp import ClientSession + from mcp.client.sse import sse_client + from mcp.types import Tool as MCPTool +else: + # Provide fallback types for runtime incase `mcp` is not installed + ClientSession = None + MCPTool = object + sse_client = None from litellm._logging import verbose_logger from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPSSEServer @@ -93,10 +99,9 @@ class MCPServerManager: async with sse_client(url=server.url) as (read, write): async with ClientSession(read, write) as session: - server.client_session = session - await server.client_session.initialize() + await session.initialize() - tools_result = await server.client_session.list_tools() + tools_result = await session.list_tools() verbose_logger.debug(f"Tools from {server.name}: {tools_result}") # Update tool to server mapping @@ -137,9 +142,8 @@ class MCPServerManager: raise ValueError(f"Tool {name} not found") async with sse_client(url=mcp_server.url) as (read, write): async with ClientSession(read, write) as session: - mcp_server.client_session = session - await mcp_server.client_session.initialize() - return await mcp_server.client_session.call_tool(name, arguments) + await session.initialize() + return await session.call_tool(name, arguments) def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPSSEServer]: """ diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 981926df3c..9752423e7e 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -1,10 +1,16 @@ -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Optional -from mcp import ClientSession -from mcp.types import Tool as MCPTool from pydantic import BaseModel, ConfigDict from typing_extensions import TypedDict +if TYPE_CHECKING: + from mcp import ClientSession + from mcp.types import Tool as MCPTool +else: + # Provide fallback types for runtime incase `mcp` is not installed + ClientSession = None + MCPTool = object + class MCPInfo(TypedDict, total=False): server_name: str @@ -14,7 +20,6 @@ class MCPInfo(TypedDict, total=False): class MCPSSEServer(BaseModel): name: str url: str - client_session: Optional[ClientSession] = None mcp_info: Optional[MCPInfo] = None model_config = ConfigDict(arbitrary_types_allowed=True) From dcfc1f31a877bb300512d7f67e1601f8de3a69a8 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 17:50:19 -0700 Subject: [PATCH 22/27] fix linting on DataTableWrapper --- .../src/components/mcp_tools/index.tsx | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx index 9018752705..ae3d4fac62 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx @@ -5,6 +5,31 @@ import { columns, ToolTestPanel } from './columns'; import { MCPTool, MCPToolsViewerProps, CallMCPToolResponse } from './types'; import { listMCPTools, callMCPTool } from '../networking'; +// Wrapper to handle the type mismatch between MCPTool and DataTable's expected type +function DataTableWrapper({ + columns, + data, + isLoading, +}: { + columns: any; + data: MCPTool[]; + isLoading: boolean; +}) { + // Create a dummy renderSubComponent and getRowCanExpand function + const renderSubComponent = () =>
; + const getRowCanExpand = () => false; + + return ( + + ); +} + export default function MCPToolsViewer({ accessToken, userRole, @@ -49,7 +74,7 @@ export default function MCPToolsViewer({ const toolsData = React.useMemo(() => { if (!mcpTools) return []; - return mcpTools.map(tool => ({ + return mcpTools.map((tool: MCPTool) => ({ ...tool, onToolSelect: (tool: MCPTool) => { setSelectedTool(tool); @@ -61,7 +86,7 @@ export default function MCPToolsViewer({ // Filter tools based on search term const filteredTools = React.useMemo(() => { - return toolsData.filter(tool => { + return toolsData.filter((tool: MCPTool) => { const searchLower = searchTerm.toLowerCase(); return ( tool.name.toLowerCase().includes(searchLower) || @@ -122,7 +147,7 @@ export default function MCPToolsViewer({
- Date: Sat, 29 Mar 2025 17:55:37 -0700 Subject: [PATCH 23/27] list_tool_rest_api --- litellm/proxy/_experimental/mcp_server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 4cfed9a603..510bd6ab3b 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -224,7 +224,7 @@ if MCP_AVAILABLE: ############ MCP Server REST API Routes ################# ######################################################## @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)]) - async def list_tool_rest_api() -> List[ListMCPToolsRestAPIResponseObject]: + async def list_tool_rest_api(): """ List all available tools with information about the server they belong to. From dde78a8ae92020ecd572461a0bdfae9355218fe6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 18:32:06 -0700 Subject: [PATCH 24/27] fix order of imports --- .../_experimental/mcp_server/mcp_server_manager.py | 14 ++++---------- litellm/proxy/_experimental/mcp_server/server.py | 2 +- litellm/proxy/proxy_server.py | 7 ++++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 9eb44f5597..df9ae0ea57 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -8,17 +8,11 @@ This is a Proxy import asyncio import json -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import Any, Dict, List, Optional -if TYPE_CHECKING: - from mcp import ClientSession - from mcp.client.sse import sse_client - from mcp.types import Tool as MCPTool -else: - # Provide fallback types for runtime incase `mcp` is not installed - ClientSession = None - MCPTool = object - sse_client = None +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.types import Tool as MCPTool from litellm._logging import verbose_logger from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPSSEServer diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 510bd6ab3b..603370711f 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -265,7 +265,7 @@ if MCP_AVAILABLE: ) ) except Exception as e: - verbose_logger.error(f"Error getting tools from {server.name}: {e}") + verbose_logger.exception(f"Error getting tools from {server.name}: {e}") continue return list_tools_result diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1a469d2c27..99d665d94c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -127,9 +127,6 @@ from litellm.litellm_core_utils.core_helpers import ( from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler -from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, -) from litellm.proxy._experimental.mcp_server.server import router as mcp_router from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, @@ -1966,6 +1963,10 @@ class ProxyConfig: mcp_servers_config = config.get("mcp_servers", None) if mcp_servers_config: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + global_mcp_server_manager.load_servers_from_config(mcp_servers_config) ## CREDENTIALS From 51279841d5268aa2bbddb7ba5eb7f22dc313e087 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 18:34:58 -0700 Subject: [PATCH 25/27] test fixes --- .../proxy/spend_tracking/test_spend_management_endpoints.py | 2 +- tests/mcp_tests/test_mcp_server.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 9ced59e4dc..78da1b5dda 100644 --- a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -651,7 +651,7 @@ class TestSpendLogsPayload: "model": "claude-3-7-sonnet-20250219", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', "cache_key": "Cache OFF", "spend": 0.01383, "total_tokens": 2598, diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index cf64d16029..2cf9193871 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -17,6 +17,7 @@ mcp_server_manager = MCPServerManager() @pytest.mark.asyncio +@pytest.mark.skip(reason="Local only test") async def test_mcp_server_manager(): mcp_server_manager.load_servers_from_config( { From 8b55148d65daa301a8ffff8be9775bee1d0b6f8e Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 18:36:13 -0700 Subject: [PATCH 26/27] test fix --- tests/pass_through_tests/test_mcp_routes.py | 69 +++++++-------------- 1 file changed, 22 insertions(+), 47 deletions(-) diff --git a/tests/pass_through_tests/test_mcp_routes.py b/tests/pass_through_tests/test_mcp_routes.py index f7f0a0d17f..687efe6195 100644 --- a/tests/pass_through_tests/test_mcp_routes.py +++ b/tests/pass_through_tests/test_mcp_routes.py @@ -1,60 +1,35 @@ +# Create server parameters for stdio connection import asyncio -from openai import AsyncOpenAI -from openai.types.chat import ChatCompletionUserMessageParam +import os + +from langchain_mcp_adapters.tools import load_mcp_tools +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent from mcp import ClientSession from mcp.client.sse import sse_client -from litellm.experimental_mcp_client.tools import ( - transform_mcp_tool_to_openai_tool, - transform_openai_tool_call_request_to_mcp_tool_call_request, -) async def main(): - # Initialize clients - client = AsyncOpenAI(api_key="sk-1234", base_url="http://localhost:4000") + model = ChatOpenAI(model="gpt-4o", api_key="sk-12") - # Connect to MCP - async with sse_client("http://localhost:4000/mcp/") as (read, write): + async with sse_client(url="http://localhost:4000/mcp/") as (read, write): async with ClientSession(read, write) as session: + # Initialize the connection + print("Initializing session") await session.initialize() - mcp_tools = await session.list_tools() - print("List of MCP tools for MCP server:", mcp_tools.tools) + print("Session initialized") - # Create message - messages = [ - ChatCompletionUserMessageParam( - content="Send an email about LiteLLM supporting MCP", role="user" - ) - ] + # Get tools + print("Loading tools") + tools = await load_mcp_tools(session) + print("Tools loaded") + print(tools) - # Request with tools - response = await client.chat.completions.create( - model="gpt-4o", - messages=messages, - tools=[ - transform_mcp_tool_to_openai_tool(tool) for tool in mcp_tools.tools - ], - tool_choice="auto", - ) - - # Handle tool call - if response.choices[0].message.tool_calls: - tool_call = response.choices[0].message.tool_calls[0] - if tool_call: - # Convert format - mcp_call = ( - transform_openai_tool_call_request_to_mcp_tool_call_request( - openai_tool=tool_call.model_dump() - ) - ) - - # Execute tool - result = await session.call_tool( - name=mcp_call.name, arguments=mcp_call.arguments - ) - - print("Result:", result) + # # Create and run the agent + # agent = create_react_agent(model, tools) + # agent_response = await agent.ainvoke({"messages": "what's (3 + 5) x 12?"}) -# Run it -asyncio.run(main()) +# Run the async function +if __name__ == "__main__": + asyncio.run(main()) From 198fb23d0c33582f6a996f8687b40e81be404d58 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Sat, 29 Mar 2025 18:40:58 -0700 Subject: [PATCH 27/27] fix listing mcp tools --- .../proxy/_experimental/mcp_server/server.py | 21 ++++++++++++++----- .../types/mcp_server/mcp_server_manager.py | 17 --------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 603370711f..fe1eccb048 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -8,16 +8,14 @@ from typing import Any, Dict, List, Optional, Union from anyio import BrokenResourceError from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse -from pydantic import ValidationError +from pydantic import ConfigDict, ValidationError from litellm._logging import verbose_logger from litellm.constants import MCP_TOOL_NAME_PREFIX from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.types.mcp_server.mcp_server_manager import ( - ListMCPToolsRestAPIResponseObject, -) +from litellm.types.mcp_server.mcp_server_manager import MCPInfo from litellm.types.utils import StandardLoggingMCPToolCall from litellm.utils import client @@ -49,6 +47,19 @@ if MCP_AVAILABLE: from .sse_transport import SseServerTransport from .tool_registry import global_mcp_tool_registry + ###################################################### + ############ MCP Tools List REST API Response Object # + # Defined here because we don't want to add `mcp` as a + # required dependency for `litellm` pip package + ###################################################### + class ListMCPToolsRestAPIResponseObject(MCPTool): + """ + Object returned by the /tools/list REST API route. + """ + + mcp_info: Optional[MCPInfo] = None + model_config = ConfigDict(arbitrary_types_allowed=True) + ######################################################## ############ Initialize the MCP Server ################# ######################################################## @@ -224,7 +235,7 @@ if MCP_AVAILABLE: ############ MCP Server REST API Routes ################# ######################################################## @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)]) - async def list_tool_rest_api(): + async def list_tool_rest_api() -> List[ListMCPToolsRestAPIResponseObject]: """ List all available tools with information about the server they belong to. diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 9752423e7e..aecd11aa1a 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -3,14 +3,6 @@ from typing import TYPE_CHECKING, Optional from pydantic import BaseModel, ConfigDict from typing_extensions import TypedDict -if TYPE_CHECKING: - from mcp import ClientSession - from mcp.types import Tool as MCPTool -else: - # Provide fallback types for runtime incase `mcp` is not installed - ClientSession = None - MCPTool = object - class MCPInfo(TypedDict, total=False): server_name: str @@ -22,12 +14,3 @@ class MCPSSEServer(BaseModel): url: str mcp_info: Optional[MCPInfo] = None model_config = ConfigDict(arbitrary_types_allowed=True) - - -class ListMCPToolsRestAPIResponseObject(MCPTool): - """ - Object returned by the /tools/list REST API route. - """ - - mcp_info: Optional[MCPInfo] = None - model_config = ConfigDict(arbitrary_types_allowed=True)