Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/scripts/run_integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ start_proxy() {
"${database_env[@]}" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True "${cost_map_env[@]}" \
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True LITELLM_ENABLE_MCP_STDIO=true "${cost_map_env[@]}" \
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 COVERAGE_FILE="$coverage_data" \
"${proxy_command[@]}" --config tests/integration/proxy_config.yaml \
--host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \
Expand Down
40 changes: 39 additions & 1 deletion litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@
from litellm.proxy._experimental.mcp_server.sampling_handler import (
MCP_SAMPLING_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.stdio_gate import (
MCP_STDIO_DISABLED_MESSAGE,
is_mcp_stdio_blocked,
is_mcp_stdio_enabled,
warn_if_mcp_stdio_blocked,
)
from litellm.proxy._experimental.mcp_server.utils import (
MCP_TOOL_PREFIX_SEPARATOR,
MCPMissingUserEnvVarsError,
Expand Down Expand Up @@ -2413,6 +2419,7 @@ async def load_servers_from_config(
alias=alias,
server_name=server_name,
)
warn_if_mcp_stdio_blocked(server_name, server_config.get("transport"))

auth_type = server_config.get("auth_type", None)
manual_issuer = _blank_to_none(server_config.get("issuer"))
Expand Down Expand Up @@ -3223,6 +3230,7 @@ async def add_server(self, mcp_server: LiteLLM_MCPServerTable):
# `credentials` field is the only one still encrypted here).
# Re-decrypting plaintext would zero the values, so build with
# env_vars_are_encrypted=False.
self._warn_if_newly_blocked_stdio(mcp_server, None)
new_server: Final = await self.build_mcp_server_from_table(mcp_server, env_vars_are_encrypted=False)
self._assign_unique_short_prefix(new_server)
self._invalidate_discovery_lists(mcp_server.server_id)
Expand Down Expand Up @@ -4198,6 +4206,8 @@ async def _create_mcp_client(

# Handle stdio transport
if transport == MCPTransport.stdio:
if not is_mcp_stdio_enabled():
raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE)
resolved_env: Final = (
stdio_env
if stdio_env is not None
Expand Down Expand Up @@ -4353,6 +4363,9 @@ async def _get_tools_from_server(
global_mcp_tool_registry,
)

if self._skip_blocked_stdio_listing(server, "tool"):
return []

verbose_logger.debug("Connecting to url: %s", server.url)
verbose_logger.info("_get_tools_from_server for %s...", server.name)

Expand Down Expand Up @@ -4531,6 +4544,19 @@ def _discovery_key(
)
return server.server_id, hashlib.sha256(material.encode()).hexdigest()

@staticmethod
def _warn_if_newly_blocked_stdio(row: LiteLLM_MCPServerTable, previous: MCPServer | None) -> None:
if previous is None or previous.transport != row.transport:
warn_if_mcp_stdio_blocked(row.alias or row.server_name, row.transport)

def _skip_blocked_stdio_listing(self, server: MCPServer, listing: str) -> bool:
if not is_mcp_stdio_blocked(server.transport):
return False
verbose_logger.debug(
"Skipping %s listing for MCP server %s: %s", listing, server.name, MCP_STDIO_DISABLED_MESSAGE
)
return True

async def get_prompts_from_server(
self,
server: MCPServer,
Expand All @@ -4541,6 +4567,8 @@ async def get_prompts_from_server(
raw_headers: dict[str, str] | None = None,
client_ip: str | None = None,
) -> list[Prompt]:
if self._skip_blocked_stdio_listing(server, "prompt"):
return []
try:
headers: Final = (
dict(
Expand Down Expand Up @@ -4587,6 +4615,8 @@ async def get_resources_from_server(
raw_headers: dict[str, str] | None = None,
client_ip: str | None = None,
) -> list[Resource]:
if self._skip_blocked_stdio_listing(server, "resource"):
return []
try:
headers: Final = (
dict(
Expand Down Expand Up @@ -4633,6 +4663,8 @@ async def get_resource_templates_from_server(
raw_headers: dict[str, str] | None = None,
client_ip: str | None = None,
) -> list[ResourceTemplate]:
if self._skip_blocked_stdio_listing(server, "resource template"):
return []
try:
headers: Final = (
dict(
Expand Down Expand Up @@ -6149,6 +6181,8 @@ def _candidate_matches_server_name(candidate: MCPServer) -> bool:
mcp_server = fallback
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
if is_mcp_stdio_blocked(mcp_server.transport):
raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE)

if resolved_by_server_name_only and not self.server_exposes_tool(mcp_server, name):
raise ValueError(f"Tool {name} not found")
Expand Down Expand Up @@ -6531,7 +6565,10 @@ def _get_mcp_server_from_tool_name(self, tool_name: str) -> MCPServer | None:
if matched is not None:
matched_prefix, original_tool_name = matched
matched_server: Final = prefix_to_server.get(matched_prefix)
if matched_server is not None and self.server_exposes_tool(matched_server, original_tool_name):
if matched_server is not None and (
self.server_exposes_tool(matched_server, original_tool_name)
or is_mcp_stdio_blocked(matched_server.transport)
):
return matched_server

return None
Expand Down Expand Up @@ -6593,6 +6630,7 @@ async def reload_servers_from_database(self):
alias=getattr(server, "alias", None),
server_name=getattr(server, "server_name", None),
)
self._warn_if_newly_blocked_stdio(server, existing_server)
verbose_logger.debug("Building server from DB: %s (%s)", server.server_id, server.server_name)
# raw_rows come straight from the DB, so their global env var
# values (like credentials) are still encrypted here, unlike the
Expand Down
28 changes: 28 additions & 0 deletions litellm/proxy/_experimental/mcp_server/stdio_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import os
from typing import Final

from litellm._logging import verbose_logger
from litellm.types.mcp import MCPTransport

MCP_STDIO_ENABLED_ENV_VAR: Final = "LITELLM_ENABLE_MCP_STDIO"
MCP_STDIO_DISABLED_MESSAGE: Final = (
f"stdio MCP servers are disabled on this proxy. "
f"Set {MCP_STDIO_ENABLED_ENV_VAR}=true on the proxy and restart to enable them"
)


def is_mcp_stdio_enabled() -> bool:
return os.getenv(MCP_STDIO_ENABLED_ENV_VAR, "").strip().lower() == "true"


def is_mcp_stdio_flag_key(env_var_name: str) -> bool:
return env_var_name.upper() == MCP_STDIO_ENABLED_ENV_VAR


def is_mcp_stdio_blocked(transport: str | None) -> bool:
return transport == MCPTransport.stdio and not is_mcp_stdio_enabled()


def warn_if_mcp_stdio_blocked(server_name: str | None, transport: str | None) -> None:
if is_mcp_stdio_blocked(transport):
verbose_logger.warning("MCP server '%s' will not start: %s", server_name, MCP_STDIO_DISABLED_MESSAGE)
Loading
Loading