fix(mcp): use global fallback before raising missing per-user env var 412

A variable declared with both global and user scope is covered by the
global value (globals win in the merge), so the tool-call path must
resolve it from the global rather than raising a 412 when the user has
not filled in the per-user value. Restrict the missing-var check to
referenced user vars that lack a global fallback.
This commit is contained in:
mateo-berri 2026-06-04 17:26:07 +00:00
parent 2fb11f18da
commit 2da1e5e6fd
No known key found for this signature in database
2 changed files with 54 additions and 6 deletions

View file

@ -1645,12 +1645,18 @@ class MCPServerManager:
if not global_values and not user_specs:
return static_headers
# Figure out which user-scoped vars are actually referenced.
# Figure out which user-scoped vars are actually referenced. A var that
# also carries a global value is always covered by that global (globals
# win in the merge below), so it can never be genuinely "missing" even if
# the user hasn't filled it in -- only vars without a global fallback do.
referenced = collect_env_var_references(strings=(static_headers or {}).values())
referenced_user_vars = referenced & user_var_names
required_user_vars = {
name for name in referenced_user_vars if name not in global_values
}
user_values: Dict[str, str] = {}
if referenced_user_vars:
if required_user_vars:
try:
user_values = await self._load_user_env_vars(server, user_api_key_auth)
except Exception as exc:
@ -1669,7 +1675,7 @@ class MCPServerManager:
if raise_on_missing:
missing = sorted(
name for name in referenced_user_vars if not user_values.get(name)
name for name in required_user_vars if not user_values.get(name)
)
if missing:
# A cached negative must never produce a 412: cache
@ -1681,9 +1687,7 @@ class MCPServerManager:
server, user_api_key_auth, force_refresh=True
)
missing = sorted(
name
for name in referenced_user_vars
if not user_values.get(name)
name for name in required_user_vars if not user_values.get(name)
)
if missing:
raise MCPMissingUserEnvVarsError(

View file

@ -446,6 +446,50 @@ async def test_resolve_static_headers_stale_user_value_cannot_override_global(
assert headers == {"X-DB-URL": "admin-db/alice"}
@pytest.mark.asyncio
async def test_resolve_static_headers_dual_scope_var_uses_global_without_412(
monkeypatch,
):
"""A var declared with both ``global`` and ``user`` scope is covered by the
global value (globals win in the merge), so the tool-call path must resolve
it from the global instead of raising a 412 when the user hasn't filled it
in. This happens during a global-to-user (or user-to-global) migration."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
manager = MCPServerManager()
server = MCPServer(
server_id="srv-5",
name="srv5",
transport="http",
url="https://example.com",
static_headers={"Authorization": "Bearer ${SHARED_TOKEN}"},
env_vars=[
{"name": "SHARED_TOKEN", "value": "global-secret", "scope": "global"},
{"name": "SHARED_TOKEN", "value": "", "scope": "user"},
],
)
load_calls = []
async def fake_load_user_env_vars(
server, user_api_key_auth, *, force_refresh=False
):
load_calls.append(force_refresh)
return {}
monkeypatch.setattr(manager, "_load_user_env_vars", fake_load_user_env_vars)
headers = await manager._resolve_static_headers_with_env_vars(
server, user_api_key_auth=object()
)
assert headers == {"Authorization": "Bearer global-secret"}
# The global fully covers the reference, so no per-user lookup is needed.
assert load_calls == []
# ── health-check skip for per-user-env-var-backed headers ──────────────────