diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index bb2c2afdd2a..ca2d6e842ae 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py index d41c20fdade..2dcc8a8390e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py @@ -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 ──────────────────