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>
This commit is contained in:
Ishaan Jaff 2026-02-12 15:11:37 -08:00 • committed by GitHub
parent 736daf0a7d
commit 5f40f93846
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 56 additions and 5 deletions

View file

@ -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")
)

View file

@ -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(

View file

@ -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."""