[MCP Gateway] Allow using stdio MCPs with LiteLLM (#12530)

* update types

* add STDIO to client

* allow using STDIO with MCP manager

* add Stdio-specific fields to schema.prisma

* fixes for MCP mgmt

* fix for adding stdio MCP to DB

* ui - allow adding stdio MCPs

* fix MCP server manager

* docs stdio MCP

* add_stdio_mcp.png

* new stdio tests

* allow adding MCPs through config.yaml

* fix tool test panel

* use TestMCPClient

* ui fixes for testing circle ci mcp
This commit is contained in:
Ishaan Jaff
2025-07-11 20:21:02 -07:00
committed by GitHub
parent db57e765a5
commit d931446a79
16 changed files with 666 additions and 141 deletions
+26 -3
View File
@@ -18,7 +18,7 @@ LiteLLM Proxy provides an MCP Gateway that allows you to use a fixed endpoint fo
| Feature | Description |
|---------|-------------|
| MCP Operations | • List Tools<br/>• Call Tools |
| Supported MCP Transports | • Streamable HTTP<br/>• SSE |
| Supported MCP Transports | • Streamable HTTP<br/>• SSE<br/>• Standard Input/Output (stdio) |
| LiteLLM Permission Management | ✨ Enterprise Only<br/>• By Key<br/>• By Team<br/>• By Organization |
## Adding your MCP
@@ -33,12 +33,22 @@ On this form, you should enter your MCP Server URL and the transport you want to
LiteLLM supports the following MCP transports:
- Streamable HTTP
- SSE (Server-Sent Events)
- Standard Input/Output (stdio)
<Image
img={require('../img/add_mcp.png')}
style={{width: '80%', display: 'block', margin: '0'}}
/>
### Adding a stdio MCP Server
For stdio MCP servers, select "Standard Input/Output (stdio)" as the transport type and provide the stdio configuration in JSON format:
<Image
img={require('../img/add_stdio_mcp.png')}
style={{width: '80%', display: 'block', margin: '0'}}
/>
</TabItem>
<TabItem value="config" label="config.yaml">
@@ -60,6 +70,15 @@ mcp_servers:
zapier_mcp:
url: "https://actions.zapier.com/mcp/sk-akxxxxx/sse"
# Standard Input/Output (stdio) Server - CircleCI Example
circleci_mcp:
transport: "stdio"
command: "npx"
args: ["-y", "@circleci/mcp-server-circleci"]
env:
CIRCLECI_TOKEN: "your-circleci-token"
CIRCLECI_BASE_URL: "https://circleci.com"
# Full configuration with all optional fields
my_http_server:
url: "https://my-mcp-server.com/mcp"
@@ -70,11 +89,15 @@ mcp_servers:
```
**Configuration Options:**
- **Server Name**: Use any descriptive name for your MCP server (e.g., `zapier_mcp`, `deepwiki_mcp`)
- **URL**: The endpoint URL for your MCP server (required)
- **Server Name**: Use any descriptive name for your MCP server (e.g., `zapier_mcp`, `deepwiki_mcp`, `circleci_mcp`)
- **URL**: The endpoint URL for your MCP server (required for HTTP/SSE transports)
- **Transport**: Optional transport type (defaults to `sse`)
- `sse` - SSE (Server-Sent Events) transport
- `http` - Streamable HTTP transport
- `stdio` - Standard Input/Output transport
- **Command**: The command to execute for stdio transport (required for stdio)
- **Args**: Array of arguments to pass to the command (optional for stdio)
- **Env**: Environment variables to set for the stdio process (optional for stdio)
- **Description**: Optional description for the server
- **Auth Type**: Optional authentication type
- **Spec Version**: Optional MCP specification version (defaults to `2025-03-26`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+33 -8
View File
@@ -1,19 +1,26 @@
"""
LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
"""
import asyncio
import base64
from datetime import timedelta
from typing import List, Optional
import asyncio
from mcp import ClientSession
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
from mcp.types import CallToolResult as MCPCallToolResult
from mcp.types import Tool as MCPTool
from litellm.types.mcp import MCPAuth, MCPAuthType, MCPTransport, MCPTransportType
from litellm.types.mcp import (
MCPAuth,
MCPAuthType,
MCPStdioConfig,
MCPTransport,
MCPTransportType,
)
def to_basic_auth(auth_value: str) -> str:
@@ -31,11 +38,12 @@ class MCPClient:
def __init__(
self,
server_url: str,
server_url: str = "",
transport_type: MCPTransportType = MCPTransport.http,
auth_type: MCPAuthType = None,
auth_value: Optional[str] = None,
timeout: float = 60.0,
stdio_config: Optional[MCPStdioConfig] = None,
):
self.server_url: str = server_url
self.transport_type: MCPTransport = transport_type
@@ -48,6 +56,7 @@ class MCPClient:
self._transport = None
self._session_ctx = None
self._task: Optional[asyncio.Task] = None
self.stdio_config: Optional[MCPStdioConfig] = stdio_config
# handle the basic auth value if provided
if auth_value:
@@ -70,10 +79,25 @@ class MCPClient:
if self._session:
return # Already connected
headers = self._get_auth_headers()
try:
if self.transport_type == MCPTransport.sse:
if self.transport_type == MCPTransport.stdio:
# For stdio transport, use stdio_client with command-line parameters
if not self.stdio_config:
raise ValueError("stdio_config is required for stdio transport")
server_params = StdioServerParameters(
command=self.stdio_config.get("command", ""),
args=self.stdio_config.get("args", []),
env=self.stdio_config.get("env", {})
)
self._transport_ctx = stdio_client(server_params)
self._transport = await self._transport_ctx.__aenter__()
self._session_ctx = ClientSession(self._transport[0], self._transport[1])
self._session = await self._session_ctx.__aenter__()
await self._session.initialize()
elif self.transport_type == MCPTransport.sse:
headers = self._get_auth_headers()
self._transport_ctx = sse_client(
url=self.server_url,
timeout=self.timeout,
@@ -83,7 +107,8 @@ class MCPClient:
self._session_ctx = ClientSession(self._transport[0], self._transport[1])
self._session = await self._session_ctx.__aenter__()
await self._session.initialize()
else:
else: # http
headers = self._get_auth_headers()
self._transport_ctx = streamablehttp_client(
url=self.server_url,
timeout=timedelta(seconds=self.timeout),
+50 -51
View File
@@ -1,5 +1,5 @@
import uuid
from typing import Iterable, List, Optional, Set
from typing import Any, Dict, Iterable, List, Optional, Set, Union
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
@@ -13,6 +13,38 @@ from litellm.proxy._types import (
from litellm.proxy.utils import PrismaClient
def _prepare_mcp_server_data(
data: Union[NewMCPServerRequest, UpdateMCPServerRequest]
) -> Dict[str, Any]:
"""
Helper function to prepare MCP server data for database operations.
Handles JSON field serialization for mcp_info and env fields.
Args:
data: NewMCPServerRequest or UpdateMCPServerRequest object
Returns:
Dict with properly serialized JSON fields
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Convert model to dict
data_dict = data.model_dump()
# Handle mcp_info serialization
if data.mcp_info is not None:
data_dict["mcp_info"] = safe_dumps(data.mcp_info)
# Handle env serialization
if data.env is not None:
data_dict["env"] = safe_dumps(data.env)
# mcp_access_groups is already List[str], no serialization needed
return data_dict
async def get_all_mcp_servers(
prisma_client: PrismaClient,
) -> List[LiteLLM_MCPServerTable]:
@@ -215,35 +247,21 @@ async def create_mcp_server(
"""
Create a new mcp server record in the db
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
if data.server_id is None:
data.server_id = str(uuid.uuid4())
# Convert model to dict and handle JSON fields
data_dict = data.model_dump()
# Use helper to prepare data with proper JSON serialization
data_dict = _prepare_mcp_server_data(data)
# Handle mcp_info serialization
mcp_info: Optional[str] = None
if data.mcp_info is not None:
mcp_info = safe_dumps(data.mcp_info)
del data_dict["mcp_info"]
# Handle mcp_access_groups - it's already a List[str], no need to serialize
mcp_access_groups: Optional[list] = None
if data.mcp_access_groups is not None:
mcp_access_groups = data.mcp_access_groups
del data_dict["mcp_access_groups"]
# Add audit fields
data_dict["created_by"] = touched_by
data_dict["updated_by"] = touched_by
mcp_server_record = await prisma_client.db.litellm_mcpservertable.create(
data={
**data_dict,
"created_by": touched_by,
"updated_by": touched_by,
"mcp_info": mcp_info,
"mcp_access_groups": mcp_access_groups,
}
new_mcp_server = await prisma_client.db.litellm_mcpservertable.create(
data=data_dict # type: ignore
)
return mcp_server_record
return new_mcp_server
async def update_mcp_server(
@@ -252,33 +270,14 @@ async def update_mcp_server(
"""
Update a new mcp server record in the db
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Use helper to prepare data with proper JSON serialization
data_dict = _prepare_mcp_server_data(data)
# Convert model to dict and handle JSON fields
data_dict = data.model_dump()
# Handle mcp_info serialization
mcp_info: Optional[str] = None
if data.mcp_info is not None:
mcp_info = safe_dumps(data.mcp_info)
del data_dict["mcp_info"]
# Handle mcp_access_groups - it's already a List[str], no need to serialize
mcp_access_groups: Optional[list] = None
if data.mcp_access_groups is not None:
mcp_access_groups = data.mcp_access_groups
del data_dict["mcp_access_groups"]
# Add audit fields
data_dict["updated_by"] = touched_by
mcp_server_record = await prisma_client.db.litellm_mcpservertable.update(
where={
"server_id": data.server_id,
},
data={
**data_dict,
"created_by": touched_by,
"updated_by": touched_by,
"mcp_info": mcp_info,
"mcp_access_groups": mcp_access_groups,
},
updated_mcp_server = await prisma_client.db.litellm_mcpservertable.update(
where={"server_id": data.server_id}, data=data_dict # type: ignore
)
return mcp_server_record
return updated_mcp_server
@@ -36,9 +36,35 @@ from litellm.proxy._types import (
MCPTransportType,
UserAPIKeyAuth,
)
from litellm.types.mcp import MCPStdioConfig
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
def _deserialize_env_dict(env_data: Any) -> Optional[Dict[str, str]]:
"""
Helper function to deserialize environment dictionary from database storage.
Handles both JSON string and dictionary formats.
Args:
env_data: The environment data from database (could be JSON string or dict)
Returns:
Dict[str, str] or None: Deserialized environment dictionary
"""
if not env_data:
return None
if isinstance(env_data, str):
try:
return json.loads(env_data)
except (json.JSONDecodeError, TypeError):
# If it's not valid JSON, return as-is (shouldn't happen but safety)
return None
else:
# Already a dictionary
return env_data
class MCPServerManager:
def __init__(self):
self.registry: Dict[str, MCPServer] = {}
@@ -88,7 +114,7 @@ class MCPServerManager:
# Generate stable server ID based on parameters
server_id = self._generate_stable_server_id(
server_name=server_name,
url=server_config["url"],
url=server_config.get("url", None) or "",
transport=server_config.get("transport", MCPTransport.http),
spec_version=server_config.get("spec_version", MCPSpecVersion.mar_2025),
auth_type=server_config.get("auth_type", None),
@@ -97,7 +123,10 @@ class MCPServerManager:
new_server = MCPServer(
server_id=server_id,
name=server_name,
url=server_config["url"],
url=server_config.get("url", None) or "",
command=server_config.get("command", None) or "",
args=server_config.get("args", None) or [],
env=server_config.get("env", None) or {},
# TODO: utility fn the default values
transport=server_config.get("transport", MCPTransport.http),
spec_version=server_config.get("spec_version", MCPSpecVersion.mar_2025),
@@ -129,6 +158,10 @@ class MCPServerManager:
def add_update_server(self, mcp_server: LiteLLM_MCPServerTable):
if mcp_server.server_id not in self.get_registry():
_mcp_info: MCPInfo = mcp_server.mcp_info or {}
# Use helper to deserialize environment dictionary
env_dict = _deserialize_env_dict(mcp_server.env)
new_server = MCPServer(
server_id=mcp_server.server_id,
name=mcp_server.alias or mcp_server.server_id,
@@ -141,6 +174,10 @@ class MCPServerManager:
description=mcp_server.description,
mcp_server_cost_info=_mcp_info.get("mcp_server_cost_info", None),
),
# Stdio-specific fields
command=mcp_server.command,
args=mcp_server.args,
env=env_dict,
)
self.registry[mcp_server.server_id] = new_server
verbose_logger.debug(
@@ -227,13 +264,36 @@ class MCPServerManager:
MCPClient: Configured MCP client instance
"""
transport = server.transport or MCPTransport.sse
return MCPClient(
server_url=server.url,
transport_type=transport,
auth_type=server.auth_type,
auth_value=mcp_auth_header or server.authentication_token,
timeout=60.0,
)
# Handle stdio transport
if transport == MCPTransport.stdio:
# For stdio, we need to get the stdio config from the server
stdio_config: Optional[MCPStdioConfig] = None
if server.command and server.args is not None:
stdio_config = MCPStdioConfig(
command=server.command,
args=server.args,
env=server.env or {}
)
return MCPClient(
server_url="", # Not used for stdio
transport_type=transport,
auth_type=server.auth_type,
auth_value=mcp_auth_header or server.authentication_token,
timeout=60.0,
stdio_config=stdio_config,
)
else:
# For HTTP/SSE transports
server_url = server.url or ""
return MCPClient(
server_url=server_url,
transport_type=transport,
auth_type=server.auth_type,
auth_value=mcp_auth_header or server.authentication_token,
timeout=60.0,
)
async def _get_tools_from_server(self, server: MCPServer, mcp_auth_header: Optional[str] = None) -> List[MCPTool]:
"""
+45 -3
View File
@@ -847,9 +847,28 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
transport: MCPTransportType = MCPTransport.sse
spec_version: MCPSpecVersionType = MCPSpecVersion.mar_2025
auth_type: Optional[MCPAuthType] = None
url: str
url: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
# Stdio-specific fields
command: Optional[str] = None
args: Optional[List[str]] = None
env: Optional[Dict[str, str]] = None
@model_validator(mode="before")
@classmethod
def validate_transport_fields(cls, values):
if isinstance(values, dict):
transport = values.get("transport")
if transport == MCPTransport.stdio:
if not values.get("command"):
raise ValueError("command is required for stdio transport")
if not values.get("args"):
raise ValueError("args is required for stdio transport")
elif transport in [MCPTransport.http, MCPTransport.sse]:
if not values.get("url"):
raise ValueError("url is required for HTTP/SSE transport")
return values
@@ -860,9 +879,28 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
transport: MCPTransportType = MCPTransport.sse
spec_version: MCPSpecVersionType = MCPSpecVersion.mar_2025
auth_type: Optional[MCPAuthType] = None
url: str
url: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
# Stdio-specific fields
command: Optional[str] = None
args: Optional[List[str]] = None
env: Optional[Dict[str, str]] = None
@model_validator(mode="before")
@classmethod
def validate_transport_fields(cls, values):
if isinstance(values, dict):
transport = values.get("transport")
if transport == MCPTransport.stdio:
if not values.get("command"):
raise ValueError("command is required for stdio transport")
if not values.get("args"):
raise ValueError("args is required for stdio transport")
elif transport in [MCPTransport.http, MCPTransport.sse]:
if not values.get("url"):
raise ValueError("url is required for HTTP/SSE transport")
return values
@@ -872,7 +910,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
server_id: str
alias: Optional[str] = None
description: Optional[str] = None
url: str
url: Optional[str] = None
transport: MCPTransportType
spec_version: MCPSpecVersionType
auth_type: Optional[MCPAuthType] = None
@@ -883,6 +921,10 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
mcp_access_groups: List[str] = Field(default_factory=list)
mcp_info: Optional[MCPInfo] = None
# Stdio-specific fields
command: Optional[str] = None
args: Optional[List[str]] = None
env: Optional[Dict[str, str]] = None
class NewUserRequestTeam(LiteLLMPydanticObjectBase):
@@ -23,8 +23,8 @@ from fastapi.responses import JSONResponse
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy.auth.model_checks import get_mcp_server_ids
from litellm.proxy._experimental.mcp_server.utils import validate_mcp_server_name
from litellm.proxy.auth.model_checks import get_mcp_server_ids
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
MCP_AVAILABLE: bool = True
@@ -244,6 +244,10 @@ if MCP_AVAILABLE:
created_at=datetime.now(),
updated_at=datetime.now(),
mcp_info=_server_config.mcp_info,
# Stdio-specific fields
command=_server_config.command,
args=_server_config.args,
env=_server_config.env,
)
)
@@ -263,7 +267,11 @@ if MCP_AVAILABLE:
updated_by=server.updated_by,
mcp_access_groups=server.mcp_access_groups if server.mcp_access_groups is not None else [],
mcp_info=server.mcp_info,
teams=cast(List[Dict[str, str | None]], server_to_teams_map.get(server.server_id, []))
teams=cast(List[Dict[str, str | None]], server_to_teams_map.get(server.server_id, [])),
# Stdio-specific fields
command=server.command,
args=server.args,
env=server.env,
)
for server in LIST_MCP_SERVERS
]
+5 -2
View File
@@ -168,7 +168,7 @@ model LiteLLM_MCPServerTable {
server_id String @id @default(uuid())
alias String?
description String?
url String
url String?
transport String @default("sse")
spec_version String @default("2025-03-26")
auth_type String?
@@ -178,7 +178,10 @@ model LiteLLM_MCPServerTable {
updated_by String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
// Stdio-specific fields
command String?
args String[] @default([])
env Json? @default("{}")
}
// Generate Tokens for Proxy
+19 -1
View File
@@ -18,6 +18,7 @@ else:
class MCPTransport(str, enum.Enum):
sse = "sse"
http = "http"
stdio = "stdio"
class MCPSpecVersion(str, enum.Enum):
@@ -32,7 +33,7 @@ class MCPAuth(str, enum.Enum):
# MCP Literals
MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http]
MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http, MCPTransport.stdio]
MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025]
MCPAuthType = Optional[
Literal[MCPAuth.none, MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic]
@@ -52,6 +53,23 @@ class MCPServerCostInfo(TypedDict, total=False):
"""
class MCPStdioConfig(TypedDict, total=False):
command: str
"""
Command to run the MCP server (e.g., 'npx', 'python', 'node')
"""
args: List[str]
"""
Arguments to pass to the command
"""
env: Optional[Dict[str, str]]
"""
Environment variables to set when running the command
"""
class MCPPostCallResponseObject(BaseModel):
"""
Pydantic object used for MCP post_call_hook response
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Dict, List, Optional
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict
@@ -17,10 +17,14 @@ class MCPInfo(TypedDict, total=False):
class MCPServer(BaseModel):
server_id: str
name: str
url: str
url: Optional[str] = None
transport: MCPTransportType
spec_version: MCPSpecVersionType
auth_type: Optional[MCPAuthType] = None
authentication_token: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
# Stdio-specific fields
command: Optional[str] = None
args: Optional[List[str]] = None
env: Optional[Dict[str, str]] = None
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -0,0 +1,79 @@
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Add the parent directory to the path so we can import litellm
sys.path.insert(0, '../../../')
from litellm.experimental_mcp_client.client import MCPClient
from litellm.types.mcp import MCPStdioConfig, MCPTransport
class TestMCPClient:
"""Test MCP Client stdio functionality"""
def test_mcp_client_stdio_init(self):
"""Test MCPClient initialization with stdio config"""
stdio_config = MCPStdioConfig(
command="python",
args=["-m", "my_mcp_server"],
env={"DEBUG": "1"}
)
client = MCPClient(
transport_type=MCPTransport.stdio,
stdio_config=stdio_config
)
assert client.transport_type == MCPTransport.stdio
assert client.stdio_config == stdio_config
assert client.stdio_config["command"] == "python"
assert client.stdio_config["args"] == ["-m", "my_mcp_server"]
@pytest.mark.asyncio
async def test_mcp_client_stdio_connect_error(self):
"""Test MCP client stdio connection error handling"""
# Test missing stdio_config
client = MCPClient(transport_type=MCPTransport.stdio)
with pytest.raises(ValueError, match="stdio_config is required for stdio transport"):
await client.connect()
@pytest.mark.asyncio
@patch('litellm.experimental_mcp_client.client.stdio_client')
@patch('litellm.experimental_mcp_client.client.ClientSession')
async def test_mcp_client_stdio_connect_success(self, mock_session, mock_stdio_client):
"""Test successful stdio connection"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_stdio_client.return_value.__aenter__ = AsyncMock(return_value=mock_transport)
mock_session_instance = MagicMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.initialize = AsyncMock()
mock_session.return_value = mock_session_instance
stdio_config = MCPStdioConfig(
command="python",
args=["-m", "my_mcp_server"],
env={"DEBUG": "1"}
)
client = MCPClient(
transport_type=MCPTransport.stdio,
stdio_config=stdio_config
)
await client.connect()
# Verify stdio_client was called with correct parameters
mock_stdio_client.assert_called_once()
call_args = mock_stdio_client.call_args[0][0]
assert call_args.command == "python"
assert call_args.args == ["-m", "my_mcp_server"]
assert call_args.env == {"DEBUG": "1"}
if __name__ == "__main__":
pytest.main([__file__])
@@ -0,0 +1,94 @@
import sys
from datetime import datetime
from unittest.mock import MagicMock
import pytest
# Add the parent directory to the path so we can import litellm
sys.path.insert(0, '../../../../../')
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
_deserialize_env_dict,
)
from litellm.proxy._types import LiteLLM_MCPServerTable, MCPSpecVersion, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
class TestMCPServerManager:
"""Test MCP Server Manager stdio functionality"""
def test_deserialize_env_dict(self):
"""Test environment dictionary deserialization"""
# Test JSON string
env_json = '{"PATH": "/usr/bin", "DEBUG": "1"}'
result = _deserialize_env_dict(env_json)
assert result == {"PATH": "/usr/bin", "DEBUG": "1"}
# Test already dict
env_dict = {"PATH": "/usr/bin", "DEBUG": "1"}
result = _deserialize_env_dict(env_dict)
assert result == {"PATH": "/usr/bin", "DEBUG": "1"}
# Test invalid JSON
invalid_json = '{"PATH": "/usr/bin", "DEBUG": 1'
result = _deserialize_env_dict(invalid_json)
assert result is None
def test_add_update_server_stdio(self):
"""Test adding stdio MCP server"""
manager = MCPServerManager()
stdio_server = LiteLLM_MCPServerTable(
server_id="stdio-server-1",
alias="test_stdio_server",
description="Test stdio server",
url=None,
transport=MCPTransport.stdio,
spec_version=MCPSpecVersion.mar_2025,
command="python",
args=["-m", "server"],
env={"DEBUG": "1", "TEST": "1"},
created_at=datetime.now(),
updated_at=datetime.now()
)
manager.add_update_server(stdio_server)
# Verify server was added
assert "stdio-server-1" in manager.registry
added_server = manager.registry["stdio-server-1"]
assert added_server.server_id == "stdio-server-1"
assert added_server.name == "test_stdio_server"
assert added_server.transport == MCPTransport.stdio
assert added_server.command == "python"
assert added_server.args == ["-m", "server"]
assert added_server.env == {"DEBUG": "1", "TEST": "1"}
def test_create_mcp_client_stdio(self):
"""Test creating MCP client for stdio transport"""
manager = MCPServerManager()
stdio_server = MCPServer(
server_id="stdio-server-2",
name="test_stdio_server",
url=None,
transport=MCPTransport.stdio,
spec_version=MCPSpecVersion.mar_2025,
command="node",
args=["server.js"],
env={"NODE_ENV": "test"}
)
client = manager._create_mcp_client(stdio_server)
assert client.transport_type == MCPTransport.stdio
assert client.stdio_config is not None
assert client.stdio_config["command"] == "node"
assert client.stdio_config["args"] == ["server.js"]
assert client.stdio_config["env"] == {"NODE_ENV": "test"}
if __name__ == "__main__":
pytest.main([__file__])
@@ -0,0 +1,58 @@
import React from "react";
import { Form, Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
interface StdioConfigurationProps {
isVisible: boolean;
}
const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible }) => {
if (!isVisible) return null;
return (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Stdio Configuration (JSON)
<Tooltip title="Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="stdio_config"
rules={[
{ required: true, message: "Please enter stdio configuration" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch {
return Promise.reject("Please enter valid JSON");
}
},
},
]}
>
<Input.TextArea
placeholder={`{
"mcpServers": {
"circleci-mcp-server": {
"command": "npx",
"args": ["-y", "@circleci/mcp-server-circleci"],
"env": {
"CIRCLECI_TOKEN": "your-circleci-token",
"CIRCLECI_BASE_URL": "https://circleci.com"
}
}
}
}`}
rows={12}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
/>
</Form.Item>
);
};
export default StdioConfiguration;
@@ -4,24 +4,6 @@ import { MCPTool, InputSchema } from "./types";
import { Form, Tooltip, message } from "antd";
import { InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons";
const AuthBanner = ({ needsAuth, authValue }: { needsAuth: boolean; authValue?: string | null }) => {
if (!needsAuth || (needsAuth && authValue)) {
return (
<Callout title="Authentication" color="green" className="mb-3">
This tool does not require authentication or has authentication added.
</Callout>
);
}
if (needsAuth && !authValue) {
return (
<Callout title="Authentication required" color="yellow" className="mb-3">
Please provide authentication details if this tool call requires auth.
</Callout>
);
}
return null;
};
export function ToolTestPanel({
tool,
@@ -65,11 +47,59 @@ export function ToolTestPanel({
return tool.inputSchema as InputSchema;
}, [tool.inputSchema]);
// Check if this is a nested params structure and extract the actual parameters
const actualSchema: InputSchema = React.useMemo(() => {
if (schema.properties && schema.properties.params &&
schema.properties.params.type === "object" &&
schema.properties.params.properties) {
// This is a nested params structure, extract the actual parameters
return {
type: "object",
properties: schema.properties.params.properties,
required: schema.properties.params.required || [],
};
}
return schema;
}, [schema]);
const handleSubmit = (values: Record<string, any>) => {
const start = Date.now();
setStartTime(start);
setDuration(null);
onSubmit(values);
// Convert form values to proper types based on schema
const convertedValues: Record<string, any> = {};
const schemaToUse = actualSchema;
Object.entries(values).forEach(([key, value]) => {
const prop = schemaToUse.properties?.[key];
if (prop && value !== null && value !== undefined && value !== "") {
switch (prop.type) {
case "boolean":
convertedValues[key] = value === "true" || value === true;
break;
case "number":
convertedValues[key] = Number(value);
break;
case "string":
convertedValues[key] = String(value);
break;
default:
convertedValues[key] = value;
}
} else if (value !== null && value !== undefined && value !== "") {
convertedValues[key] = value;
}
});
// If this was a nested params structure, wrap the values back in params
const submitValues = (schema.properties && schema.properties.params &&
schema.properties.params.type === "object" &&
schema.properties.params.properties)
? { params: convertedValues }
: convertedValues;
onSubmit(submitValues);
};
// Track when result changes to calculate duration
@@ -183,9 +213,7 @@ export function ToolTestPanel({
</Button>
</div>
{/* Auth Banner */}
<AuthBanner needsAuth={needsAuth} authValue={authValue} />
{/* Two Column Layout - Always Side by Side */}
<div className="grid grid-cols-2 gap-4 h-full">
{/* Left Column - Input Parameters */}
@@ -219,7 +247,7 @@ export function ToolTestPanel({
/>
</Form.Item>
</div>
) : schema.properties === undefined ? (
) : actualSchema.properties === undefined ? (
<div className="text-center py-6 bg-gray-50 rounded-lg border border-gray-200">
<div className="max-w-sm mx-auto">
<h4 className="text-sm font-medium text-gray-900 mb-1">No Parameters Required</h4>
@@ -228,13 +256,13 @@ export function ToolTestPanel({
</div>
) : (
<div className="space-y-3">
{Object.entries(schema.properties).map(([key, prop]) => (
{Object.entries(actualSchema.properties).map(([key, prop]) => (
<Form.Item
key={key}
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
{key}{" "}
{schema.required?.includes(key) && <span className="text-red-500">*</span>}
{actualSchema.required?.includes(key) && <span className="text-red-500">*</span>}
{prop.description && (
<Tooltip title={prop.description}>
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
@@ -245,13 +273,29 @@ export function ToolTestPanel({
name={key}
rules={[
{
required: schema.required?.includes(key),
required: actualSchema.required?.includes(key),
message: `Please enter ${key}`,
},
]}
className="mb-3"
>
{prop.type === "string" && (
{prop.type === "string" && prop.enum && (
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
defaultValue={prop.default}
>
{!actualSchema.required?.includes(key) && (
<option value="">Select {key}</option>
)}
{prop.enum.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
)}
{prop.type === "string" && !prop.enum && (
<TextInput
placeholder={prop.description || `Enter ${key}`}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
@@ -267,13 +311,16 @@ export function ToolTestPanel({
)}
{prop.type === "boolean" && (
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors"
/>
<span className="text-sm text-gray-700">Enable this option</span>
</div>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
defaultValue={prop.default?.toString() || ""}
>
{!actualSchema.required?.includes(key) && (
<option value="">Select {key}</option>
)}
<option value="true">True</option>
<option value="false">False</option>
</select>
)}
</Form.Item>
))}
@@ -6,15 +6,15 @@ import {
Select,
message,
Button as AntdButton,
Space,
Input,
} from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { MCPServer, MCPServerCostInfo } from "./types";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
import StdioConfiguration from "./StdioConfiguration";
import { isAdminRole } from "@/utils/roles";
@@ -38,6 +38,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [tools, setTools] = useState<any[]>([]);
const [transportType, setTransportType] = useState<string>('sse');
const handleCreate = async (formValues: Record<string, any>) => {
setIsLoading(true);
@@ -46,9 +47,51 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const accessGroups = formValues.mcp_access_groups
// Process stdio configuration if present
let stdioFields = {};
if (formValues.stdio_config && transportType === 'stdio') {
try {
const stdioConfig = JSON.parse(formValues.stdio_config);
// Handle both formats:
// 1. Full mcpServers structure: {"mcpServers": {"server-name": {...}}}
// 2. Direct config: {"command": "...", "args": [...], "env": {...}}
let actualConfig = stdioConfig;
// If it's the full mcpServers structure, extract the first server config
if (stdioConfig.mcpServers && typeof stdioConfig.mcpServers === 'object') {
const serverNames = Object.keys(stdioConfig.mcpServers);
if (serverNames.length > 0) {
const firstServerName = serverNames[0];
actualConfig = stdioConfig.mcpServers[firstServerName];
// If no alias is provided, use the server name from the JSON
if (!formValues.alias) {
formValues.alias = firstServerName.replace(/-/g, '_'); // Replace hyphens with underscores
}
}
}
stdioFields = {
command: actualConfig.command,
args: actualConfig.args,
env: actualConfig.env
};
console.log('Parsed stdio config:', stdioFields);
} catch (error) {
message.error("Invalid JSON in stdio configuration");
return;
}
}
// Prepare the payload with cost configuration
const payload = {
...formValues,
...stdioFields,
// Remove the raw stdio_config field as we've extracted its components
stdio_config: undefined,
mcp_info: {
server_name: formValues.alias || formValues.url,
description: formValues.description,
@@ -89,7 +132,15 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setModalVisible(false);
};
const handleTransportChange = (value: string) => {
setTransportType(value);
// Clear fields that are not relevant for the selected transport
if (value === 'stdio') {
form.setFieldsValue({ url: undefined, auth_type: undefined });
} else {
form.setFieldsValue({ command: undefined, args: undefined, env: undefined });
}
};
// rendering
if (!isAdminRole(userRole)) {
@@ -186,41 +237,48 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
MCP Server URL
Transport Type
</span>
}
name="url"
rules={[
{ required: true, message: "Please enter a server URL" },
{ type: 'url', message: "Please enter a valid URL" }
]}
name="transport"
rules={[{ required: true, message: "Please select a transport type" }]}
>
<TextInput
placeholder="https://your-mcp-server.com"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
<Select
placeholder="Select transport"
className="rounded-lg"
size="large"
onChange={handleTransportChange}
value={transportType}
>
<Select.Option value="http">HTTP</Select.Option>
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
<Select.Option value="stdio">Standard Input/Output (stdio)</Select.Option>
</Select>
</Form.Item>
<div className="grid grid-cols-2 gap-4">
{/* URL field - only show for HTTP and SSE */}
{transportType !== 'stdio' && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Transport Type
MCP Server URL
</span>
}
name="transport"
rules={[{ required: true, message: "Please select a transport type" }]}
name="url"
rules={[
{ required: true, message: "Please enter a server URL" },
{ type: 'url', message: "Please enter a valid URL" }
]}
>
<Select
placeholder="Select transport"
className="rounded-lg"
size="large"
>
<Select.Option value="http">HTTP</Select.Option>
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
</Select>
<TextInput
placeholder="https://your-mcp-server.com"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
{/* Authentication - only show for HTTP and SSE */}
{transportType !== 'stdio' && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
@@ -241,7 +299,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="basic">Basic Auth</Select.Option>
</Select>
</Form.Item>
</div>
)}
{/* Stdio Configuration - only show for stdio transport */}
<StdioConfiguration isVisible={transportType === 'stdio'} />
<Form.Item
label={
@@ -42,6 +42,10 @@ export const mcpServerHasAuth = (authType?: string | null): boolean => {
export interface InputSchemaProperty {
type: string;
description?: string;
properties?: Record<string, InputSchemaProperty>; // For nested object properties
required?: string[]; // For required fields in nested objects
enum?: string[]; // For enum values
default?: any; // For default values
}
// Define the structure for the input schema of a tool