From 5f40f9384626390fcd94ca1371309f18bb79c34a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 12 Feb 2026 15:11:37 -0800 Subject: [PATCH] fix: MCP - inject NPM_CONFIG_CACHE into STDIO MCP subprocess env (#21069) * fix: inject NPM_CONFIG_CACHE into STDIO MCP subprocess env for Docker npm/npx needs a writable cache directory. In containers the default (~/.npm) may not exist or be read-only, causing STDIO MCP servers launched via npx to fail with ENOENT. Inject NPM_CONFIG_CACHE=/tmp/.npm_mcp_cache into the subprocess env when not already set. * test: add unit test for NPM_CONFIG_CACHE injection in STDIO MCP Verifies that NPM_CONFIG_CACHE is auto-injected when not set, and preserved when explicitly provided. Also moves the import to module level per code style rules. * Update litellm/constants.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/constants.py | 5 +++ .../mcp_server/mcp_server_manager.py | 17 ++++++-- .../mcp_server/test_mcp_server_manager.py | 39 ++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 88c57d3ce4c..4e89ddd7bd9 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -101,6 +101,11 @@ MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE = int( MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL = int( os.getenv("MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL", "3600") ) + +# Default npm cache directory for STDIO MCP servers. +# npm/npx needs a writable cache dir; in containers the default (~/.npm) +# may not exist or be read-only. /tmp is always writable. +MCP_NPM_CACHE_DIR = os.getenv("MCP_NPM_CACHE_DIR", "/tmp/.npm_mcp_cache") MCP_OAUTH2_TOKEN_CACHE_MIN_TTL = int( os.getenv("MCP_OAUTH2_TOKEN_CACHE_MIN_TTL", "10") ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 1d7c0eaa116..fbb0603100e 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -15,7 +15,6 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Uni from urllib.parse import urlparse import anyio - from fastapi import HTTPException from httpx import HTTPStatusError from mcp import ReadResourceResult, Resource @@ -32,6 +31,7 @@ from pydantic import AnyUrl import litellm from litellm._logging import verbose_logger +from litellm.constants import MCP_NPM_CACHE_DIR from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException from litellm.experimental_mcp_client.client import MCPClient from litellm.llms.custom_httpx.http_handler import get_async_httpx_client @@ -72,7 +72,9 @@ try: from mcp.shared.tool_name_validation import ( validate_tool_name, # pyright: ignore[reportAssignmentType] ) - from mcp.shared.tool_name_validation import SEP_986_URL + from mcp.shared.tool_name_validation import ( + SEP_986_URL, + ) except ImportError: from pydantic import BaseModel @@ -889,8 +891,15 @@ class MCPServerManager: # Handle stdio transport if transport == MCPTransport.stdio: - # For stdio, we need to get the stdio config from the server - resolved_env = stdio_env if stdio_env is not None else server.env or {} + resolved_env = stdio_env if stdio_env is not None else dict(server.env or {}) + + # Ensure npm-based STDIO MCP servers have a writable cache dir. + # In containers the default (~/.npm or /app/.npm) may not exist + # or be read-only, causing npx to fail with ENOENT. + if "NPM_CONFIG_CACHE" not in resolved_env: + from litellm.constants import MCP_NPM_CACHE_DIR + + resolved_env["NPM_CONFIG_CACHE"] = MCP_NPM_CACHE_DIR stdio_config: Optional[MCPStdioConfig] = None if server.command and server.args is not None: stdio_config = MCPStdioConfig( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index b4b5811666b..1a50cacd308 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -112,7 +112,44 @@ class TestMCPServerManager: 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"} + # NPM_CONFIG_CACHE is injected automatically for container compatibility + from litellm.constants import MCP_NPM_CACHE_DIR + + assert client.stdio_config["env"]["NODE_ENV"] == "test" + assert client.stdio_config["env"]["NPM_CONFIG_CACHE"] == MCP_NPM_CACHE_DIR + + async def test_create_mcp_client_stdio_injects_npm_config_cache(self): + """Test that _create_mcp_client injects NPM_CONFIG_CACHE when not already set, + and preserves user-provided NPM_CONFIG_CACHE when present.""" + from litellm.constants import MCP_NPM_CACHE_DIR + + manager = MCPServerManager() + + # Case 1: NPM_CONFIG_CACHE not set -> should be injected + server_no_cache = MCPServer( + server_id="stdio-npm-1", + name="test_npm_server", + url=None, + transport=MCPTransport.stdio, + command="npx", + args=["-y", "@modelcontextprotocol/server-everything"], + env={}, + ) + client = await manager._create_mcp_client(server_no_cache) + assert client.stdio_config["env"]["NPM_CONFIG_CACHE"] == MCP_NPM_CACHE_DIR + + # Case 2: NPM_CONFIG_CACHE already set -> should NOT be overwritten + server_with_cache = MCPServer( + server_id="stdio-npm-2", + name="test_npm_server_custom", + url=None, + transport=MCPTransport.stdio, + command="npx", + args=["-y", "@modelcontextprotocol/server-everything"], + env={"NPM_CONFIG_CACHE": "/custom/cache"}, + ) + client2 = await manager._create_mcp_client(server_with_cache) + assert client2.stdio_config["env"]["NPM_CONFIG_CACHE"] == "/custom/cache" def test_build_stdio_env_only_accepts_x_prefixed_placeholders(self): """Ensure only ${X-*} placeholders are substituted from headers."""