import importlib
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from typing import Any, Dict, Final, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import HTTPException

from litellm.proxy._experimental.mcp_server.exceptions import (
    MCPServerListError,
    MCPUpstreamAuthError,
)
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ServerListFault

# Add the parent directory to the path so we can import litellm


import httpx
from mcp import ReadResourceResult, Resource
from mcp.types import (
    CallToolResult,
    GetPromptResult,
    Prompt,
    ResourceTemplate,
    TextResourceContents,
)
from mcp.types import Tool as MCPTool

from litellm.constants import MCP_METADATA_TIMEOUT
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
    MCPServerManager,
    _deserialize_json_dict,
    _flow_endpoints_missing,
    _mcp_oauth_discovery_on_startup_enabled,
    _oauth_endpoints_unresolved,
    _deserialize_json_list,
    _normalize_mcp_server_cost_info,
    _obo_retry_applies,
    _resolve_openapi_tool_auth,
    _should_strip_caller_authorization,
)
from litellm.proxy._types import (
    LiteLLM_MCPServerTable,
    LiteLLM_ObjectPermissionTable,
    LitellmUserRoles,
    MCPApprovalStatus,
    MCPEnvVar,
    MCPEnvVarScope,
    MCPTransport,
    UserAPIKeyAuth,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPAuth, MCPAuthType
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer


def _reload_mcp_manager_module():
    utils_module = sys.modules["litellm.proxy._experimental.mcp_server.utils"]
    manager_module = sys.modules["litellm.proxy._experimental.mcp_server.mcp_server_manager"]
    importlib.reload(utils_module)
    reloaded = importlib.reload(manager_module)
    # After reload, server.py still holds a stale reference to the old
    # global_mcp_server_manager. Update it so tests that exercise server.py
    # functions (e.g. _get_tools_from_mcp_servers) use the fresh instance.
    server_module = sys.modules.get("litellm.proxy._experimental.mcp_server.server")
    if server_module is not None and hasattr(server_module, "global_mcp_server_manager"):
        server_module.global_mcp_server_manager = reloaded.global_mcp_server_manager
    return reloaded


@pytest.fixture(autouse=True)
def enable_eager_mcp_oauth_discovery(monkeypatch):
    monkeypatch.setenv("LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP", "1")


class TestMCPServerManager:
    """Test MCP Server Manager stdio functionality"""

    def test_deserialize_json_dict(self):
        """Test environment dictionary deserialization"""
        # Test JSON string
        env_json = '{"PATH": "/usr/bin", "DEBUG": "1"}'
        result = _deserialize_json_dict(env_json)
        assert result == {"PATH": "/usr/bin", "DEBUG": "1"}

        # Test already dict
        env_dict = {"PATH": "/usr/bin", "DEBUG": "1"}
        result = _deserialize_json_dict(env_dict)
        assert result == {"PATH": "/usr/bin", "DEBUG": "1"}

        # Test invalid JSON
        invalid_json = '{"PATH": "/usr/bin", "DEBUG": 1'
        result = _deserialize_json_dict(invalid_json)
        assert result is None

    async def test_add_update_server_stdio(self):
        """Test adding stdio MCP server"""
        manager = MCPServerManager()

        stdio_server = LiteLLM_MCPServerTable(
            server_id="stdio-server-1",
            alias="test_stdio_server",
            description="Test stdio server",
            url=None,
            transport=MCPTransport.stdio,
            command="python",
            args=["-m", "server"],
            env={"DEBUG": "1", "TEST": "1"},
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        await manager.add_server(stdio_server)

        # Verify server was added
        assert "stdio-server-1" in manager.registry
        added_server = manager.registry["stdio-server-1"]

        assert added_server.server_id == "stdio-server-1"
        assert added_server.name == "test_stdio_server"
        assert added_server.transport == MCPTransport.stdio
        assert added_server.command == "python"
        assert added_server.args == ["-m", "server"]
        assert added_server.env == {"DEBUG": "1", "TEST": "1"}

    async def test_create_mcp_client_stdio(self):
        """Test creating MCP client for stdio transport"""
        manager = MCPServerManager()

        stdio_server = MCPServer(
            server_id="stdio-server-2",
            name="test_stdio_server",
            url=None,
            transport=MCPTransport.stdio,
            command="node",
            args=["server.js"],
            env={"NODE_ENV": "test"},
        )

        client = await manager._create_mcp_client(stdio_server)

        assert client.transport_type == MCPTransport.stdio
        assert client.stdio_config is not None
        assert client.stdio_config["command"] == "node"
        assert client.stdio_config["args"] == ["server.js"]
        # 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

    @pytest.mark.asyncio
    async def test_caller_auth_header_cannot_bypass_v2_for_authorization_code(self):
        """A caller-supplied per-request override must not substitute the stored authorization_code
        token: _create_mcp_client keeps the v2 spec and resolves through the injected provider
        rather than deferring to the v1 caller-override path."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
            Ok,
        )
        from litellm.types.mcp import MCPAuth

        calls = []

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                calls.append((subject.subject_id, server.server_id))
                return Ok(StaticHeaderAuth("stored-token"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = MCPServer(
            server_id="authz-srv",
            name="authz",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2,  # oauth2 + no client creds + not delegate -> authorization_code
        )

        client = await manager._create_mcp_client(server, mcp_auth_header="Bearer caller-supplied-token")

        # the v2 resolver ran (the caller override did NOT defer to v1); the stored token wins
        assert calls == [("", "authz-srv")]
        assert client is not None

    @pytest.mark.asyncio
    async def test_caller_auth_header_cannot_bypass_id_jag_exchange(self):
        """A caller-supplied per-request override must not disable the ID-JAG exchange and forward an
        arbitrary bearer upstream: _create_mcp_client keeps the v2 spec and resolves through the
        injected provider rather than deferring to the v1 caller-override path."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
            Ok,
        )
        from litellm.types.mcp import MCPAuth

        calls = []

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                calls.append((subject.subject_id, server.server_id))
                return Ok(StaticHeaderAuth("Bearer minted-id-jag-token"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = MCPServer(
            server_id="id-jag-srv",
            name="id-jag",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2_id_jag,
            client_id="gateway-client",
            client_secret="gateway-secret",
            token_exchange_endpoint="https://org-idp.example/oauth2/token",
            id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token",
        )

        client = await manager._create_mcp_client(
            server,
            mcp_auth_header="Bearer caller-supplied-token",
            subject_token="caller-id-token",
        )

        assert calls == [("", "id-jag-srv")]
        assert client is not None

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "missing_field",
        ["token_exchange_endpoint", "id_jag_resource_token_endpoint", "client_id", "client_secret"],
    )
    async def test_half_configured_id_jag_fails_closed_instead_of_deferring_to_v1(self, missing_field):
        """ID-JAG has no v1 arm, so a half-configured oauth2_id_jag server must not silently fall
        through to resolve_mcp_auth, where a caller x-mcp-* override or the static
        authentication_token would bypass the per-user identity assertion. It must be refused as an
        operator misconfiguration (HTTP 500) before any client is built."""
        from fastapi import HTTPException

        from litellm.types.mcp import MCPAuth

        fields = {
            "client_id": "gateway-client",
            "client_secret": "gateway-secret",
            "token_exchange_endpoint": "https://org-idp.example/oauth2/token",
            "id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token",
        }
        fields.pop(missing_field)
        server = MCPServer(
            server_id="id-jag-srv",
            name="id-jag",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2_id_jag,
            authentication_token="static-server-secret",
            **fields,
        )

        with pytest.raises(HTTPException) as exc_info:
            await MCPServerManager()._create_mcp_client(
                server,
                mcp_auth_header="Bearer caller-supplied-token",
                subject_token="caller-id-token",
            )

        assert exc_info.value.status_code == 500
        assert "oauth2_id_jag" in str(exc_info.value.detail)

    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."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="stdio-server-env",
            name="stdio_env",
            transport=MCPTransport.stdio,
            command="node",
            args=["server.js"],
            env={
                "PASSTHROUGH": "${X-Test-Header}",
                "STATIC": "value",
                "IGNORED": "${Not-Allowed}",
            },
        )

        env = manager._build_stdio_env(
            server,
            raw_headers={
                "x-test-header": "resolved-value",
                "x-not-used": "other",
            },
        )

        assert env == {
            "PASSTHROUGH": "resolved-value",
            "STATIC": "value",
            "IGNORED": "${Not-Allowed}",
        }

    def test_build_stdio_env_missing_header_skips_entry(self):
        """Ensure missing headers drop the placeholder from the resolved env."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="stdio-server-env-miss",
            name="stdio_env_miss",
            transport=MCPTransport.stdio,
            command="node",
            args=["server.js"],
            env={"EXPECTED": "${X-Missing}"},
        )

        env = manager._build_stdio_env(server, raw_headers={})

        # When the header isn't provided, the key is omitted entirely
        assert env == {}

    @pytest.mark.asyncio
    async def test_load_servers_from_config_debug_dump_redacts_secrets(self, caplog):
        """The registry debug dump must not leak long-lived credentials: the ID-JAG signing key,
        client secret, and static token are masked while non-secret fields stay readable."""

        manager = MCPServerManager()
        config = {
            "idjag": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2_id_jag,
                "client_id": "gateway-client",
                "client_secret": "SECRET-CLIENT-SECRET",
                "client_private_key": "-----BEGIN PRIVATE KEY-----SECRET-PEM-----END PRIVATE KEY-----",
                "token_exchange_endpoint": "https://org-idp.example/oauth2/token",
                "id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token",
                "authentication_token": "SECRET-STATIC-TOKEN",
            }
        }

        with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
            await manager.load_servers_from_config(config)

        dump = next(m for m in caplog.messages if "Loaded MCP Servers" in m)
        assert "SECRET-PEM" not in dump
        assert "SECRET-CLIENT-SECRET" not in dump
        assert "SECRET-STATIC-TOKEN" not in dump
        assert "gateway-client" in dump
        assert "https://org-idp.example/oauth2/token" in dump

    async def test_load_servers_from_config_warns_on_invalid_alias(self, caplog):
        """Invalid aliases from config should emit warnings during load."""

        manager = MCPServerManager()
        config = {
            "validserver": {
                "alias": "bad/name",
                "url": "https://example.com",
                "transport": MCPTransport.http,
            }
        }

        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.load_servers_from_config(config)

        assert any("invalid alias 'bad/name'" in message for message in caplog.messages)

    @pytest.mark.asyncio
    async def test_load_servers_from_config_accepts_valid_alias(self, caplog):
        """Valid aliases should be accepted and populate the registry."""

        manager = MCPServerManager()
        config = {
            "validserver": {
                "alias": "friendly_alias",
                "url": "https://example.com",
                "transport": MCPTransport.http,
            }
        }

        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.load_servers_from_config(config)

        # No warnings logged for the valid alias
        assert all("invalid alias" not in message for message in caplog.messages)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.alias == "friendly_alias"
        assert server.server_name == "validserver"

    def _oauth2_config(self, **overrides):
        base = {
            "url": "https://example.com/mcp",
            "transport": MCPTransport.http,
            "auth_type": MCPAuth.oauth2,
            "token_url": "https://idp.example.com/token",
            "client_id": "cid",
            "client_secret": "csec",
        }
        base.update(overrides)
        return {"m2mserver": base}

    @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"])
    def test_mcp_oauth_discovery_on_startup_true_values(self, value):
        with patch.dict(os.environ, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": value}):
            assert _mcp_oauth_discovery_on_startup_enabled() is True

    @pytest.mark.parametrize("value", ["0", "false", "FALSE", "no", "off", "", "invalid"])
    def test_mcp_oauth_discovery_on_startup_non_true_values(self, value):
        with patch.dict(os.environ, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": value}):
            assert _mcp_oauth_discovery_on_startup_enabled() is False

    def test_mcp_oauth_discovery_on_startup_defaults_to_disabled(self):
        with patch.dict(os.environ, {}, clear=True):
            assert _mcp_oauth_discovery_on_startup_enabled() is False

    @pytest.mark.asyncio
    async def test_config_oauth_discovery_warmup_is_non_blocking_and_shared(self):
        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["mcp.read"],
        )
        with patch.dict(os.environ, {}, clear=True):
            manager = MCPServerManager()

        started = asyncio.Event()
        release = asyncio.Event()

        async def discover(_server):
            started.set()
            await release.wait()
            return metadata

        with (
            patch.object(manager, "_discover_oauth_metadata_for_server", side_effect=discover) as discovery,
            patch.object(manager, "initialize_tool_name_to_mcp_server_name_mapping"),
        ):
            load_task = asyncio.create_task(
                manager.load_servers_from_config(
                    self._oauth2_config(
                        oauth2_flow="authorization_code",
                        authorization_url=None,
                        token_url=None,
                    )
                )
            )
            await started.wait()
            assert load_task.done()
            await load_task

            server = next(iter(manager.config_mcp_servers.values()))
            waiters = [asyncio.create_task(manager.ensure_oauth_metadata_discovered(server)) for _ in range(10)]
            await asyncio.sleep(0)
            release.set()
            resolved = await asyncio.gather(*waiters)

        discovery.assert_awaited_once_with(server)
        assert all(result is resolved[0] for result in resolved)
        assert resolved[0].authorization_url == "https://idp.example.com/authorize"
        assert resolved[0].token_url == "https://idp.example.com/token"
        assert resolved[0].scopes == ["mcp.read"]
        assert manager.config_mcp_servers[server.server_id] is resolved[0]
        assert server.authorization_url is None
        assert manager._oauth_discovery_slot(server.server_id) is None

    @pytest.mark.asyncio
    async def test_table_oauth_discovery_can_be_deferred_until_first_use(self):
        row = LiteLLM_MCPServerTable(
            server_id="lazy-db-1",
            alias="lazy_db",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )
        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
        )
        with patch.dict(os.environ, {}, clear=True):
            manager = MCPServerManager()

        discovery = AsyncMock(return_value=metadata)
        with patch.object(manager, "_descovery_metadata", new=discovery):
            server = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        discovery.assert_not_awaited()
        assert manager._oauth_discovery_slot(server.server_id) is not None
        manager.registry[server.server_id] = server

        with patch.object(manager, "_descovery_metadata", new=discovery):
            resolved = await manager.ensure_oauth_metadata_discovered(server)

        discovery.assert_awaited_once()
        assert resolved.authorization_url == "https://idp.example.com/authorize"
        assert resolved.token_url == "https://idp.example.com/token"

    @pytest.mark.asyncio
    async def test_lazy_oauth_discovery_failure_is_shared_and_retries_after_cooldown(self):
        with patch.dict(os.environ, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "false"}):
            manager = MCPServerManager()

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
        )
        discovery = AsyncMock(side_effect=[None, None, None, metadata])
        discovery_clock: Final = MagicMock(return_value=100.0)
        with (
            patch.object(manager, "_discover_oauth_metadata_for_server", new=discovery),
            patch.object(manager, "initialize_tool_name_to_mcp_server_name_mapping"),
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager._oauth_discovery_now",
                new=discovery_clock,
            ),
        ):
            await manager.load_servers_from_config(
                self._oauth2_config(
                    oauth2_flow="authorization_code",
                    authorization_url=None,
                    token_url=None,
                )
            )

            server = next(iter(manager.config_mcp_servers.values()))
            failures: Final = await asyncio.gather(
                *(manager.ensure_oauth_metadata_discovered(server) for _ in range(10)),
                return_exceptions=True,
            )
            cooldown_failures: Final = await asyncio.gather(
                *(manager.ensure_oauth_metadata_discovered(server) for _ in range(10)),
                return_exceptions=True,
            )
            discovery_clock.return_value = 130.0
            resolutions: Final = await asyncio.gather(
                *(manager.ensure_oauth_metadata_discovered(server) for _ in range(10))
            )

        assert discovery.await_count == 4
        assert all(isinstance(failure, HTTPException) and failure.status_code == 503 for failure in failures)
        assert all(isinstance(failure, HTTPException) and failure.status_code == 503 for failure in cooldown_failures)
        assert len({id(resolution) for resolution in resolutions}) == 1
        assert resolutions[0].authorization_url == "https://idp.example.com/authorize"
        assert resolutions[0].token_url == "https://idp.example.com/token"
        assert manager._oauth_discovery_slot(server.server_id) is None

    @pytest.mark.asyncio
    async def test_lazy_oauth_discovery_timeout_is_bounded(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-timeout-1",
            name="lazy_timeout",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)

        async def never_returns(_server):
            await asyncio.Future()

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCP_METADATA_TIMEOUT",
                0.01,
            ),
            patch.object(manager, "_discover_oauth_metadata_for_server", side_effect=never_returns) as discovery,
        ):
            with pytest.raises(HTTPException) as exc:
                await asyncio.wait_for(manager.ensure_oauth_metadata_discovered(server), timeout=0.2)

        assert exc.value.status_code == 503
        assert "timed out" in str(exc.value.detail)
        discovery.assert_awaited_once_with(server)
        assert manager._oauth_discovery_slot(server.server_id) is not None

    @pytest.mark.asyncio
    async def test_cancelling_one_waiter_does_not_cancel_shared_discovery(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-cancel-1",
            name="lazy_cancel",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)
        started = asyncio.Event()
        release = asyncio.Event()
        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
        )

        async def discover(_server):
            started.set()
            await release.wait()
            return metadata

        with patch.object(manager, "_discover_oauth_metadata_for_server", side_effect=discover) as discovery:
            cancelled_waiter = asyncio.create_task(manager.ensure_oauth_metadata_discovered(server))
            successful_waiter = asyncio.create_task(manager.ensure_oauth_metadata_discovered(server))
            await started.wait()
            cancelled_waiter.cancel()
            with pytest.raises(asyncio.CancelledError):
                await cancelled_waiter
            release.set()
            resolved = await successful_waiter

        discovery.assert_awaited_once_with(server)
        assert resolved.authorization_url == "https://idp.example.com/authorize"

    @pytest.mark.asyncio
    async def test_lazy_oauth_discovery_ignores_stale_registration_result(self):
        manager = MCPServerManager()
        old_server = MCPServer(
            server_id="lazy-reload-1",
            name="lazy_reload",
            url="https://old.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
        )
        replacement = old_server.model_copy(update={"url": "https://new.example.com/mcp"})
        manager.registry[old_server.server_id] = old_server
        manager._set_oauth_discovery_deferred(old_server.server_id, True)
        started = asyncio.Event()
        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
        )

        async def discover(candidate):
            if candidate.url == old_server.url:
                started.set()
                await asyncio.Future()
            return metadata

        with patch.object(manager, "_discover_oauth_metadata_for_server", side_effect=discover):
            old_attempt = asyncio.create_task(manager.ensure_oauth_metadata_discovered(old_server))
            await started.wait()
            manager.registry[replacement.server_id] = replacement
            manager._set_oauth_discovery_deferred(replacement.server_id, True)
            resolved = await old_attempt

        assert resolved is manager.registry[replacement.server_id]
        assert resolved.url == replacement.url
        assert old_server.authorization_url is None
        assert old_server.token_url is None
        assert replacement.authorization_url is None
        assert replacement.token_url is None
        assert resolved.authorization_url == "https://idp.example.com/authorize"
        assert resolved.token_url == "https://idp.example.com/token"
        assert manager._oauth_discovery_slot(replacement.server_id) is None

    def test_registry_swap_reconcile_keeps_slot_for_issuer_anchored_server_without_url(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="anchored-no-url-1",
            name="anchored_no_url",
            url=None,
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            issuer="https://idp.example.com",
            issuer_is_anchored=True,
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)

        manager._reconcile_oauth_discovery_slots_for_servers([server])

        assert manager._oauth_discovery_slot(server.server_id) is not None

        resolved = server.model_copy(
            update={
                "authorization_url": "https://idp.example.com/authorize",
                "token_url": "https://idp.example.com/token",
            }
        )
        manager.registry[resolved.server_id] = resolved
        manager._reconcile_oauth_discovery_slots_for_servers([resolved])

        assert manager._oauth_discovery_slot(server.server_id) is None

    def _assert_oauth_discovery_state_removed(self, manager, server_id):
        assert manager._oauth_discovery_slot(server_id) is None

    @pytest.mark.asyncio
    async def test_deactivated_server_clears_lazy_oauth_discovery_state(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-deactivated-1",
            name="lazy_deactivated",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)
        record = LiteLLM_MCPServerTable(
            server_id=server.server_id,
            server_name=server.name,
            url=server.url,
            transport=MCPTransport.http,
            approval_status="rejected",
        )

        await manager.update_server(record)

        assert manager.registry == {}
        self._assert_oauth_discovery_state_removed(manager, server.server_id)

    @pytest.mark.asyncio
    async def test_database_reload_drop_clears_lazy_oauth_discovery_state(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-dropped-1",
            name="lazy_dropped",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)
        repository = MagicMock()
        repository.table.find_many = AsyncMock(return_value=[])

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository",
                return_value=repository,
            ),
            patch(
                "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
                return_value=MagicMock(),
            ),
        ):
            await manager.reload_servers_from_database()

        assert manager.registry == {}
        self._assert_oauth_discovery_state_removed(manager, server.server_id)

    @pytest.mark.asyncio
    async def test_database_reload_rearms_discovery_lost_to_registry_swap(self):
        """A resolution published into the old registry while reload is staged
        must leave the swapped-in unresolved entry with a fresh retry slot.
        """
        manager = MCPServerManager()
        stamp = datetime.now()
        server = MCPServer(
            server_id="lazy-swap-1",
            name="lazy_swap",
            server_name="lazy_swap",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            updated_at=stamp,
        )
        manager.registry[server.server_id] = server
        previous_registry = manager.registry
        manager._set_oauth_discovery_deferred(server.server_id, True)
        old_generation = manager._oauth_discovery_slot(server.server_id).generation
        resolved = server.model_copy(
            update={
                "authorization_url": "https://idp.example.com/authorize",
                "token_url": "https://idp.example.com/token",
            }
        )
        row = LiteLLM_MCPServerTable(
            server_id=server.server_id,
            server_name=server.server_name,
            url=server.url,
            transport=server.transport,
            auth_type=server.auth_type,
            oauth2_flow=server.oauth2_flow,
            updated_at=stamp,
        )
        raw_row = MagicMock()
        raw_row.model_dump.return_value = row.model_dump()
        repository = MagicMock()
        repository.table.find_many = AsyncMock(return_value=[raw_row])

        async def publish_while_staged(*_args, **_kwargs):
            assert manager.registry is previous_registry
            assert manager._publish_resolved_oauth_server(resolved, old_generation) is resolved

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository",
                return_value=repository,
            ),
            patch(
                "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
                return_value=MagicMock(),
            ),
            patch.object(
                manager,
                "_maybe_register_openapi_tools",
                new=AsyncMock(side_effect=publish_while_staged),
            ),
            patch.object(manager, "_prime_oauth_metadata_discovery_for_servers"),
        ):
            await manager.reload_servers_from_database()

        assert previous_registry[server.server_id] is resolved
        assert manager.registry[server.server_id] is server
        retry_slot = manager._oauth_discovery_slot(server.server_id)
        assert retry_slot is not None
        assert retry_slot.generation > old_generation

    @pytest.mark.asyncio
    async def test_lazy_oauth_discovery_preserves_manual_authorization_url_gate(self):
        with patch.dict(os.environ, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "false"}):
            manager = MCPServerManager()

        metadata = MCPOAuthMetadata(
            authorization_url="https://attacker.example.com/authorize",
            token_url="https://attacker.example.com/token",
            scopes=["mcp.read"],
        )
        discovery = AsyncMock(return_value=metadata)
        with (
            patch.object(manager, "_descovery_metadata", new=discovery),
            patch.object(manager, "initialize_tool_name_to_mcp_server_name_mapping"),
        ):
            await manager.load_servers_from_config(
                self._oauth2_config(
                    oauth2_flow="authorization_code",
                    authorization_url="https://idp.example.com/authorize",
                    token_url=None,
                )
            )

        server = next(iter(manager.config_mcp_servers.values()))
        with (
            patch.object(manager, "_descovery_metadata", new=discovery),
            pytest.raises(HTTPException) as exc,
        ):
            await manager.ensure_oauth_metadata_discovered(server)

        assert exc.value.status_code == 503
        assert manager.config_mcp_servers[server.server_id].authorization_url == "https://idp.example.com/authorize"
        assert manager.config_mcp_servers[server.server_id].token_url is None
        assert manager.config_mcp_servers[server.server_id].scopes is None
        assert manager._oauth_discovery_slot(server.server_id) is not None

    @pytest.mark.asyncio
    async def test_create_mcp_client_triggers_deferred_oauth_discovery(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-client-1",
            name="lazy_client",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
        )
        ensure_oauth_metadata_discovered: Final = AsyncMock(return_value=server)

        with (
            patch.object(
                manager,
                "ensure_oauth_metadata_discovered",
                new=ensure_oauth_metadata_discovered,
            ),
            patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient"),
        ):
            await manager._create_mcp_client(server)

        ensure_oauth_metadata_discovered.assert_awaited_once_with(server)

    @pytest.mark.asyncio
    async def test_startup_tool_mapping_skips_servers_with_deferred_discovery(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="lazy-map-1",
            name="lazy_map",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        manager.registry[server.server_id] = server
        manager._set_oauth_discovery_deferred(server.server_id, True)

        with patch.object(manager, "_get_tools_from_server", new=AsyncMock()) as get_tools:
            await manager._initialize_tool_name_to_mcp_server_name_mapping()

        get_tools.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_load_servers_from_config_requires_oauth2_flow(self):
        """auth_type oauth2 without an explicit oauth2_flow is a config error: the
        credential shape is ambiguous (a DCR interactive server looks identical to M2M),
        so the config must assert the flow instead of the proxy guessing it."""

        manager = MCPServerManager()

        with (
            patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
            pytest.raises(ValueError) as exc_info,
        ):
            await manager.load_servers_from_config(self._oauth2_config())

        assert "oauth2_flow: client_credentials" in str(exc_info.value)
        assert "oauth2_flow: authorization_code" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_load_servers_from_config_rejects_unknown_oauth2_flow(self):
        manager = MCPServerManager()

        with (
            patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
            pytest.raises(ValueError) as exc_info,
        ):
            await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="m2m"))

        assert "got 'm2m'" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_load_servers_from_config_accepts_explicit_client_credentials(self):
        manager = MCPServerManager()

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="client_credentials"))

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.oauth2_flow == "client_credentials"
        assert server.has_client_credentials is True

    @pytest.mark.asyncio
    async def test_load_servers_from_config_accepts_explicit_authorization_code(self):
        manager = MCPServerManager()

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="authorization_code"))

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.oauth2_flow == "authorization_code"
        assert server.needs_user_oauth_token is True

    @pytest.mark.asyncio
    async def test_load_servers_from_config_keeps_configured_endpoints_for_management_view(self):
        """A yaml server with a pinned issuer still reports its configured endpoints to the management
        view, even though the runtime fields are empty because the anchored issuer is the sole endpoint
        source. The dashboard edits that view, so emptied values there load as blank fields and the next
        save writes the blanks over the config."""
        manager = MCPServerManager()

        config = self._oauth2_config(
            oauth2_flow="authorization_code",
            issuer="https://idp.example.com",
            authorization_url="https://example.com/oauth/authorize",
            token_url="https://example.com/oauth/token",
            registration_url="https://example.com/oauth/register",
        )
        with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.authorization_url is None
        assert server.token_url is None
        assert server.registration_url is None

        view = manager._build_mcp_server_table(server)

        assert view.authorization_url == "https://example.com/oauth/authorize"
        assert view.token_url == "https://example.com/oauth/token"
        assert view.registration_url == "https://example.com/oauth/register"

    @pytest.mark.asyncio
    async def test_load_servers_from_config_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(self):
        """A yaml server with a manual authorization_url has the same config-time mix-up exposure as a
        DB row: a document advertising a different authorize endpoint has its token_url rejected. The
        resource-driven scopes are kept, because scope selection is resource-driven (MCP Scope
        Selection Strategy) and scope inflation is bounded by the authorization server at consent, not
        by dropping scopes when an endpoint mismatches."""
        manager = MCPServerManager()

        metadata = MCPOAuthMetadata(
            authorization_url="https://attacker.example.com/authorize",
            token_url="https://attacker.example.com/token",
            scopes=["read", "admin"],
        )
        config = self._oauth2_config(
            oauth2_flow="authorization_code",
            authorization_url="https://idp.example.com/authorize",
            token_url=None,
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.authorization_url == "https://idp.example.com/authorize"
        assert server.token_url is None
        assert server.scopes == ["read", "admin"]

    @pytest.mark.asyncio
    async def test_load_servers_from_config_fills_token_url_when_metadata_corroborates_manual_authorization_url(self):
        """Corroborated metadata keeps the self-heal on the config path: when the discovered document
        advertises the same authorize endpoint the admin pinned, its token_url fills the blank field
        and scopes come through resource-driven (the discovered document's resource-preferred scopes),
        not the authorization server's own capability list."""
        manager = MCPServerManager()

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read", "admin"],
        )
        config = self._oauth2_config(
            oauth2_flow="authorization_code",
            authorization_url="https://idp.example.com/authorize/",
            token_url=None,
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.token_url == "https://idp.example.com/token"
        assert server.scopes == ["read", "admin"]

    @pytest.mark.asyncio
    @pytest.mark.parametrize("blank_authorization_url", ["", "   "])
    async def test_load_servers_from_config_blank_authorization_url_is_not_a_pin(self, blank_authorization_url):
        """A blank authorization_url — empty or whitespace-only — is not a trust anchor, so discovery
        backfills the whole set (authorize endpoint, token_url, and its resource-preferred scopes)
        from the same chain, exactly as if the field had been omitted. The merge and the corroboration
        gate must agree that blank means unpinned; a whitespace value that the merge kept for redirects
        while the gate treated as unpinned would strand a broken half-discovered config."""
        manager = MCPServerManager()

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read"],
        )
        config = self._oauth2_config(
            oauth2_flow="authorization_code",
            authorization_url=blank_authorization_url,
            token_url=None,
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.authorization_url == "https://idp.example.com/authorize"
        assert server.token_url == "https://idp.example.com/token"
        assert server.scopes == ["read"]

    @pytest.mark.asyncio
    async def test_load_servers_from_config_non_oauth2_needs_no_flow(self):
        manager = MCPServerManager()
        config = {
            "apiserver": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.api_key,
                "auth_value": "sk-upstream",
            }
        }

        await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.oauth2_flow is None

    def _client_forwarded_config(self, auth_type, **overrides):
        base = {
            "url": "https://example.com/mcp",
            "transport": MCPTransport.http,
            "auth_type": auth_type,
        }
        base.update(overrides)
        return {"bridgeserver": base}

    @pytest.mark.asyncio
    async def test_load_servers_from_config_rejects_dcr_bridge_on_gateway_managed_auth_type(self):
        manager = MCPServerManager()

        with (
            patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
            pytest.raises(ValueError) as exc_info,
        ):
            await manager.load_servers_from_config(
                self._oauth2_config(oauth2_flow="authorization_code", dcr_bridge=True)
            )

        assert "dcr_bridge is only supported" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_load_servers_from_config_rejects_non_boolean_dcr_bridge(self):
        manager = MCPServerManager()

        with (
            patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)),
            pytest.raises(ValueError) as exc_info,
        ):
            await manager.load_servers_from_config(
                self._client_forwarded_config(MCPAuth.true_passthrough, dcr_bridge="yes")
            )

        assert "must be a boolean" in str(exc_info.value)

    @pytest.mark.asyncio
    @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
    async def test_load_servers_from_config_accepts_dcr_bridge_on_client_forwarded_modes(self, auth_type):
        manager = MCPServerManager()

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(self._client_forwarded_config(auth_type, dcr_bridge=True))

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.dcr_bridge is True
        assert server.is_dcr_bridge is True

    @pytest.mark.asyncio
    async def test_load_servers_from_config_dcr_bridge_defaults_off(self):
        manager = MCPServerManager()

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(self._client_forwarded_config(MCPAuth.true_passthrough))

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.dcr_bridge is None
        assert server.is_dcr_bridge is False

    @pytest.mark.asyncio
    async def test_load_servers_from_config_coerces_cost_string_to_float(self):
        """YAML 1.1 parses `7e-05` as a string; ingest must coerce it to float."""
        manager = MCPServerManager()
        config = {
            "google_maps": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "mcp_info": {
                    "mcp_server_cost_info": {
                        "default_cost_per_query": "7e-05",
                        "tool_name_to_cost_per_query": {"geocode": "1e-3"},
                    }
                },
            }
        }

        await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        cost_info = server.mcp_info["mcp_server_cost_info"]
        assert cost_info["default_cost_per_query"] == 7e-05
        assert isinstance(cost_info["default_cost_per_query"], float)
        assert cost_info["tool_name_to_cost_per_query"]["geocode"] == 1e-3
        assert isinstance(cost_info["tool_name_to_cost_per_query"]["geocode"], float)

    @pytest.mark.asyncio
    async def test_load_servers_from_config_sets_token_endpoint_auth_method(self):
        """token_endpoint_auth_method from config is carried onto the MCPServer (LIT-4091)."""
        manager = MCPServerManager()
        config = {
            "basic_provider": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "token_endpoint_auth_method": "client_secret_basic",
            },
            "default_provider": {
                "url": "https://example.com/mcp2",
                "transport": MCPTransport.http,
            },
        }

        await manager.load_servers_from_config(config)

        by_name = {s.server_name: s for s in manager.config_mcp_servers.values()}
        assert by_name["basic_provider"].token_endpoint_auth_method == "client_secret_basic"
        assert by_name["default_provider"].token_endpoint_auth_method is None

    def test_normalize_mcp_server_cost_info_preserves_float_values(self):
        mcp_info = {
            "server_name": "maps",
            "mcp_server_cost_info": {
                "default_cost_per_query": 0.01,
                "tool_name_to_cost_per_query": {"search": 0.05},
            },
        }

        _normalize_mcp_server_cost_info(mcp_info)

        cost_info = mcp_info["mcp_server_cost_info"]
        assert cost_info["default_cost_per_query"] == 0.01
        assert cost_info["tool_name_to_cost_per_query"] == {"search": 0.05}

    def test_normalize_mcp_server_cost_info_drops_non_numeric_values(self):
        mcp_info = {
            "server_name": "maps",
            "mcp_server_cost_info": {
                "default_cost_per_query": "not-a-number",
                "tool_name_to_cost_per_query": {"search": "free", "geocode": "2e-4"},
            },
        }

        _normalize_mcp_server_cost_info(mcp_info)

        cost_info = mcp_info["mcp_server_cost_info"]
        assert "default_cost_per_query" not in cost_info
        assert cost_info["tool_name_to_cost_per_query"] == {"geocode": 2e-4}

    def test_normalize_mcp_server_cost_info_leaves_missing_cost_info_alone(self):
        mcp_info = {"server_name": "maps"}

        _normalize_mcp_server_cost_info(mcp_info)

        assert "mcp_server_cost_info" not in mcp_info

    def test_warns_when_custom_separator_invalid(self, monkeypatch, caplog):
        """Invalid MCP_TOOL_PREFIX_SEPARATOR values should log a warning."""

        original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
        monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "/")

        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            _reload_mcp_manager_module()

        assert any("violates SEP-986" in message for message in caplog.messages)

        # Restore original setting and ensure warning disappears
        if original_value is None:
            monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
        else:
            monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)

        caplog.clear()
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            _reload_mcp_manager_module()

        assert all("violates SEP-986" not in message for message in caplog.messages)

    def test_accepts_valid_custom_separator(self, monkeypatch, caplog):
        """Valid separators should not emit warnings during module import."""

        original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
        monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "_")

        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            _reload_mcp_manager_module()

        assert all("violates SEP-986" not in message for message in caplog.messages)

        if original_value is None:
            monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
        else:
            monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)

        _reload_mcp_manager_module()

    @pytest.mark.asyncio
    async def test_list_tools_with_server_specific_auth_headers(self):
        """Test list_tools method with server-specific auth headers"""
        manager = MCPServerManager()

        # Mock servers
        server1 = MagicMock()
        server1.name = "github"
        server1.alias = "github"
        server1.server_name = "github"

        server2 = MagicMock()
        server2.name = "zapier"
        server2.alias = "zapier"
        server2.server_name = "zapier"

        # Mock get_allowed_mcp_servers to return our test servers
        manager.get_allowed_mcp_servers = AsyncMock(return_value=["github", "zapier"])
        manager.get_mcp_server_by_id = MagicMock(side_effect=lambda x: server1 if x == "github" else server2)

        # Mock _get_tools_from_server to return different results
        async def mock_get_tools_from_server(
            server,
            mcp_auth_header=None,
            **kwargs,
        ):
            if server.name == "github":
                tool1 = MagicMock()
                tool1.name = "github_tool_1"
                tool2 = MagicMock()
                tool2.name = "github_tool_2"
                return [tool1, tool2]
            else:
                tool1 = MagicMock()
                tool1.name = "zapier_tool_1"
                return [tool1]

        manager._get_tools_from_server = mock_get_tools_from_server

        # Test with server-specific auth headers
        mcp_server_auth_headers = {
            "github": "Bearer github-token",
            "zapier": "zapier-api-key",
        }

        result = await manager.list_tools(mcp_server_auth_headers=mcp_server_auth_headers)

        # Verify that both servers were called with their specific auth headers
        assert len(result) == 3  # 2 from github + 1 from zapier

        # Verify the tools have the expected names
        tool_names = [tool.name for tool in result]
        assert "github_tool_1" in tool_names
        assert "github_tool_2" in tool_names
        assert "zapier_tool_1" in tool_names

    @pytest.mark.asyncio
    async def test_list_tools_fallback_to_legacy_auth_header(self):
        """Test that list_tools falls back to legacy auth header when server-specific not available"""
        manager = MCPServerManager()

        # Mock server
        server = MagicMock()
        server.name = "github"
        server.alias = "github"
        server.server_name = "github"

        # Mock get_allowed_mcp_servers
        manager.get_allowed_mcp_servers = AsyncMock(return_value=["github"])
        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock _get_tools_from_server
        async def mock_get_tools_from_server(
            server,
            mcp_auth_header=None,
            **kwargs,
        ):
            assert mcp_auth_header == "legacy-token"  # Should use legacy header
            tool = MagicMock()
            tool.name = "github_tool_1"
            return [tool]

        manager._get_tools_from_server = mock_get_tools_from_server

        # Test with only legacy auth header (no server-specific headers)
        result = await manager.list_tools(
            mcp_auth_header="legacy-token",
            mcp_server_auth_headers={},  # Empty server-specific headers
        )

        assert len(result) == 1
        assert result[0].name == "github_tool_1"

    @pytest.mark.asyncio
    async def test_list_tools_prioritizes_server_specific_over_legacy(self):
        """Test that server-specific auth headers take priority over legacy header"""
        manager = MCPServerManager()

        # Mock server
        server = MagicMock()
        server.name = "github"
        server.alias = "github"
        server.server_name = "github"

        # Mock get_allowed_mcp_servers
        manager.get_allowed_mcp_servers = AsyncMock(return_value=["github"])
        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock _get_tools_from_server
        async def mock_get_tools_from_server(
            server,
            mcp_auth_header=None,
            **kwargs,
        ):
            assert mcp_auth_header == "server-specific-token"  # Should use server-specific header
            tool = MagicMock()
            tool.name = "github_tool_1"
            return [tool]

        manager._get_tools_from_server = mock_get_tools_from_server

        # Test with both legacy and server-specific headers
        result = await manager.list_tools(
            mcp_auth_header="legacy-token",
            mcp_server_auth_headers={"github": "server-specific-token"},
        )

        assert len(result) == 1
        assert result[0].name == "github_tool_1"

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_case_insensitive_extra_headers(self):
        """_call_regular_mcp_tool should forward headers regardless of original casing."""

        manager = MCPServerManager()
        server = MCPServer(
            server_id="server-case-call",
            name="case-call-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.authorization,
            extra_headers=["Authorization"],
        )

        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured_extra_headers = None

        async def capture_create_mcp_client(
            server, mcp_auth_header, extra_headers, stdio_env, **kwargs
        ):  # pragma: no cover - helper
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        result = await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers={"authorization": "Bearer token"},
            proxy_logging_obj=None,
        )

        assert captured_extra_headers == {"Authorization": "Bearer token"}
        assert isinstance(result, CallToolResult)

    async def _capture_list_subject_token(self, server, oauth2_headers, raw_headers=None):
        """Run _get_tools_from_server and return the subject_token it threaded to _create_mcp_client."""
        manager = MCPServerManager()
        captured = {}

        async def capture_create_mcp_client(
            server, mcp_auth_header, extra_headers, stdio_env, subject_token=None, **kwargs
        ):  # pragma: no cover - helper
            captured["subject_token"] = subject_token
            return AsyncMock()

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)
        manager._fetch_tools_with_timeout = AsyncMock(return_value=[])
        await manager._get_tools_from_server(server=server, oauth2_headers=oauth2_headers, raw_headers=raw_headers)
        return captured["subject_token"]

    @pytest.mark.asyncio
    async def test_list_threads_subject_token_for_token_exchange(self):
        """tools/list discovery must hand the caller's bearer to the resolver for OBO servers."""
        server = MCPServer(
            server_id="te-list",
            name="te-list-server",
            url="https://up.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )
        subject_token = await self._capture_list_subject_token(
            server, oauth2_headers={"Authorization": "Bearer subj-jwt"}
        )
        assert subject_token == "subj-jwt"

    @pytest.mark.asyncio
    async def test_list_does_not_thread_subject_token_for_non_token_exchange(self):
        """A non-OBO server must not get the caller's bearer threaded (no leak across modes)."""
        server = MCPServer(
            server_id="none-list",
            name="none-list-server",
            url="https://up.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
        )
        subject_token = await self._capture_list_subject_token(
            server, oauth2_headers={"Authorization": "Bearer subj-jwt"}
        )
        assert subject_token is None

    @pytest.mark.asyncio
    async def test_list_subject_token_none_without_oauth2_headers(self):
        """Background/registry refresh (no oauth2 headers) lists with no subject token, as before."""
        server = MCPServer(
            server_id="te-list-bg",
            name="te-list-bg-server",
            url="https://up.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )
        subject_token = await self._capture_list_subject_token(server, oauth2_headers=None)
        assert subject_token is None

    @pytest.mark.asyncio
    async def test_list_surfaces_resolver_401_as_upstream_auth_error(self):
        """A v2 resolver auth challenge (HTTPException 401) raised while building the client must
        surface as MCPUpstreamAuthError with its WWW-Authenticate preserved, so single-server routes
        challenge the client instead of the old behavior of masking it to an empty tool list."""
        server = MCPServer(
            server_id="te-401",
            name="te-401-server",
            url="https://up.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )
        manager = MCPServerManager()
        challenge = (
            'Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/te-401-server", error="invalid_token"'
        )
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": challenge})
        )
        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(server=server, oauth2_headers={"Authorization": "Bearer subj-jwt"})
        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate == challenge

    @pytest.mark.asyncio
    async def test_list_surfaces_non_auth_httpexception_as_internal_fault(self):
        """A non-auth HTTP error (e.g. 412 no endpoint, 503 IdP down) now raises MCPServerListError
        with an "internal" fault carrying the status code instead of absorbing to []: the silent
        empty list made a misconfigured/unavailable server indistinguishable from a healthy server
        with no tools. The aggregate absorbs it into that server's outcome, so one broken server
        still does not blank the whole aggregate listing."""
        server = MCPServer(
            server_id="te-412",
            name="te-412-server",
            url="https://up.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )
        manager = MCPServerManager()
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(status_code=412, detail="token exchange endpoint is not configured")
        )
        with pytest.raises(MCPServerListError) as exc_info:
            await manager._get_tools_from_server(server=server, oauth2_headers={"Authorization": "Bearer subj-jwt"})
        assert exc_info.value.fault == ServerListFault(tag="internal", status_code=412)
        assert exc_info.value.server_name == "te-412-server"

    def _upstream_status_error(self, status_code: int, www_authenticate: Optional[str] = None) -> httpx.HTTPStatusError:
        """Build an httpx.HTTPStatusError shaped like the one the MCP SDK surfaces for an upstream
        HTTP failure, so _extract_upstream_auth_failure can read status_code and WWW-Authenticate."""
        request = httpx.Request("POST", "https://up.example.com/mcp")
        headers = {"www-authenticate": www_authenticate} if www_authenticate else {}
        response = httpx.Response(status_code, headers=headers, request=request)
        return httpx.HTTPStatusError(f"HTTP {status_code}", request=request, response=response)

    def _passthrough_call_server(self, auth_type, server_id: str = "pt-call") -> "MCPServer":
        return MCPServer(
            server_id=server_id,
            name=f"{server_id}-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=auth_type,
        )

    async def _run_call_regular(self, manager, server):
        return await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers={"authorization": "Bearer caller-upstream-token"},
            proxy_logging_obj=None,
        )

    @pytest.mark.asyncio
    @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
    async def test_call_relays_upstream_401_for_client_forwarded_modes(self, auth_type):
        """A client-forwarded pass-through/delegate call must relay an upstream 401 (expired/invalid
        token) as MCPUpstreamAuthError with the upstream WWW-Authenticate preserved, so single-server
        REST routes challenge the caller instead of masking it as a generic isError. Only 401 is a
        re-auth signal; the relay opts into raise_on_error so the transport failure surfaces."""
        server = self._passthrough_call_server(auth_type)
        challenge = f'Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/{server.name}"'
        manager = MCPServerManager()
        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(side_effect=self._upstream_status_error(401, challenge))
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await self._run_call_regular(manager, server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate == challenge
        assert mock_client.call_tool.call_args.kwargs.get("raise_on_error") is True

    @pytest.mark.asyncio
    @pytest.mark.parametrize("is_error", [False, True])
    async def test_call_passthrough_returns_tool_result_unchanged(self, is_error):
        """The relay only re-raises transport failures. A tool that RETURNS a result (a success, or a
        tool-level isError, neither of which raises) on a pass-through call must be returned verbatim,
        never wrapped as MCPUpstreamAuthError or replaced by error_tool_result."""
        server = self._passthrough_call_server(MCPAuth.true_passthrough, server_id=f"pt-ok-{is_error}")
        manager = MCPServerManager()
        expected = CallToolResult(content=[], isError=is_error)
        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=expected)
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        result = await self._run_call_regular(manager, server)

        assert result is expected
        assert mock_client.call_tool.call_args.kwargs.get("raise_on_error") is True

    @pytest.mark.asyncio
    @pytest.mark.parametrize("status_code", [403, 503])
    async def test_call_passthrough_non_reauth_failure_stays_iserror(self, status_code):
        """Only an upstream 401 is a re-auth signal. A 403 (authenticated but forbidden; re-auth
        won't help) and a genuine non-auth failure (e.g. 503) both keep the default isError
        degradation and stay a visible warning, mirroring the list path, rather than being relayed as
        a re-auth challenge."""
        from litellm.experimental_mcp_client.client import MCPClient

        server = self._passthrough_call_server(MCPAuth.true_passthrough, server_id=f"pt-{status_code}")
        manager = MCPServerManager()
        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(side_effect=self._upstream_status_error(status_code))
        mock_client.error_tool_result = MCPClient.error_tool_result
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        import litellm.proxy._experimental.mcp_server.mcp_server_manager as _mgr_mod

        with patch.object(_mgr_mod, "verbose_logger") as mock_log:
            result = await self._run_call_regular(manager, server)

        assert result.isError is True
        # A genuine non-auth failure keeps operator visibility at warning level, since call_tool's
        # raise_on_error demoted the client-layer error log to debug.
        assert mock_log.warning.called

    @pytest.mark.asyncio
    async def test_call_non_passthrough_does_not_opt_into_raise_on_error(self):
        """Non-client-forwarded auth types keep the default call_tool masking (raise_on_error stays
        off), so this relay is scoped to the pass-through modes and cannot regress api_key/OBO calls."""
        server = MCPServer(
            server_id="ak-call",
            name="ak-call-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.api_key,
            authentication_token="static-key",
        )
        manager = MCPServerManager()
        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        result = await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers=None,
            proxy_logging_obj=None,
        )

        assert result.isError is False
        assert mock_client.call_tool.call_args.kwargs.get("raise_on_error") is not True

    def _token_exchange_server(self, server_id: str) -> "MCPServer":
        return MCPServer(
            server_id=server_id,
            name=f"{server_id}-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )

    @pytest.mark.asyncio
    async def test_entra_obo_profile_survives_db_credentials_round_trip(self):
        # A credentials blob from the management API / DB carries token_exchange_profile; the DB build
        # must reconstruct it onto the MCPServer, and the v2 adapter must map it onto the resolver
        # config. Without threading it through, an entra_obo server persisted via the API silently
        # falls back to rfc8693 and posts the wrong grant to the IdP.
        from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import to_server_spec
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import TokenExchangeConfig

        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="entra-db-1",
            alias="entra_db",
            description="entra obo from db",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            credentials={
                "client_id": "cid",
                "client_secret": "csec",
                "token_exchange_endpoint": "https://login.microsoftonline.com/tid/oauth2/v2.0/token",
                "scopes": ["api://target/.default"],
                "token_exchange_profile": "entra_obo",
            },
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
        assert built.token_exchange_profile == "entra_obo"

        spec = to_server_spec(built)
        assert spec is not None and isinstance(spec.config, TokenExchangeConfig)
        assert spec.config.profile == "entra_obo"

    @pytest.mark.asyncio
    async def test_upstream_resource_survives_db_credentials_round_trip(self):
        """A server persisted through the management API carries upstream_resource in its
        credentials blob, mirroring id_jag_resource. Without reading it back on the DB build, a
        UI-created server silently drops the knob and keeps hitting invalid_target."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="res-db-1",
            alias="res_db",
            description="rfc8707 from db",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            credentials={
                "client_id": "cid",
                "client_secret": "csec",
                "authorization_url": "https://idp.example.com/authorize",
                "token_url": "https://idp.example.com/token",
                "upstream_resource": "https://up.example.com/mcp",
            },
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
        assert built.upstream_resource == "https://up.example.com/mcp"

    @pytest.mark.asyncio
    async def test_upstream_resource_loads_from_config(self):
        """The config.yaml arm of the same field: mcp_servers entries must carry the knob onto the
        registry entry, since a config-declared server never round-trips through the DB."""
        manager = MCPServerManager()
        await manager.load_servers_from_config(
            {
                "strict_as": {
                    "url": "https://strict.example.com/mcp",
                    "transport": MCPTransport.http,
                    "auth_type": MCPAuth.oauth2,
                    "oauth2_flow": "authorization_code",
                    "upstream_resource": "auto",
                }
            }
        )

        loaded = next(s for s in manager.get_registry().values() if s.name == "strict_as")
        assert loaded.upstream_resource == "auto"

    @pytest.mark.asyncio
    @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
    async def test_build_from_table_discovers_upstream_oauth_for_client_forwarded_modes(self, auth_type):
        """The gateway's relayed authorize flow (used by the browser-only Authorize) needs the
        upstream's authorization_url on the registry entry, and these rows never persist one, so
        the DB build must discover it the same way oauth2 rows do."""

        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="cf-db-1",
            alias="cf_db",
            description="client-forwarded from db",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=auth_type,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=None,
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery:
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        mock_discovery.assert_awaited_once()
        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"

    @pytest.mark.asyncio
    async def test_build_from_table_backfills_resource_driven_scopes_for_pinned_authorization_url(self):
        """When authorization_url is admin-pinned and corroborated, scopes backfill as the
        resource-driven value (the WWW-Authenticate challenge scope, else the RFC 9728
        protected-resource scopes_supported), per the MCP authorization spec Scope Selection Strategy.
        The client does not restrict scopes to the authorization server's own scopes_supported; scope
        minimization and inflation control are the authorization server's and user's job at consent
        (RFC 6749 §3.3)."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="manual-auth-url-1",
            alias="manual_auth_url",
            description="manual authorization_url, blank scopes",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/authorize",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read", "admin"],
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery:
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        mock_discovery.assert_awaited_once()
        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"
        assert built.scopes == ["read", "admin"]

    @pytest.mark.asyncio
    async def test_build_from_table_reflects_discovered_issuer_trust_on_first_use(self):
        """An unpinned server resolves endpoints resource-rooted on first discovery and records the
        discovered issuer trust-on-first-use. The returned in-memory server must carry that discovered
        issuer so the registry matches what gets persisted to the row; otherwise the OAuth token
        identity (which includes issuer) differs between this build and the next rebuild, forcing a
        spurious re-auth. Endpoints and issuer come from the same authorization-server document, so
        they are consistent."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="tofu-issuer-1",
            alias="tofu_issuer",
            description="unpinned, discovers its issuer",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read"],
            discovered_issuer="https://idp.example.com",
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.issuer == "https://idp.example.com"
        assert built.issuer_is_anchored is False
        assert built.authorization_url == "https://idp.example.com/authorize"

    @pytest.mark.asyncio
    async def test_build_from_table_origin_fallback_issuer_is_not_reflected(self):
        """An origin-fallback discovery is a guess that is deliberately never persisted, so the built
        server must not claim an issuer the row will not hold; otherwise in-memory and DB would
        disagree in the opposite direction."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="origin-fallback-1",
            alias="origin_fallback",
            description="unpinned, origin-fallback discovery",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://up.example.com/authorize",
            token_url="https://up.example.com/token",
            discovered_issuer="https://up.example.com",
            from_origin_fallback=True,
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.issuer is None

    @pytest.mark.asyncio
    async def test_build_from_table_whitespace_authorization_url_is_not_a_pin(self):
        """A whitespace-only authorization_url on the row must not be kept for redirects while the
        gate treats it as unpinned. It is normalized to unpinned everywhere, so the built server
        takes the discovered authorize endpoint, token_url, and scopes as one consistent group
        rather than serving the whitespace value with half-discovered fields."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="whitespace-auth-url",
            alias="whitespace_auth_url",
            description="whitespace authorization_url is not a pin",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="   ",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read"],
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"
        assert built.scopes == ["read"]

    @pytest.mark.asyncio
    async def test_build_from_table_fills_endpoints_when_metadata_corroborates_manual_authorization_url(self):
        """A discovered token_url is only trusted next to a manual authorization_url when the same
        metadata document advertises that authorize endpoint, and the comparison must tolerate
        formatting-only differences (host case, trailing slash, query params like ?prompt=consent)
        so hand-copied URLs still self-heal."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="manual-auth-url-2",
            alias="manual_auth_url_match",
            description="manual authorization_url matching discovery, blank token_url",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://IDP.example.com/authorize/?prompt=consent",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["read"],
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.authorization_url == "https://IDP.example.com/authorize/?prompt=consent"
        assert built.token_url == "https://idp.example.com/token"
        assert built.registration_url == "https://idp.example.com/register"
        assert built.scopes == ["read"]

    @pytest.mark.asyncio
    async def test_build_from_table_uses_issuer_anchored_endpoints_when_issuer_configured(self):
        """When an admin configures an issuer, the build takes its endpoints from the issuer-anchored
        fetch (RFC 8414 §3.3) rather than the resource-rooted corroboration path. The build path does
        not call _descovery_metadata directly; the issuer-anchored helper is responsible for combining
        issuer endpoints with resource-driven scopes internally, and is invoked with the server url so
        it can fetch those scopes."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="issuer-anchored-1",
            alias="issuer_anchored",
            description="issuer configured, blank endpoints",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        resolved = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["read", "write"],
        )
        resource_rooted = AsyncMock(return_value=MCPOAuthMetadata(token_url="https://attacker.example.com/steal"))
        with (
            patch.object(
                manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=resolved)
            ) as anchored,
            patch.object(manager, "_descovery_metadata", new=resource_rooted),
        ):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        anchored.assert_awaited_once_with("https://idp.example.com", "https://up.example.com/mcp")
        resource_rooted.assert_not_awaited()
        assert built.issuer == "https://idp.example.com"
        assert built.issuer_is_anchored is True
        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"
        assert built.registration_url == "https://idp.example.com/register"
        assert built.scopes == ["read", "write"]

    @pytest.mark.asyncio
    async def test_fetch_issuer_anchored_metadata_takes_endpoints_from_issuer_scopes_from_resource(self):
        """The issuer-anchored helper adopts token_endpoint/registration_endpoint from the pinned
        issuer's own §3.3-validated document, but the scopes are resource-driven: it fetches the
        resource's advertised scopes and uses those, not the issuer document's scopes_supported. This
        keeps endpoint trust anchored on the issuer while scope selection stays resource-driven per the
        MCP Scope Selection Strategy."""
        manager = MCPServerManager()

        issuer_document = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["as.everything"],
        )
        resource_document = MCPOAuthMetadata(scopes=["resource.read"])
        with (
            patch.object(
                manager, "_fetch_single_authorization_server_metadata", new=AsyncMock(return_value=issuer_document)
            ) as issuer_fetch,
            patch.object(
                manager, "_descovery_metadata", new=AsyncMock(return_value=resource_document)
            ) as resource_fetch,
        ):
            result = await manager._fetch_issuer_anchored_oauth_metadata(
                "https://idp.example.com", "https://up.example.com/mcp"
            )

        issuer_fetch.assert_awaited_once_with(
            "https://idp.example.com", "https://idp.example.com", require_issuer="https://idp.example.com"
        )
        resource_fetch.assert_awaited_once()
        assert result is not None
        assert result.token_url == "https://idp.example.com/token"
        assert result.registration_url == "https://idp.example.com/register"
        assert result.scopes == ["resource.read"]

    @pytest.mark.asyncio
    async def test_build_from_table_issuer_anchor_fails_closed_without_falling_back_to_resource(self):
        """A configured issuer whose metadata does not validate (RFC 8414 §3.3 mismatch or fetch
        failure) yields None from the anchored fetch. The build must adopt nothing and must NOT fall
        back to resource-rooted discovery, or the fail-closed guarantee would be defeated by the very
        resource the issuer anchor exists to distrust."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="issuer-anchored-2",
            alias="issuer_anchored_failclosed",
            description="issuer configured, upstream fails validation",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        resource_rooted = AsyncMock(return_value=MCPOAuthMetadata(token_url="https://attacker.example.com/steal"))
        with (
            patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)),
            patch.object(manager, "_descovery_metadata", new=resource_rooted),
        ):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        resource_rooted.assert_not_awaited()
        assert built.issuer == "https://idp.example.com"
        assert built.token_url is None
        assert built.registration_url is None
        assert built.scopes is None

    @pytest.mark.asyncio
    async def test_build_from_table_issuer_anchor_overrides_stored_endpoints_even_when_populated(self):
        """When an issuer is pinned, the endpoints come SOLELY from the §3.3-validated issuer document
        and win over any stored/manual endpoint values, even a fully-populated row. Otherwise an
        attacker who controls a stored token endpoint keeps receiving codes/secrets after an admin
        pins a trusted issuer: `needs_discovery` must not short-circuit on populated fields, and the
        issuer's endpoints must override the stored ones."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="issuer-anchored-populated",
            alias="issuer_anchored_populated",
            description="issuer set, but stale/hostile endpoints already stored",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            authorization_url="https://attacker.example.com/authorize",
            token_url="https://attacker.example.com/steal",
            credentials={"scopes": ["stale"]},
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        issuer_resolved = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            scopes=["read"],
        )
        with patch.object(
            manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=issuer_resolved)
        ) as anchored:
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        anchored.assert_awaited_once_with("https://idp.example.com", "https://up.example.com/mcp")
        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"
        assert built.token_url != "https://attacker.example.com/steal"

    @pytest.mark.asyncio
    async def test_management_view_keeps_stored_endpoints_when_issuer_is_pinned(self):
        """A pinned issuer empties the endpoints the runtime uses, but the management view must still
        report what the admin stored. Serving the emptied values made the dashboard edit form load the
        three endpoint fields blank, so saving with no edits sent them back as explicit nulls and wiped
        the row, and re-entering them looked like it never saved."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="issuer-anchored-management-view",
            alias="issuer_anchored_management_view",
            description="issuer pinned with admin-entered endpoints",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            issuer="https://idp.example.com",
            authorization_url="https://up.example.com/oauth/authorize",
            token_url="https://up.example.com/oauth/token",
            registration_url="https://up.example.com/oauth/register",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.authorization_url is None
        assert built.token_url is None
        assert built.registration_url is None

        view = manager._build_mcp_server_table(built)

        assert view.issuer == "https://idp.example.com"
        assert view.authorization_url == "https://up.example.com/oauth/authorize"
        assert view.token_url == "https://up.example.com/oauth/token"
        assert view.registration_url == "https://up.example.com/oauth/register"

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "advertised_authorization_url",
        ["https://attacker.example.com/authorize", None],
    )
    async def test_build_from_table_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(
        self, advertised_authorization_url
    ):
        """Resource-rooted discovery lets a compromised upstream advertise its own authorization
        server. With a manual authorization_url pinned, a document that does not corroborate it has
        its token_url and registration_url dropped: accepting them would send the code, client secret,
        and PKCE verifier to the attacker (config-time RFC 9700 mix-up). The resource-driven scopes
        are kept, because scope selection is resource-driven (MCP Scope Selection Strategy) and scope
        inflation is bounded by the authorization server at consent (RFC 6749 §3.3), not by dropping
        scopes on an endpoint mismatch. The gateway persists nothing, so the in-memory merge is the
        entire behavior."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="manual-auth-url-3",
            alias="manual_auth_url_mismatch",
            description="manual authorization_url, hostile discovery document",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/authorize",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        metadata = MCPOAuthMetadata(
            authorization_url=advertised_authorization_url,
            token_url="https://attacker.example.com/token",
            registration_url="https://attacker.example.com/register",
            scopes=["read", "admin"],
        )
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url is None
        assert built.registration_url is None
        assert built.scopes == ["read", "admin"]

    @pytest.mark.asyncio
    async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self):
        """A fully hand-configured server (authorization_url, token_url, and scopes all set) has
        nothing left for discovery to fill, so the build must not fetch upstream metadata."""
        manager = MCPServerManager()
        row = LiteLLM_MCPServerTable(
            server_id="fully-manual-1",
            alias="fully_manual",
            description="all upstream oauth fields set by the admin",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/manual-authorize",
            token_url="https://idp.example.com/manual-token",
            credentials={"scopes": ["calendar.read"]},
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)) as mock_discovery:
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        mock_discovery.assert_not_awaited()
        assert built.authorization_url == "https://idp.example.com/manual-authorize"
        assert built.token_url == "https://idp.example.com/manual-token"
        assert built.scopes == ["calendar.read"]

    async def _capture_subject_token(self, call) -> Optional[str]:
        """Run a manager method (via ``call(manager)``) and return the subject_token it threaded
        into ``_create_mcp_client``."""
        manager = MCPServerManager()
        captured: Dict[str, Any] = {}

        async def capture_create_mcp_client(
            server, mcp_auth_header, extra_headers, stdio_env, subject_token=None, **kwargs
        ):  # pragma: no cover - helper
            captured["subject_token"] = subject_token
            return AsyncMock()

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)
        await call(manager)
        return captured.get("subject_token")

    @pytest.mark.asyncio
    async def test_prompts_thread_subject_token_for_token_exchange(self):
        """prompts/list on an OBO server must exchange the caller's bearer, not connect with none."""
        server = self._token_exchange_server("te-prompts")
        st = await self._capture_subject_token(
            lambda m: m.get_prompts_from_server(server=server, raw_headers={"authorization": "Bearer subj-jwt"})
        )
        assert st == "subj-jwt"

    @pytest.mark.asyncio
    async def test_resources_thread_subject_token_for_token_exchange(self):
        """resources/list on an OBO server must exchange the caller's bearer."""
        server = self._token_exchange_server("te-resources")
        st = await self._capture_subject_token(
            lambda m: m.get_resources_from_server(server=server, raw_headers={"authorization": "Bearer subj-jwt"})
        )
        assert st == "subj-jwt"

    @pytest.mark.asyncio
    async def test_read_resource_threads_subject_token_for_token_exchange(self):
        """resources/read on an OBO server must exchange the caller's bearer."""
        server = self._token_exchange_server("te-read")
        st = await self._capture_subject_token(
            lambda m: m.read_resource_from_server(
                server=server,
                url="https://up.example.com/r",
                raw_headers={"authorization": "Bearer subj-jwt"},
            )
        )
        assert st == "subj-jwt"

    @pytest.mark.asyncio
    async def test_prompts_no_subject_token_for_non_token_exchange(self):
        """A non-OBO server must not get the caller's bearer threaded (no cross-mode leak)."""
        server = MCPServer(
            server_id="none-prompts",
            name="none-prompts-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
        )
        st = await self._capture_subject_token(
            lambda m: m.get_prompts_from_server(server=server, raw_headers={"authorization": "Bearer subj-jwt"})
        )
        assert st is None

    @pytest.mark.asyncio
    async def test_caller_header_cannot_bypass_v2_for_token_exchange(self):
        """A caller-supplied per-server header (x-mcp-*) must NOT disable the OBO exchange:
        _create_mcp_client keeps the v2 spec and runs the resolver (which exchanges the subject),
        rather than deferring to v1 and forwarding the caller's header verbatim upstream."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        exchanged_subjects = []

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                exchanged_subjects.append(subject.inbound_token.get_secret_value() if subject.inbound_token else None)
                return Ok(StaticHeaderAuth("Bearer MINTED", header_name="Authorization"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = self._token_exchange_server("te-bypass")

        client = await manager._create_mcp_client(
            server,
            mcp_auth_header="Bearer x-mcp-caller-header",
            subject_token="subj-jwt",
        )

        # The resolver ran and exchanged the subject despite the per-server header; not bypassed.
        assert exchanged_subjects == ["subj-jwt"]
        assert client is not None

    @pytest.mark.asyncio
    async def test_injected_authorization_does_not_shadow_obo_minted_token(self):
        """A guardrail/static Authorization (e.g. MCPJWTSigner) must NOT shadow the exchanged OBO
        token. The resolver-owned credential is authoritative: the conflicting header is dropped and
        the minted token is what reaches the upstream, not the injected JWT."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Ok(StaticHeaderAuth("Bearer MINTED", header_name="Authorization"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = self._token_exchange_server("te-shadow")

        client = await manager._create_mcp_client(
            server,
            extra_headers={"Authorization": "Bearer signer-jwt"},  # simulate the JWT signer
            subject_token="subj-jwt",
        )

        # minted token wins (resolved_auth kept), signer's header dropped from extra_headers
        assert client._resolved_auth is not None
        assert "authorization" not in {k.lower() for k in (client.extra_headers or {})}

    @pytest.mark.asyncio
    async def test_injected_authorization_does_not_shadow_m2m_minted_token(self):
        """The M2M twin of the OBO shadow test: a guardrail/static Authorization must not displace
        the gateway-minted client_credentials bearer. Dropping the resolved auth here would also
        drop the one-shot 401 refetch that rides on it, so the resolver-owned credential is
        authoritative exactly as for token_exchange and authorization_code."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="Authorization"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = MCPServer(
            server_id="m2m-shadow",
            name="m2m-shadow-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="client_credentials",
            client_id="cid",
            client_secret="csec",
            token_url="https://idp.example.com/token",
        )

        client = await manager._create_mcp_client(
            server,
            extra_headers={"Authorization": "Bearer signer-jwt"},  # simulate the JWT signer
        )

        assert client._resolved_auth is not None
        assert "authorization" not in {k.lower() for k in (client.extra_headers or {})}

    @staticmethod
    def _esb_server(header: "str | None") -> MCPServer:
        return MCPServer(
            server_id="esb",
            name="esb-server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="client_credentials",
            client_id="cid",
            client_secret="csec",
            token_url="https://idp.example.com/token",
            upstream_token_header=header,
            static_headers={"Authorization": "Bearer static-upstream-mcp-token"},
        )

    @pytest.mark.asyncio
    async def test_static_authorization_survives_a_minted_token_aimed_elsewhere(self):
        """The dual-credential case: an ESB wants the gateway-minted token on its own header while a
        separate static Authorization passes through to the origin. Dropping Authorization here (the
        old name-blind behavior) deletes the second credential and the upstream 401s."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        client = await manager._create_mcp_client(
            self._esb_server("esb-oauth"),
            extra_headers={"Authorization": "Bearer static-upstream-mcp-token"},
        )

        assert client._resolved_auth is not None
        assert (client.extra_headers or {})["Authorization"] == "Bearer static-upstream-mcp-token"

    @pytest.mark.asyncio
    async def test_a_minted_token_aimed_at_the_static_header_still_wins_that_slot(self):
        """The negative class of the test above: when the two DO collide the resolver-owned
        credential is still authoritative, so the knob cannot be used to smuggle a second
        credential into the same slot."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        client = await manager._create_mcp_client(
            self._esb_server("esb-oauth"),
            extra_headers={"esb-oauth": "Bearer signer-jwt", "X-Trace": "keep-me"},
        )

        assert client._resolved_auth is not None
        assert "esb-oauth" not in {k.lower() for k in (client.extra_headers or {})}
        assert (client.extra_headers or {})["X-Trace"] == "keep-me"

    @pytest.mark.asyncio
    async def test_a_differently_cased_injected_header_is_still_recognised_as_the_collision(self):
        """HTTP header names are case-insensitive, so the conflict check must be too.

        A case-sensitive check reports no conflict and hands the injected header back untouched, so
        the returned extra_headers still carries a second copy of the credential slot for every
        downstream consumer of that dict. httpx happens to collapse the two on the wire, which is
        exactly why this needs pinning rather than being left to luck.
        """
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Ok(StaticHeaderAuth("Bearer MINTED", header_name="esb-oauth"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        client = await manager._create_mcp_client(
            self._esb_server("esb-oauth"),
            extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"},
        )

        assert client._resolved_auth is not None
        assert not any(k.lower() == "esb-oauth" for k in (client.extra_headers or {}))
        assert (client.extra_headers or {})["X-Trace"] == "keep"

    def test_without_header_drops_only_the_named_header(self):
        from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header

        headers = {"Authorization": "Bearer a", "esb-oauth": "Bearer b", "X-Trace": "t"}
        assert without_header(headers, "ESB-OAuth") == {"Authorization": "Bearer a", "X-Trace": "t"}
        assert without_header(headers, DEFAULT_CREDENTIAL_HEADER) == {"esb-oauth": "Bearer b", "X-Trace": "t"}

    @pytest.mark.asyncio
    async def test_preflight_token_exchange_challenges_on_rejected_subject(self):
        """A subject the IdP rejects must raise the RFC 9728 401 challenge from the preflight, so a
        single-server route fails observably at the transport edge instead of the old behavior of
        the session opening and list_tools masking the failed exchange as an empty tool list."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Error(CredError.of_unauthorized("subject token rejected by the IdP"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = self._token_exchange_server("te-preflight-401")

        with pytest.raises(HTTPException) as exc_info:
            await manager.preflight_token_exchange(
                server=server,
                oauth2_headers={"Authorization": "Bearer rejected-subject"},
                user_api_key_auth=None,
            )
        assert exc_info.value.status_code == 401
        headers = exc_info.value.headers or {}
        www_authenticate = headers.get("WWW-Authenticate") or headers.get("www-authenticate") or ""
        assert "resource_metadata" in www_authenticate

    @pytest.mark.asyncio
    async def test_preflight_token_exchange_maps_gateway_fault_to_public_status(self):
        """A gateway-fault CredError (e.g. invalid_client) must surface its public status (500)
        from the preflight, not the OBO 401 challenge and not an empty-success session."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                return Error(CredError.of_misconfigured("token exchange configuration error"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = self._token_exchange_server("te-preflight-500")

        with pytest.raises(HTTPException) as exc_info:
            await manager.preflight_token_exchange(
                server=server,
                oauth2_headers={"Authorization": "Bearer subj"},
                user_api_key_auth=None,
            )
        assert exc_info.value.status_code == 500

    @pytest.mark.asyncio
    async def test_preflight_token_exchange_noop_on_success_and_without_subject(self):
        """A successful exchange returns without raising, and a request with no bearer never
        reaches the resolver (the no-subject case is the existing preemptive challenge's job)."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        resolved = []

        class _FakeProvider:
            async def resolve_credentials(self, subject, server):
                resolved.append(subject.inbound_token.get_secret_value() if subject.inbound_token else None)
                return Ok(StaticHeaderAuth("Bearer MINTED", header_name="Authorization"))

        manager = MCPServerManager(cred_provider=_FakeProvider())
        server = self._token_exchange_server("te-preflight-ok")

        assert (
            await manager.preflight_token_exchange(
                server=server,
                oauth2_headers={"Authorization": "Bearer good-subject"},
                user_api_key_auth=None,
            )
            is None
        )
        assert resolved == ["good-subject"]

        await manager.preflight_token_exchange(server=server, oauth2_headers=None, user_api_key_auth=None)
        assert resolved == ["good-subject"]

    @pytest.mark.asyncio
    async def test_preflight_token_exchange_skips_discovery_for_other_auth_modes(self):
        """Preflight must not make unrelated auth modes depend on OAuth discovery."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="plain-preflight",
            name="plain_preflight",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
        )
        manager.ensure_oauth_metadata_discovered = AsyncMock(
            side_effect=AssertionError("non-token-exchange server was resolved")
        )

        await manager.preflight_token_exchange(
            server=server,
            oauth2_headers={"Authorization": "Bearer subject"},
            user_api_key_auth=None,
        )

        manager.ensure_oauth_metadata_discovered.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_passthrough_strips_authorization_when_admission_consumed_litellm_key(
        self,
    ):
        """OAuth pass-through must not forward the caller's Authorization to upstream
        when LiteLLM admission consumed the bearer as its API key — otherwise the
        LiteLLM key the caller used for admission would leak upstream."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="server-passthrough-call",
            name="passthrough-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            extra_headers=["Authorization", "x-request-id"],
            oauth_passthrough=True,
        )

        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured_extra_headers = None

        async def capture_create_mcp_client(
            server,
            mcp_auth_header,
            extra_headers,
            stdio_env,
            subject_token=None,
            **kwargs,
        ):  # pragma: no cover - helper
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers={
                "authorization": "Bearer sk-litellm-key",
                "x-request-id": "req-123",
            },
            proxy_logging_obj=None,
            user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
        )

        assert captured_extra_headers == {"x-request-id": "req-123"}

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_v2_authz_code_drops_caller_authorization(
        self,
    ):
        """A v2-migrated per-user OAuth (authorization_code) server must NOT seed a
        caller-forwarded Authorization into extra_headers — the resolver injects the
        stored per-user token, and apply-if-absent would otherwise let the caller's
        header override another user's stored credential (matches v1's overwrite)."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        # oauth2, not M2M, not delegate => to_server_spec maps it to AuthorizationCodeConfig
        server = MCPServer(
            server_id="server-authz-code-call",
            name="authz-code-server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
        )
        # Migrated authorization_code => the centralized strip decision says drop the
        # caller's Authorization (the v2 resolver injects the stored token).
        assert _should_strip_caller_authorization(mcp_server=server, raw_headers=None, user_api_key_auth=None) is True

        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured_extra_headers = "unset"

        async def capture_create_mcp_client(
            server,
            mcp_auth_header,
            extra_headers,
            stdio_env,
            subject_token=None,
            **kwargs,
        ):  # pragma: no cover - helper
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers={"Authorization": "Bearer caller-supplied-token"},
            raw_headers={"authorization": "Bearer caller-supplied-token"},
            proxy_logging_obj=None,
            user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
        )

        # The caller's Authorization must not reach extra_headers; the v2 resolver is
        # the sole Authorization source for this server.
        assert captured_extra_headers != "unset"
        if captured_extra_headers:
            assert "authorization" not in {k.lower() for k in captured_extra_headers}

    def test_without_header_drops_only_the_credential(self):
        from litellm.types.mcp import without_header

        # None / empty -> None
        assert without_header(None, "Authorization") is None
        assert without_header({}, "Authorization") is None
        # Only Authorization present -> nothing left -> None (case-insensitive)
        assert without_header({"authorization": "Bearer x"}, "Authorization") is None
        # Authorization dropped, other headers kept
        assert without_header({"Authorization": "Bearer x", "X-Trace-Id": "t"}, "Authorization") == {"X-Trace-Id": "t"}

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_passthrough_forwards_authorization_with_admission_header(
        self,
    ):
        """OAuth pass-through forwards Authorization upstream when x-litellm-api-key
        provides admission — in that case Authorization carries the upstream OAuth
        bearer, not the LiteLLM key."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="server-passthrough-call-admission",
            name="passthrough-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            extra_headers=["Authorization"],
            oauth_passthrough=True,
        )

        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured_extra_headers = None

        async def capture_create_mcp_client(
            server,
            mcp_auth_header,
            extra_headers,
            stdio_env,
            subject_token=None,
            **kwargs,
        ):  # pragma: no cover - helper
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers={
                "x-litellm-api-key": "Bearer sk-litellm-key",
                "authorization": "Bearer upstream-oauth-bearer",
            },
            proxy_logging_obj=None,
            user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
        )

        assert captured_extra_headers == {"Authorization": "Bearer upstream-oauth-bearer"}

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_passthrough_forwards_authorization_for_anonymous_admission(
        self,
    ):
        """OAuth pass-through cold-start return (RFC 9728): the caller's only
        credential is the upstream bearer in Authorization, and LiteLLM admission
        is anonymous (no api_key on user_api_key_auth). Authorization must be
        forwarded so the delegated flow can complete."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="server-passthrough-call-anon",
            name="passthrough-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            extra_headers=["Authorization"],
            oauth_passthrough=True,
        )

        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured_extra_headers = None

        async def capture_create_mcp_client(
            server,
            mcp_auth_header,
            extra_headers,
            stdio_env,
            subject_token=None,
            **kwargs,
        ):  # pragma: no cover - helper
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=None,
            raw_headers={"authorization": "Bearer upstream-oauth-bearer"},
            proxy_logging_obj=None,
            user_api_key_auth=UserAPIKeyAuth(api_key=None),
        )

        assert captured_extra_headers == {"Authorization": "Bearer upstream-oauth-bearer"}

    async def _capture_call_extra_headers(self, server, oauth2_headers, raw_headers, user_api_key_auth):
        manager = MCPServerManager()
        mock_client = AsyncMock()
        mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False))
        captured = {"extra_headers": "unset"}

        async def capture_create_mcp_client(
            server, mcp_auth_header, extra_headers, stdio_env, subject_token=None, **kwargs
        ):  # pragma: no cover - helper
            captured["extra_headers"] = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)
        await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers=oauth2_headers,
            raw_headers=raw_headers,
            proxy_logging_obj=None,
            user_api_key_auth=user_api_key_auth,
        )
        return captured["extra_headers"]

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_true_passthrough_forwards_authorization(self):
        from litellm.proxy._types import UserAPIKeyAuth

        server = MCPServer(
            server_id="server-true-passthrough",
            name="tp-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        extra_headers = await self._capture_call_extra_headers(
            server,
            oauth2_headers={"Authorization": "Bearer upstream-token"},
            raw_headers={"authorization": "Bearer upstream-token"},
            user_api_key_auth=UserAPIKeyAuth(api_key=None),
        )
        assert extra_headers == {"Authorization": "Bearer upstream-token"}

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_oauth_delegate_forwards_separate_authorization(self):
        from litellm.proxy._types import UserAPIKeyAuth

        server = MCPServer(
            server_id="server-oauth-delegate",
            name="od-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        extra_headers = await self._capture_call_extra_headers(
            server,
            oauth2_headers={"Authorization": "Bearer upstream-token"},
            raw_headers={
                "x-litellm-api-key": "Bearer sk-litellm-key",
                "authorization": "Bearer upstream-token",
            },
            user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
        )
        assert extra_headers == {"Authorization": "Bearer upstream-token"}

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_admission_key(self):
        from litellm.proxy._types import UserAPIKeyAuth

        server = MCPServer(
            server_id="server-oauth-delegate-leak",
            name="od-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        extra_headers = await self._capture_call_extra_headers(
            server,
            oauth2_headers={"Authorization": "Bearer sk-litellm-key"},
            raw_headers={"authorization": "Bearer sk-litellm-key"},
            user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
        )
        assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers}

    def test_should_strip_caller_authorization_new_modes(self):
        from litellm.proxy._types import UserAPIKeyAuth

        true_passthrough = MCPServer(
            server_id="tp",
            name="tp",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        assert (
            _should_strip_caller_authorization(
                mcp_server=true_passthrough,
                raw_headers={"authorization": "Bearer upstream"},
                user_api_key_auth=UserAPIKeyAuth(api_key=None),
            )
            is False
        )

        oauth_delegate = MCPServer(
            server_id="od",
            name="od",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        assert (
            _should_strip_caller_authorization(
                mcp_server=oauth_delegate,
                raw_headers={
                    "x-litellm-api-key": "Bearer sk-litellm-key",
                    "authorization": "Bearer upstream",
                },
                user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
            )
            is False
        )
        assert (
            _should_strip_caller_authorization(
                mcp_server=oauth_delegate,
                raw_headers={"authorization": "Bearer sk-litellm-key"},
                user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"),
            )
            is True
        )

    def test_should_strip_authorization_for_oauth_delegate_admitted_via_jwt_without_api_key(self):
        """JWT / SSO / OIDC / session admission yields a UserAPIKeyAuth with a user_id but
        api_key=None; the caller's Authorization was that credential and must be stripped for
        oauth_delegate when no separate x-litellm-api-key carried admission (LIT-3794-class leak)."""
        from litellm.proxy._types import UserAPIKeyAuth

        oauth_delegate = MCPServer(
            server_id="od-jwt",
            name="od",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        assert (
            _should_strip_caller_authorization(
                mcp_server=oauth_delegate,
                raw_headers={"authorization": "Bearer eyJ-idp-jwt"},
                user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None),
            )
            is True
        )
        assert (
            _should_strip_caller_authorization(
                mcp_server=oauth_delegate,
                raw_headers={
                    "x-litellm-api-key": "Bearer sk-1234",
                    "authorization": "Bearer upstream",
                },
                user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None),
            )
            is False
        )

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_jwt_admission(self):
        from litellm.proxy._types import UserAPIKeyAuth

        server = MCPServer(
            server_id="od-jwt-e2e",
            name="od",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        extra_headers = await self._capture_call_extra_headers(
            server,
            oauth2_headers={"Authorization": "Bearer eyJ-idp-jwt"},
            raw_headers={"authorization": "Bearer eyJ-idp-jwt"},
            user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None),
        )
        assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers}

    def test_new_passthrough_modes_require_per_user_auth(self):
        for auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate):
            server = MCPServer(
                server_id="s",
                name="s",
                url="https://example.com",
                transport=MCPTransport.http,
                auth_type=auth_type,
            )
            assert server.requires_per_user_auth is True

    @pytest.mark.asyncio
    async def test_create_mcp_client_forwarded_modes_use_the_passthrough_arm(self):
        manager = MCPServerManager()
        server = MCPServer(
            server_id="tp-egress",
            name="tp",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth",
                new_callable=AsyncMock,
            ) as mock_resolve,
            patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls,
        ):
            await manager._create_mcp_client(server=server, extra_headers={"Authorization": "Bearer upstream-token"})
        mock_resolve.assert_not_awaited()
        kwargs = mock_client_cls.call_args.kwargs
        emitted = httpx.Request("GET", "https://example.com/mcp")
        flow = kwargs["resolved_auth"].auth_flow(emitted)
        next(flow)
        flow.close()
        assert emitted.headers["Authorization"] == "Bearer upstream-token"
        assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]}

    @pytest.mark.asyncio
    async def test_create_mcp_client_token_exchange_never_falls_back_to_v1(self):
        """A configured OBO server is owned end to end by the v2 token_exchange arm, even when the
        caller supplies an x-mcp-* override. This is what makes the v1 OBO handler unreachable, so if
        it ever defers to v1 again the deleted handler is silently needed back."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
            OAuthToken,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.resolver import (
            UpstreamCredentialProvider,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _StubExchanger:
            def __init__(self):
                self.subject_tokens = []

            async def exchange(self, subject_token, server, config, *, tenant_id=""):
                self.subject_tokens.append(subject_token)
                return Ok(OAuthToken(access_token="exchanged-token"))

            async def invalidate(self, subject_token, server, config, *, tenant_id=""):
                return None

        exchanger = _StubExchanger()
        manager = MCPServerManager()
        server = MCPServer(
            server_id="obo-egress",
            name="obo",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            client_id="gateway-client",
            client_secret="gateway-secret",
            token_exchange_endpoint="https://idp.example.com/oauth2/token",
        )
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth",
                new_callable=AsyncMock,
            ) as mock_resolve,
            patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls,
        ):
            await manager._create_mcp_client(
                server=server,
                mcp_auth_header="Bearer caller-override",
                subject_token="eyJ-subject-token",
                cred_provider=UpstreamCredentialProvider(token_exchanger=exchanger),
            )
        mock_resolve.assert_not_awaited()
        assert exchanger.subject_tokens == ["eyJ-subject-token"]
        assert self._emitted_authorization(mock_client_cls) == "Bearer exchanged-token"

    @staticmethod
    def _emitted_authorization(mock_client_cls) -> str:
        kwargs = mock_client_cls.call_args.kwargs
        emitted = httpx.Request("GET", "https://example.com/mcp")
        flow = kwargs["resolved_auth"].auth_flow(emitted)
        next(flow)
        flow.close()
        return emitted.headers["Authorization"]

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "per_server_header",
        ["Bearer per-server-token", {"Authorization": "Bearer per-server-token"}],
    )
    async def test_create_mcp_client_passthrough_prefers_per_server_token(self, per_server_header):
        """A per-server x-mcp-{alias}-authorization value is the explicit one-token-one-server
        binding, so it must win over the request-wide Authorization and reach the upstream
        verbatim through the passthrough arm."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="tp-per-server",
            name="tp",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth",
                new_callable=AsyncMock,
            ) as mock_resolve,
            patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls,
        ):
            await manager._create_mcp_client(
                server=server,
                mcp_auth_header=per_server_header,
                extra_headers={"Authorization": "Bearer global-token"},
            )
        mock_resolve.assert_not_awaited()
        assert self._emitted_authorization(mock_client_cls) == "Bearer per-server-token"
        kwargs = mock_client_cls.call_args.kwargs
        assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]}

    def test_consumes_caller_authorization_per_mode(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _consumes_caller_authorization,
        )

        def build(**kwargs) -> MCPServer:
            return MCPServer(
                server_id="s",
                name="s",
                url="https://example.com",
                transport=MCPTransport.http,
                **kwargs,
            )

        assert _consumes_caller_authorization(build(auth_type=MCPAuth.true_passthrough)) is True
        assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth_delegate)) is True
        assert (
            _consumes_caller_authorization(
                build(auth_type=MCPAuth.none, extra_headers=["Authorization"], oauth_passthrough=True)
            )
            is True
        )
        assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth2, delegate_auth_to_upstream=True)) is True
        assert _consumes_caller_authorization(build(auth_type=MCPAuth.api_key, authentication_token="x")) is False
        assert (
            _consumes_caller_authorization(
                build(
                    auth_type=MCPAuth.oauth2,
                    delegate_auth_to_upstream=True,
                    oauth2_flow="client_credentials",
                    token_url="https://idp/token",
                )
            )
            is False
        )

    def test_caller_authorization_fans_out_only_with_second_consumer(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _caller_authorization_fans_out,
        )

        delegate = MCPServer(
            server_id="od",
            name="od",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
        )
        second = MCPServer(
            server_id="tp",
            name="tp",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
        )
        static_server = MCPServer(
            server_id="static",
            name="static",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.api_key,
            authentication_token="x",
        )

        assert _caller_authorization_fans_out(delegate, None) is False
        assert _caller_authorization_fans_out(delegate, [delegate]) is False
        assert _caller_authorization_fans_out(delegate, [delegate, static_server]) is False
        assert _caller_authorization_fans_out(delegate, [delegate, second]) is True

    @pytest.mark.asyncio
    async def test_get_prompts_from_server_success(self):
        """Ensure prompts are fetched and prefixed when requested."""
        manager = MCPServerManager()

        server = MCPServer(
            server_id="server-1",
            name="alias-server",
            alias="alias-server",
            server_name="alias-server",
            url="https://example.com",
            transport=MCPTransport.http,
        )

        mock_prompt = Prompt(name="hello", description="Say hi")
        mock_client = AsyncMock()
        mock_client.list_prompts = AsyncMock(return_value=[mock_prompt])

        with patch.object(
            manager,
            "_create_mcp_client",
            new_callable=AsyncMock,
            return_value=mock_client,
        ):
            prompts = await manager.get_prompts_from_server(server, add_prefix=True)

        mock_client.list_prompts.assert_awaited_once()
        assert len(prompts) == 1
        assert prompts[0].name == "alias-server-hello"

    @pytest.mark.asyncio
    async def test_get_prompt_from_server_success(self):
        """Ensure a single prompt definition is requested via the MCP client."""
        manager = MCPServerManager()

        server = MCPServer(
            server_id="server-1",
            name="alias-server",
            alias="alias-server",
            server_name="alias-server",
            url="https://example.com",
            transport=MCPTransport.http,
        )

        mock_result = GetPromptResult(
            description="Hello world prompt",
            messages=[],
        )
        mock_client = AsyncMock()
        mock_client.get_prompt = AsyncMock(return_value=mock_result)

        with patch.object(
            manager,
            "_create_mcp_client",
            new_callable=AsyncMock,
            return_value=mock_client,
        ):
            result = await manager.get_prompt_from_server(
                server=server,
                prompt_name="hello",
                arguments={"tone": "casual"},
            )

        mock_client.get_prompt.assert_awaited_once()
        awaited_call = mock_client.get_prompt.await_args
        called_params = awaited_call.args[0]
        assert called_params.name == "hello"
        assert called_params.arguments == {"tone": "casual"}
        assert result is mock_result

    @pytest.mark.asyncio
    async def test_get_resources_from_server_success(self):
        manager = MCPServerManager()

        server = MCPServer(
            server_id="server-1",
            name="alias-server",
            alias="alias-server",
            server_name="alias-server",
            url="https://example.com",
            transport=MCPTransport.http,
            static_headers={"X-Static": "static"},
        )

        mock_client = AsyncMock()
        mock_resources = [Resource(name="file", uri="https://example.com/file")]
        mock_client.list_resources = AsyncMock(return_value=mock_resources)
        prefixed_resources = [Resource(name="alias-server-file", uri="https://example.com/file")]

        with (
            patch.object(
                manager,
                "_create_mcp_client",
                new_callable=AsyncMock,
                return_value=mock_client,
            ) as mock_create_client,
            patch.object(
                manager,
                "_create_prefixed_resources",
                return_value=prefixed_resources,
            ) as mock_prefix,
        ):
            result = await manager.get_resources_from_server(
                server=server,
                mcp_auth_header="auth",
                extra_headers={"X-Test": "1"},
                add_prefix=True,
            )

        mock_create_client.assert_called_once()
        called_kwargs = mock_create_client.call_args.kwargs
        assert called_kwargs["server"] is server
        assert called_kwargs["mcp_auth_header"] == "auth"
        assert called_kwargs["extra_headers"] == {"X-Test": "1", "X-Static": "static"}
        mock_client.list_resources.assert_awaited_once()
        mock_prefix.assert_called_once_with(mock_resources, server, add_prefix=True)
        assert result == prefixed_resources

    @pytest.mark.asyncio
    async def test_get_resource_templates_from_server_success(self):
        manager = MCPServerManager()

        server = MCPServer(
            server_id="server-1",
            name="alias-server",
            alias="alias-server",
            server_name="alias-server",
            url="https://example.com",
            transport=MCPTransport.http,
        )

        mock_client = AsyncMock()
        mock_templates = [
            ResourceTemplate(
                name="template",
                uriTemplate="https://example.com/{id}",
            )
        ]
        mock_client.list_resource_templates = AsyncMock(return_value=mock_templates)
        prefixed_templates = [
            ResourceTemplate(
                name="alias-server-template",
                uriTemplate="https://example.com/{id}",
            )
        ]

        with (
            patch.object(
                manager,
                "_create_mcp_client",
                new_callable=AsyncMock,
                return_value=mock_client,
            ) as mock_create_client,
            patch.object(
                manager,
                "_create_prefixed_resource_templates",
                return_value=prefixed_templates,
            ) as mock_prefix,
        ):
            result = await manager.get_resource_templates_from_server(
                server=server,
                mcp_auth_header="auth",
                extra_headers=None,
                add_prefix=False,
            )

        mock_create_client.assert_called_once_with(
            server=server,
            mcp_auth_header="auth",
            extra_headers=None,
            stdio_env=None,
            subject_token=None,
        )
        mock_client.list_resource_templates.assert_awaited_once()
        mock_prefix.assert_called_once_with(mock_templates, server, add_prefix=False)
        assert result == prefixed_templates

    @pytest.mark.asyncio
    async def test_read_resource_from_server_success(self):
        manager = MCPServerManager()

        server = MCPServer(
            server_id="server-1",
            name="alias-server",
            alias="alias-server",
            server_name="alias-server",
            url="https://example.com",
            transport=MCPTransport.http,
            static_headers={"X-Static": "1"},
        )

        mock_client = AsyncMock()
        read_result = ReadResourceResult(
            contents=[
                TextResourceContents(
                    uri="https://example.com/resource",
                    text="hello",
                    mimeType="text/plain",
                )
            ]
        )
        mock_client.read_resource = AsyncMock(return_value=read_result)

        with patch.object(
            manager,
            "_create_mcp_client",
            new_callable=AsyncMock,
            return_value=mock_client,
        ) as mock_create_client:
            result = await manager.read_resource_from_server(
                server=server,
                url="https://example.com/resource",
                mcp_auth_header="auth",
                extra_headers={"X-Test": "1"},
            )

        mock_create_client.assert_called_once()
        called_kwargs = mock_create_client.call_args.kwargs
        assert called_kwargs["extra_headers"] == {"X-Test": "1", "X-Static": "1"}
        mock_client.read_resource.assert_awaited_once_with("https://example.com/resource")
        assert result is read_result

    @pytest.mark.asyncio
    async def test_fetch_oauth_metadata_from_resource_returns_servers_and_scopes(self):
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.json.return_value = {
            "authorization_servers": [
                "https://auth1.example.com",
                "https://auth2.example.com",
            ],
            "scopes_supported": ["read", "write"],
        }
        mock_response.raise_for_status = MagicMock()

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://protected.example.com/.well-known/oauth",
                "https://protected.example.com/mcp",
            )

        assert servers == [
            "https://auth1.example.com",
            "https://auth2.example.com",
        ]
        assert scopes == ["read", "write"]

    @pytest.mark.asyncio
    async def test_descovery_metadata_probes_well_known_when_server_does_not_challenge(
        self,
    ):
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.raise_for_status = MagicMock()

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        mock_metadata = MCPOAuthMetadata(
            scopes=None,
            authorization_url="https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
            token_url="https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
            registration_url=None,
        )

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=mock_client,
            ) as get_client,
            patch.object(
                manager,
                "_attempt_well_known_discovery",
                AsyncMock(
                    return_value=(
                        ["https://login.microsoftonline.com/test-tenant-id/v2.0"],
                        ["api://some-scope/.default"],
                    )
                ),
            ) as mock_well_known,
            patch.object(
                manager,
                "_fetch_authorization_server_metadata",
                AsyncMock(return_value=mock_metadata),
            ) as mock_fetch_auth,
        ):
            result = await manager._descovery_metadata("http://localhost:8001/mcp")

        get_client.assert_called_once_with(
            llm_provider=httpxSpecialProvider.MCP,
            params={"timeout": MCP_METADATA_TIMEOUT},
        )
        mock_well_known.assert_awaited_once_with("http://localhost:8001/mcp")
        mock_fetch_auth.assert_awaited_once_with(
            ["https://login.microsoftonline.com/test-tenant-id/v2.0"],
            "http://localhost:8001/mcp",
        )
        assert result is mock_metadata
        assert result.scopes == ["api://some-scope/.default"]
        assert result.from_origin_fallback is False

    @pytest.mark.asyncio
    async def test_descovery_metadata_scopes_are_resource_driven(self):
        """The effective `scopes` are resource-driven: the RFC 9728 protected-resource advertisement
        (or WWW-Authenticate challenge) overrides the authorization server's own scopes_supported. This
        is the MCP Scope Selection Strategy: the client requests what the resource needs, not the AS's
        full capability list."""
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.raise_for_status = MagicMock()
        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        authorization_server_metadata = MCPOAuthMetadata(
            scopes=["as.read", "as.write"],
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
        )

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=mock_client,
            ),
            patch.object(
                manager,
                "_attempt_well_known_discovery",
                AsyncMock(return_value=(["https://idp.example.com"], ["resource.only"])),
            ),
            patch.object(
                manager,
                "_fetch_authorization_server_metadata",
                AsyncMock(return_value=authorization_server_metadata),
            ),
        ):
            result = await manager._descovery_metadata("https://up.example.com/mcp")

        assert result is not None
        assert result.scopes == ["resource.only"]

    @pytest.mark.asyncio
    async def test_fetch_single_authorization_server_metadata_supports_azure_issuer_path(
        self,
    ):
        manager = MCPServerManager()
        issuer = "https://login.microsoftonline.com/test-tenant-id/v2.0"

        def build_response(url: str, **kwargs):
            mock_response = MagicMock()
            if url == f"{issuer}/.well-known/openid-configuration":
                mock_response.json.return_value = {
                    "authorization_endpoint": "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/authorize",
                    "token_endpoint": "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token",
                    "scopes_supported": ["api://some-scope/.default"],
                }
                mock_response.raise_for_status = MagicMock()
            else:
                request = httpx.Request("GET", url)
                response_obj = httpx.Response(status_code=404, request=request)
                mock_response.raise_for_status = MagicMock(
                    side_effect=httpx.HTTPStatusError("not found", request=request, response=response_obj)
                )
            return mock_response

        mock_client = MagicMock()
        mock_client.get = AsyncMock(side_effect=build_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            # The Azure issuer is cross-origin against the server_url — use
            # the issuer itself as server_url so the test exercises the
            # well-known fetch logic without needing real DNS.
            result = await manager._fetch_single_authorization_server_metadata(issuer, issuer)

        assert result is not None
        assert result.authorization_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/authorize"
        assert result.token_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token"
        assert result.scopes == ["api://some-scope/.default"]

    @staticmethod
    def _issuer_doc_response_builder(well_known_url: str, document: dict):
        def build_response(url: str, **kwargs):
            mock_response = MagicMock()
            if url == well_known_url:
                mock_response.json.return_value = document
                mock_response.raise_for_status = MagicMock()
            else:
                request = httpx.Request("GET", url)
                response_obj = httpx.Response(status_code=404, request=request)
                mock_response.raise_for_status = MagicMock(
                    side_effect=httpx.HTTPStatusError("not found", request=request, response=response_obj)
                )
            return mock_response

        return build_response

    @pytest.mark.asyncio
    async def test_fetch_single_authorization_server_metadata_adopts_document_with_matching_issuer(self):
        """RFC 8414 §3.3: under require_issuer, a document that self-attests the same issuer it was
        fetched from is authoritative and its endpoints and scopes are adopted."""
        manager = MCPServerManager()
        issuer = "https://idp.example.com"
        build_response = self._issuer_doc_response_builder(
            f"{issuer}/.well-known/oauth-authorization-server",
            {
                "issuer": issuer,
                "authorization_endpoint": "https://idp.example.com/authorize",
                "token_endpoint": "https://idp.example.com/token",
                "scopes_supported": ["read", "write"],
            },
        )
        mock_client = MagicMock()
        mock_client.get = AsyncMock(side_effect=build_response)
        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(issuer, issuer, require_issuer=issuer)

        assert result is not None
        assert result.authorization_url == "https://idp.example.com/authorize"
        assert result.token_url == "https://idp.example.com/token"
        assert result.scopes == ["read", "write"]

    @pytest.mark.asyncio
    async def test_fetch_single_authorization_server_metadata_rejects_issuer_mismatch(self):
        """RFC 8414 §3.3 fail-closed: a document self-attesting a DIFFERENT issuer than the one it was
        fetched from is rejected even though it carries valid-looking endpoints, so a compromised
        resource cannot point the issuer-anchored fetch at an attacker authorization server that
        smuggles its own token_endpoint and inflated scopes."""
        manager = MCPServerManager()
        issuer = "https://idp.example.com"
        build_response = self._issuer_doc_response_builder(
            f"{issuer}/.well-known/oauth-authorization-server",
            {
                "issuer": "https://attacker.example.com",
                "authorization_endpoint": "https://idp.example.com/authorize",
                "token_endpoint": "https://attacker.example.com/steal",
                "scopes_supported": ["admin"],
            },
        )
        mock_client = MagicMock()
        mock_client.get = AsyncMock(side_effect=build_response)
        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(issuer, issuer, require_issuer=issuer)

        assert result is None

    @pytest.mark.asyncio
    async def test_fetch_single_authorization_server_metadata_derives_azure_metadata(
        self,
    ):
        manager = MCPServerManager()
        issuer = "https://login.microsoftonline.com/test-tenant-id/v2.0"

        request = httpx.Request("GET", issuer)
        response_obj = httpx.Response(status_code=404, request=request)
        mock_response = MagicMock()
        mock_response.raise_for_status = MagicMock(
            side_effect=httpx.HTTPStatusError("not found", request=request, response=response_obj)
        )

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(issuer, issuer)

        assert result is not None
        assert result.authorization_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/authorize"
        assert result.token_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token"

    @pytest.mark.asyncio
    async def test_azure_heuristic_reachable_under_require_issuer(self):
        """Under issuer-anchored discovery (require_issuer set), an Entra issuer whose OIDC document
        cannot be fetched still gets the deterministic Azure endpoint construction. The heuristic
        derives the endpoints from the pinned issuer's own tenant URL, so it is authoritative-by-
        construction and safe under require_issuer; only a non-Entra issuer stays fail-closed (None)."""
        manager = MCPServerManager()
        issuer = "https://login.microsoftonline.com/test-tenant-id/v2.0"

        request = httpx.Request("GET", issuer)
        response_obj = httpx.Response(status_code=404, request=request)
        mock_response = MagicMock()
        mock_response.raise_for_status = MagicMock(
            side_effect=httpx.HTTPStatusError("not found", request=request, response=response_obj)
        )
        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            azure = await manager._fetch_single_authorization_server_metadata(issuer, issuer, require_issuer=issuer)
            non_entra = await manager._fetch_single_authorization_server_metadata(
                "https://idp.example.com", "https://idp.example.com", require_issuer="https://idp.example.com"
            )

        assert azure is not None
        assert azure.token_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token"
        assert non_entra is None

    @pytest.mark.asyncio
    async def test_descovery_metadata_falls_back_to_origin_when_no_auth_servers(self):
        manager = MCPServerManager()
        server_url = "https://example.com/public/mcp"

        request = httpx.Request("GET", server_url)
        response_obj = httpx.Response(
            status_code=401,
            request=request,
            headers={"WWW-Authenticate": 'Bearer scope="read"'},
        )

        def raise_http_error():
            raise httpx.HTTPStatusError("unauthorized", request=request, response=response_obj)

        response_obj.raise_for_status = MagicMock(side_effect=raise_http_error)

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=response_obj)

        mock_metadata = MCPOAuthMetadata(
            scopes=None,
            authorization_url="https://example.com/auth",
            token_url="https://example.com/token",
            registration_url=None,
        )

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=mock_client,
            ),
            patch.object(
                manager,
                "_fetch_oauth_metadata_from_resource",
                AsyncMock(return_value=([], None)),
            ),
            patch.object(
                manager,
                "_attempt_well_known_discovery",
                AsyncMock(return_value=([], None)),
            ),
            patch.object(
                manager,
                "_fetch_authorization_server_metadata",
                AsyncMock(return_value=mock_metadata),
            ) as mock_fetch_auth,
        ):
            result = await manager._descovery_metadata(server_url)

        mock_fetch_auth.assert_awaited_once_with(["https://example.com"], server_url)
        assert result is mock_metadata
        assert result.scopes == ["read"]
        assert result.from_origin_fallback is True

    @pytest.mark.asyncio
    async def test_load_servers_from_config_overrides_discovery_metadata(self):
        """Config values win per field. The discovered token_url/registration_url do NOT fill the
        blanks here: the document advertises a different authorization_endpoint than the manually
        configured one, so combining its endpoints with the pinned authorize URL would be the
        config-time mix-up the discovery gate exists to prevent."""
        manager = MCPServerManager()

        discovered_metadata = MCPOAuthMetadata(
            scopes=["discovered"],
            authorization_url="https://discovered.example.com/auth",
            token_url="https://discovered.example.com/token",
            registration_url="https://discovered.example.com/register",
        )

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            assert server_url == "https://example.com/mcp"
            # oauth2 (browser flow) keeps the origin fallback; only OBO disables it.
            assert allow_origin_fallback is True
            return discovered_metadata

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        config = {
            "example": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2,
                "oauth2_flow": "authorization_code",
                "scopes": ["config"],
                "authorization_url": "https://config.example.com/auth",
            }
        }

        await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.scopes == ["config"]  # config overrides discovery
        assert server.authorization_url == "https://config.example.com/auth"
        assert server.token_url is None
        assert server.registration_url is None

    @pytest.mark.asyncio
    async def test_load_servers_from_config_filters_blank_scopes(self):
        """A YAML ``scopes: [""]`` must normalize to None (matching the DB path), so a blank-only
        list never becomes a ``("",)`` tuple that skips the entra_obo fail-closed scope check."""
        manager = MCPServerManager()
        config = {
            "entra": {
                "url": "https://up.example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2_token_exchange,
                "token_exchange_profile": "entra_obo",
                "token_exchange_endpoint": "https://login.microsoftonline.com/t/oauth2/v2.0/token",
                "client_id": "cid",
                "client_secret": "csec",
                "scopes": ["", "  "],
            }
        }

        await manager.load_servers_from_config(config)

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.scopes is None

        # And the exchange precondition now fails closed before any IdP call.
        from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import to_server_spec
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error
        from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchanger import (
            OboTokenExchanger,
        )

        spec = to_server_spec(server)
        assert spec is not None
        assert spec.config.scopes == ()

        async def _must_not_post(url, form, headers):
            raise AssertionError("entra_obo with blank scopes must fail closed before POSTing to the IdP")

        result = await OboTokenExchanger(_must_not_post).exchange("subj", spec, spec.config)
        assert isinstance(result, Error)
        assert result.error.tag == "misconfigured"

    @pytest.mark.asyncio
    async def test_load_servers_from_config_reads_all_token_exchange_fields(self):
        """Every token-exchange setting is configurable through config.yaml as a top-level
        key (the config counterpart of the REST/UI columns) and reaches the resolver spec;
        omitted keys resolve to their documented defaults. token_exchange servers need no
        oauth2_flow (that requirement is oauth2-only)."""
        from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE

        manager = MCPServerManager()
        config = {
            "te_full": {
                "url": "https://up.example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2_token_exchange,
                "token_exchange_endpoint": "https://idp.example.com/oauth2/token",
                "audience": "api://upstream",
                "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
                "token_exchange_profile": "entra_obo",
                "client_id": "cid",
                "client_secret": "csec",
                "scopes": ["api://upstream/.default"],
            },
            "te_minimal": {
                "url": "https://up2.example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2_token_exchange,
                "token_exchange_endpoint": "https://idp2.example.com/oauth2/token",
                "client_id": "cid2",
                "client_secret": "csec2",
            },
        }

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            await manager.load_servers_from_config(config)

        by_name = {s.server_name: s for s in manager.config_mcp_servers.values()}

        full = by_name["te_full"]
        assert full.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
        assert full.audience == "api://upstream"
        assert full.subject_token_type == "urn:ietf:params:oauth:token-type:jwt"
        assert full.token_exchange_profile == "entra_obo"

        minimal = by_name["te_minimal"]
        assert minimal.audience is None
        assert minimal.subject_token_type == DEFAULT_SUBJECT_TOKEN_TYPE
        assert minimal.token_exchange_profile == "rfc8693"

        from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import to_server_spec

        spec = to_server_spec(full)
        assert spec is not None
        assert spec.config.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
        assert spec.config.profile == "entra_obo"

        minimal_spec = to_server_spec(minimal)
        assert minimal_spec is not None
        assert minimal_spec.config.subject_token_type == DEFAULT_SUBJECT_TOKEN_TYPE
        assert minimal_spec.config.profile == "rfc8693"

    @pytest.mark.asyncio
    async def test_config_oauth_initialize_tool_name_to_mcp_server_name_mapping(self):
        manager = MCPServerManager()

        config = {
            "example": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2,
                "oauth2_flow": "authorization_code",
                "scopes": ["config"],
                "authorization_url": "https://config.example.com/auth",
            }
        }

        await manager.load_servers_from_config(config)

        # Initialize the tool mapping
        await manager._initialize_tool_name_to_mcp_server_name_mapping()
        assert manager.tool_name_to_mcp_server_name_mapping == {}

    @pytest.mark.asyncio
    async def test_list_tools_handles_missing_server_alias(self):
        """Test that list_tools handles servers without alias gracefully"""
        manager = MCPServerManager()

        # Mock server without alias
        server = MagicMock()
        server.name = "github"
        server.alias = None  # No alias
        server.server_name = "github"

        # Mock get_allowed_mcp_servers
        manager.get_allowed_mcp_servers = AsyncMock(return_value=["github"])
        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock _get_tools_from_server
        async def mock_get_tools_from_server(
            server,
            mcp_auth_header=None,
            **kwargs,
        ):
            assert mcp_auth_header == "server-specific-token"  # Should use server-specific header via server_name
            tool = MagicMock()
            tool.name = "github_tool_1"
            return [tool]

        manager._get_tools_from_server = mock_get_tools_from_server

        # Test with server-specific headers that match server_name (even without alias)
        result = await manager.list_tools(
            mcp_auth_header="legacy-token",
            mcp_server_auth_headers={"github": "server-specific-token"},
        )

        assert len(result) == 1
        assert result[0].name == "github_tool_1"

    @pytest.mark.asyncio
    async def test_health_check_server_healthy(self):
        """Test health check for a healthy server"""
        manager = MCPServerManager()

        # Mock server
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            auth_type=None,
            authentication_token="test-token",
            url="http://test-server.com",
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock successful client.run_with_session
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(return_value="ok")
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        # Perform health check
        result = await manager.health_check_server("test-server")

        # Verify results - result is now LiteLLM_MCPServerTable
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "test-server"
        assert result.status == "healthy"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_server_unhealthy(self):
        """Test health check for an unhealthy server"""
        manager = MCPServerManager()

        # Mock server
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            auth_type=None,
            authentication_token="test-token",
            url="http://test-server.com",
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock failed client.run_with_session
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(side_effect=Exception("Connection timeout"))
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        # Perform health check
        result = await manager.health_check_server("test-server")

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "test-server"
        assert result.status == "unhealthy"
        assert result.health_check_error == "Connection timeout"
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_server_contains_client_creation_failure(self):
        """Deferred discovery failures are reported unhealthy, not raised."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="discovery-failure",
            name="discovery-failure",
            transport=MCPTransport.http,
            auth_type=None,
            authentication_token="test-token",
            url="https://up.example.com/mcp",
        )
        manager.get_mcp_server_by_id = MagicMock(return_value=server)
        manager._resolve_static_headers_with_env_vars = AsyncMock(return_value=None)
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(status_code=503, detail="OAuth discovery unavailable")
        )

        result = await manager.health_check_server(server.server_id)

        assert result.status == "unhealthy"
        assert "OAuth discovery unavailable" in (result.health_check_error or "")

    @pytest.mark.asyncio
    async def test_health_check_server_not_found(self):
        """Test health check for a server that doesn't exist"""
        manager = MCPServerManager()

        # Mock server not found
        manager.get_mcp_server_by_id = MagicMock(return_value=None)

        # Perform health check
        result = await manager.health_check_server("non-existent-server")

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "non-existent-server"
        assert result.server_name is None
        assert result.status == "unknown"
        assert result.health_check_error == "Server not found"
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_server_oauth2_skips_check(self):
        """Test that health check is skipped for OAuth2 servers and returns unknown status"""
        manager = MCPServerManager()

        # Mock OAuth2 server
        server = MCPServer(
            server_id="oauth2-server",
            name="oauth2-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            url="http://oauth2-server.com",
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # _create_mcp_client should not be called for OAuth2 servers
        manager._create_mcp_client = AsyncMock()

        # Perform health check
        result = await manager.health_check_server("oauth2-server")

        # Verify that client was not created (health check was skipped)
        manager._create_mcp_client.assert_not_called()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "oauth2-server"
        assert result.status == "unknown"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_server_no_token_skips_check(self):
        """Test that health check is skipped when auth_type is set but authentication_token is missing"""
        manager = MCPServerManager()

        # Mock server with auth_type but no authentication_token
        server = MCPServer(
            server_id="no-token-server",
            name="no-token-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.bearer_token,
            authentication_token=None,  # No token
            url="http://no-token-server.com",
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # _create_mcp_client should not be called
        manager._create_mcp_client = AsyncMock()

        # Perform health check
        result = await manager.health_check_server("no-token-server")

        # Verify that client was not created (health check was skipped)
        manager._create_mcp_client.assert_not_called()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "no-token-server"
        assert result.status == "unknown"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_server_with_static_headers(self):
        """Test health check with static headers configured"""
        manager = MCPServerManager()

        # Mock server with static_headers
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            auth_type=None,
            authentication_token="test-token",
            url="http://test-server.com",
            static_headers={"X-Custom-Header": "custom-value"},
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock successful client
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(return_value="ok")

        # Capture the extra_headers passed to _create_mcp_client
        captured_extra_headers = None

        async def capture_create_mcp_client(server, mcp_auth_header, extra_headers, stdio_env):
            nonlocal captured_extra_headers
            captured_extra_headers = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client)

        # Perform health check
        result = await manager.health_check_server("test-server")

        # Verify static headers were passed
        assert captured_extra_headers == {"X-Custom-Header": "custom-value"}

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "test-server"
        assert result.status == "healthy"
        assert result.health_check_error is None

    @pytest.mark.asyncio
    async def test_health_check_skips_passthrough_auth_with_authorization_header(self):
        """Test that health check is skipped for servers with passthrough Authorization header"""
        manager = MCPServerManager()

        # Mock server with auth_type=none and Authorization in extra_headers (passthrough auth)
        server = MCPServer(
            server_id="github-server",
            name="github-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            authentication_token=None,
            url="http://github-server.com",
            extra_headers=["Authorization"],  # Passthrough auth configured
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # _create_mcp_client should not be called (health check should be skipped)
        manager._create_mcp_client = AsyncMock()

        # Perform health check
        result = await manager.health_check_server("github-server")

        # Verify that client was not created (health check was skipped)
        manager._create_mcp_client.assert_not_called()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "github-server"
        assert result.status == "unknown"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_skips_passthrough_auth_with_api_key_header(self):
        """Test that health check is skipped for servers with passthrough x-api-key header"""
        manager = MCPServerManager()

        # Mock server with auth_type=none and x-api-key in extra_headers
        server = MCPServer(
            server_id="sourcegraph-server",
            name="sourcegraph-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            authentication_token=None,
            url="http://sourcegraph-server.com",
            extra_headers=["x-api-key"],  # Passthrough auth configured
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # _create_mcp_client should not be called
        manager._create_mcp_client = AsyncMock()

        # Perform health check
        result = await manager.health_check_server("sourcegraph-server")

        # Verify that client was not created (health check was skipped)
        manager._create_mcp_client.assert_not_called()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "sourcegraph-server"
        assert result.status == "unknown"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_runs_when_no_passthrough_auth(self):
        """Test that health check runs normally for servers with auth_type=none but no passthrough headers"""
        manager = MCPServerManager()

        # Mock server with auth_type=none but no extra_headers (no passthrough auth)
        server = MCPServer(
            server_id="public-server",
            name="public-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            authentication_token=None,
            url="http://public-server.com",
            extra_headers=None,  # No passthrough auth
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock successful client
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(return_value="ok")
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        # Perform health check
        result = await manager.health_check_server("public-server")

        # Verify that client WAS created (health check should run)
        manager._create_mcp_client.assert_called_once()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "public-server"
        assert result.status == "healthy"
        assert result.health_check_error is None
        assert result.last_health_check is not None

    @pytest.mark.asyncio
    async def test_health_check_runs_when_extra_headers_no_auth(self):
        """Test that health check runs when extra_headers exist but don't include auth headers"""
        manager = MCPServerManager()

        # Mock server with extra_headers but no auth-related headers
        server = MCPServer(
            server_id="custom-server",
            name="custom-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            authentication_token=None,
            url="http://custom-server.com",
            extra_headers=["X-Custom-Header", "X-Request-ID"],  # Non-auth headers
        )

        manager.get_mcp_server_by_id = MagicMock(return_value=server)

        # Mock successful client
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(return_value="ok")
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        # Perform health check
        result = await manager.health_check_server("custom-server")

        # Verify that client WAS created (health check should run)
        manager._create_mcp_client.assert_called_once()

        # Verify results
        assert isinstance(result, LiteLLM_MCPServerTable)
        assert result.server_id == "custom-server"
        assert result.status == "healthy"
        assert result.health_check_error is None

    @pytest.mark.asyncio
    async def test_requires_per_user_auth_property_oauth2(self):
        """Test that requires_per_user_auth returns True for OAuth2 without client credentials"""
        # OAuth2 without client credentials
        server = MCPServer(
            server_id="oauth-server",
            name="oauth-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            url="http://oauth-server.com",
            client_id=None,
            client_secret=None,
            token_url=None,
        )
        assert server.requires_per_user_auth is True
        assert server.needs_user_oauth_token is True

    @pytest.mark.asyncio
    async def test_requires_per_user_auth_property_oauth2_with_client_creds(self):
        """Test that requires_per_user_auth returns False for OAuth2 with client credentials"""
        # M2M must be opted in explicitly with oauth2_flow="client_credentials"
        server = MCPServer(
            server_id="oauth-server",
            name="oauth-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            url="http://oauth-server.com",
            client_id="client-id",
            client_secret="client-secret",
            token_url="http://oauth-server.com/token",
            oauth2_flow="client_credentials",
        )
        assert server.requires_per_user_auth is False
        assert server.has_client_credentials is True

    @pytest.mark.asyncio
    async def test_requires_per_user_auth_property_passthrough_auth(self):
        """Test that requires_per_user_auth returns True for passthrough auth (auth_type=none + Authorization header)"""
        # Passthrough auth with Authorization header
        server = MCPServer(
            server_id="github-server",
            name="github-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            url="http://github-server.com",
            extra_headers=["Authorization"],
        )
        assert server.requires_per_user_auth is True

        # Passthrough auth with x-api-key header
        server2 = MCPServer(
            server_id="sourcegraph-server",
            name="sourcegraph-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            url="http://sourcegraph-server.com",
            extra_headers=["x-api-key"],
        )
        assert server2.requires_per_user_auth is True

        # Passthrough auth with api-key header (case insensitive)
        server3 = MCPServer(
            server_id="api-server",
            name="api-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            url="http://api-server.com",
            extra_headers=["API-Key"],
        )
        assert server3.requires_per_user_auth is True

    @pytest.mark.asyncio
    async def test_requires_per_user_auth_property_no_passthrough(self):
        """Test that requires_per_user_auth returns False when no passthrough auth is configured"""
        # auth_type=none but no extra_headers
        server = MCPServer(
            server_id="public-server",
            name="public-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            url="http://public-server.com",
            extra_headers=None,
        )
        assert server.requires_per_user_auth is False

        # auth_type=none with non-auth extra_headers
        server2 = MCPServer(
            server_id="custom-server",
            name="custom-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            url="http://custom-server.com",
            extra_headers=["X-Custom-Header", "X-Request-ID"],
        )
        assert server2.requires_per_user_auth is False

    @pytest.mark.asyncio
    async def test_register_openapi_tools_includes_static_headers(self, tmp_path):
        """Ensure OpenAPI-to-MCP tool calls include server.static_headers (Issue #19341)."""
        manager = MCPServerManager()

        spec_path = tmp_path / "openapi.json"
        spec_path.write_text(
            json.dumps(
                {
                    "openapi": "3.0.0",
                    "info": {"title": "Demo", "version": "1.0.0"},
                    "paths": {
                        "/health": {
                            "get": {
                                "operationId": "health_check",
                                "summary": "health",
                            }
                        }
                    },
                }
            )
        )

        server = MCPServer(
            server_id="openapi-server",
            name="openapi-server",
            server_name="openapi-server",
            url="https://example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            static_headers={"Authorization": "STATIC token"},
        )

        captured: dict = {}

        def fake_create_tool_function(
            path, method, operation, base_url, headers=None, server_label=None, relays_upstream_auth=False
        ):
            captured["headers"] = headers
            captured["server_label"] = server_label
            captured["relays_upstream_auth"] = relays_upstream_auth

            async def tool_func(**kwargs):
                return "ok"

            return tool_func

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.create_tool_function",
                side_effect=fake_create_tool_function,
            ),
            patch(
                "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.build_input_schema",
                return_value={"type": "object", "properties": {}, "required": []},
            ),
            patch(
                "litellm.proxy._experimental.mcp_server.tool_registry.global_mcp_tool_registry.register_tool",
                return_value=None,
            ),
        ):
            await manager._register_openapi_tools(
                spec_path=str(spec_path),
                server=server,
                base_url="https://example.com",
            )

        assert captured["headers"] is not None
        assert captured["headers"]["Authorization"] == "STATIC token"
        # The label names the server in an upstream-failure error, so registration must thread it;
        # without this the fake would simply tolerate the argument and prove nothing about it.
        assert captured["server_label"] == "openapi-server"
        # auth_type is none here, so a 401 from this upstream must not be dressed up as a re-auth signal
        assert captured["relays_upstream_auth"] is False

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_allowed_tools_list_allows_tool(self):
        """Test pre_call_tool_check allows tool when it's in allowed_tools list"""
        manager = MCPServerManager()

        # Create server with allowed_tools list
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=["allowed_tool", "another_allowed_tool"],
            disallowed_tools=None,
        )

        # Mock dependencies - set object_permission and object_permission_id to None
        # so permission checks return None (no restrictions)
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None
        proxy_logging_obj = MagicMock()

        # Mock the async methods that pre_call_tool_check calls
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})

        # This should not raise an exception
        await manager.pre_call_tool_check(
            name="allowed_tool",
            arguments={"param": "value"},
            server_name="test-server",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_allowed_tools_list_blocks_tool(self):
        """Test pre_call_tool_check blocks tool when it's not in allowed_tools list"""
        manager = MCPServerManager()

        # Create server with allowed_tools list
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=["allowed_tool", "another_allowed_tool"],
            disallowed_tools=None,
        )

        # Mock dependencies
        user_api_key_auth = MagicMock()
        proxy_logging_obj = MagicMock()

        # This should raise an HTTPException
        with pytest.raises(HTTPException) as exc_info:
            await manager.pre_call_tool_check(
                name="blocked_tool",
                arguments={"param": "value"},
                server_name="test-server",
                user_api_key_auth=user_api_key_auth,
                proxy_logging_obj=proxy_logging_obj,
                server=server,
            )

        assert exc_info.value.status_code == 403
        assert "Tool blocked_tool is not allowed for server test-server" in exc_info.value.detail["error"]
        assert "Contact proxy admin to allow this tool" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_disallowed_tools_list_allows_tool(self):
        """Test pre_call_tool_check allows tool when it's not in disallowed_tools list"""
        manager = MCPServerManager()

        # Create server with disallowed_tools list
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=None,
            disallowed_tools=["banned_tool", "another_banned_tool"],
        )

        # Mock dependencies - set object_permission and object_permission_id to None
        # so permission checks return None (no restrictions)
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None
        proxy_logging_obj = MagicMock()

        # Mock the async methods that pre_call_tool_check calls
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})

        # This should not raise an exception
        await manager.pre_call_tool_check(
            name="allowed_tool",
            arguments={"param": "value"},
            server_name="test-server",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_disallowed_tools_list_blocks_tool(self):
        """Test pre_call_tool_check blocks tool when it's in disallowed_tools list"""
        manager = MCPServerManager()

        # Create server with disallowed_tools list
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=None,
            disallowed_tools=["banned_tool", "another_banned_tool"],
        )

        # Mock dependencies
        user_api_key_auth = MagicMock()
        proxy_logging_obj = MagicMock()

        # This should raise an HTTPException
        with pytest.raises(HTTPException) as exc_info:
            await manager.pre_call_tool_check(
                name="banned_tool",
                arguments={"param": "value"},
                server_name="test-server",
                user_api_key_auth=user_api_key_auth,
                proxy_logging_obj=proxy_logging_obj,
                server=server,
            )

        assert exc_info.value.status_code == 403
        assert "Tool banned_tool is not allowed for server test-server" in exc_info.value.detail["error"]
        assert "Contact proxy admin to allow this tool" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_no_restrictions_allows_any_tool(self):
        """Test pre_call_tool_check allows any tool when no restrictions are set"""
        manager = MCPServerManager()

        # Create server with no tool restrictions
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=None,
            disallowed_tools=None,
        )

        # Mock dependencies - set object_permission and object_permission_id to None
        # so permission checks return None (no restrictions)
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None
        proxy_logging_obj = MagicMock()

        # Mock the async methods that pre_call_tool_check calls
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})

        # This should not raise an exception
        await manager.pre_call_tool_check(
            name="any_tool",
            arguments={"param": "value"},
            server_name="test-server",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

    @pytest.mark.asyncio
    async def test_pre_call_tool_check_allowed_tools_takes_precedence(self):
        """Test that allowed_tools list takes precedence over disallowed_tools list"""
        manager = MCPServerManager()

        # Create server with both allowed_tools and disallowed_tools
        # Note: The logic in check_allowed_or_banned_tools prioritizes allowed_tools
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.stdio,
            allowed_tools=["tool1", "tool2"],
            disallowed_tools=["tool2", "tool3"],  # tool2 is in both lists
        )

        # Mock dependencies - set object_permission and object_permission_id to None
        # so permission checks return None (no restrictions)
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None
        proxy_logging_obj = MagicMock()

        # Mock the async methods that pre_call_tool_check calls
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})

        # tool2 should be allowed since it's in allowed_tools (takes precedence)
        await manager.pre_call_tool_check(
            name="tool2",
            arguments={"param": "value"},
            server_name="test-server",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

        # tool3 should be blocked since it's not in allowed_tools
        with pytest.raises(HTTPException) as exc_info:
            await manager.pre_call_tool_check(
                name="tool3",
                arguments={"param": "value"},
                server_name="test-server",
                user_api_key_auth=user_api_key_auth,
                proxy_logging_obj=proxy_logging_obj,
                server=server,
            )

        assert exc_info.value.status_code == 403
        assert "Tool tool3 is not allowed for server test-server" in exc_info.value.detail["error"]

    async def test_get_tools_from_server_add_prefix(self):
        """Verify _get_tools_from_server respects add_prefix True/False."""
        manager = MCPServerManager()

        # Create a minimal server with alias used as prefix
        server = MCPServer(
            server_id="zapier",
            name="zapier",
            transport=MCPTransport.http,
        )

        # Mock client creation and fetching tools
        manager._create_mcp_client = AsyncMock(return_value=object())

        # Tools returned upstream (unprefixed from provider)
        upstream_tool = MCPTool(
            name="send_email",
            description="Send an email",
            inputSchema={},
        )

        manager._fetch_tools_with_timeout = AsyncMock(return_value=[upstream_tool])

        # Case 1: add_prefix=True (default for multi-server) -> expect prefixed
        tools_prefixed = await manager._get_tools_from_server(server, add_prefix=True)
        assert len(tools_prefixed) == 1
        assert tools_prefixed[0].name == "zapier-send_email"

        # Case 2: add_prefix=False (single-server) -> expect unprefixed
        tools_unprefixed = await manager._get_tools_from_server(server, add_prefix=False)
        assert len(tools_unprefixed) == 1
        assert tools_unprefixed[0].name == "send_email"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_jwt_skipped_when_mcp_auth_header_set(self):
        """When a per-user mcp_auth_header is resolved, JWT injection must be skipped.

        MCPClient._get_auth_headers() applies extra_headers AFTER writing
        Authorization from auth_value, so an injected JWT would clobber the
        user's per-server OAuth token. Regression test for that interaction.
        """
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="zapier",
            name="zapier",
            transport=MCPTransport.http,
        )

        manager._create_mcp_client = AsyncMock(return_value=object())
        manager._fetch_tools_with_timeout = AsyncMock(return_value=[])

        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")

        with (
            patch(
                "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.get_mcp_jwt_signer",
                return_value=MagicMock(),
            ),
            patch(
                "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.inject_mcp_jwt_headers_for_upstream",
                new=AsyncMock(return_value={"Authorization": "Bearer signed-jwt"}),
            ) as mock_inject,
        ):
            # Case A: mcp_auth_header present -> JWT must NOT be injected
            await manager._get_tools_from_server(
                server,
                mcp_auth_header="oauth-user-token",
                user_api_key_auth=user_auth,
            )
            mock_inject.assert_not_called()

            # Case B: no mcp_auth_header -> JWT injection runs as before
            await manager._get_tools_from_server(
                server,
                user_api_key_auth=user_auth,
            )
            mock_inject.assert_awaited_once()

    def test_resolve_mcp_server_for_tool_call_via_prefixed_name(self):
        """Resolution succeeds when the prefixed tool name is in the mapping."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="jira",
            name="jira",
            transport=MCPTransport.http,
        )
        manager.registry = {"jira": server}
        manager.tool_name_to_mcp_server_name_mapping["jira-search_issues"] = "jira"
        manager.tool_name_to_mcp_server_name_mapping["search_issues"] = "jira"

        resolved = manager._resolve_mcp_server_for_tool_call("jira", "search_issues")
        assert resolved is server

    def test_resolve_mcp_server_for_tool_call_via_alias(self):
        """Resolution falls back to alias/server_name match in the registry."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="srv-uuid-123",
            name="zapier",
            alias="zapier-alias",
            transport=MCPTransport.http,
        )
        manager.registry = {"srv-uuid-123": server}
        manager.tool_name_to_mcp_server_name_mapping["create_zap"] = "zapier"

        resolved = manager._resolve_mcp_server_for_tool_call("zapier-alias", "create_zap")
        assert resolved is server

    def test_resolve_mcp_server_for_tool_call_unknown_tool_with_empty_mapping(self):
        """Server-name match alone must not let unknown tools through when the
        mapping has no entries for that server (e.g. listing has not completed
        or the server is OAuth2 and the user has not yet listed tools).
        """
        manager = MCPServerManager()
        server = MCPServer(
            server_id="srv-uuid-123",
            name="zapier",
            alias="zapier-alias",
            transport=MCPTransport.http,
        )
        manager.registry = {"srv-uuid-123": server}

        with pytest.raises(ValueError, match="Tool create_zap not found"):
            manager._resolve_mcp_server_for_tool_call("zapier-alias", "create_zap")

    def test_resolve_mcp_server_for_tool_call_fallback_to_unprefixed_lookup(self):
        """Fallback to unprefixed _get_mcp_server_from_tool_name when other paths fail."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="linear",
            name="linear",
            transport=MCPTransport.http,
        )
        manager.registry = {"linear": server}
        manager.tool_name_to_mcp_server_name_mapping["create_issue"] = "linear"

        # server_name is empty so the fallback unprefixed lookup runs and matches.
        resolved = manager._resolve_mcp_server_for_tool_call("", "create_issue")
        assert resolved is server

    def test_resolve_mcp_server_for_tool_call_raises_when_not_found(self):
        """ValueError is raised when no resolution path finds the tool."""
        manager = MCPServerManager()
        with pytest.raises(ValueError, match="Tool .* not found"):
            manager._resolve_mcp_server_for_tool_call("nonexistent", "ghost_tool")

    def test_resolve_mcp_server_for_tool_call_unscoped_cached_tool_still_fails(self):
        """Without an explicit server, an unmapped tool remains ambiguous."""
        manager = MCPServerManager()
        manager.registry = {
            "github": MCPServer(
                server_id="github",
                name="github",
                transport=MCPTransport.http,
            )
        }

        with pytest.raises(ValueError, match="Tool cached_tool not found"):
            manager._resolve_mcp_server_for_tool_call("", "cached_tool")

    def test_resolve_mcp_server_for_tool_call_unknown_tool_with_known_server(self):
        """Server-name match alone must not let unknown tools slip through.

        If the registry has tools for this server but neither the prefixed nor
        unprefixed tool name is in the mapping, raise rather than returning the
        server (would otherwise allow tool enumeration via name spoofing).
        """
        manager = MCPServerManager()
        server = MCPServer(
            server_id="github",
            name="github",
            transport=MCPTransport.http,
        )
        manager.registry = {"github": server}
        # Mapping has *some* tools for github but not "missing_tool".
        manager.tool_name_to_mcp_server_name_mapping["github-list_repos"] = "github"
        manager.tool_name_to_mcp_server_name_mapping["list_repos"] = "github"

        with pytest.raises(ValueError, match="Tool missing_tool not found"):
            manager._resolve_mcp_server_for_tool_call("github", "missing_tool")

    @staticmethod
    def _manager_with_deepwiki_and_huggingface() -> MCPServerManager:
        manager = MCPServerManager()
        deepwiki = MCPServer(
            server_id="deepwiki-id", name="deepwiki", server_name="deepwiki", transport=MCPTransport.http
        )
        huggingface = MCPServer(
            server_id="huggingface-id", name="huggingface", server_name="huggingface", transport=MCPTransport.http
        )
        manager.registry = {"deepwiki-id": deepwiki, "huggingface-id": huggingface}
        manager.tool_name_to_mcp_server_name_mapping = {
            "read_wiki_structure": "deepwiki",
            "deepwiki-read_wiki_structure": "deepwiki",
            "hub_repo_search": "huggingface",
            "huggingface-hub_repo_search": "huggingface",
        }
        return manager

    def test_resolve_mcp_server_for_tool_call_rejects_tool_exposed_only_by_another_server(self):
        manager = self._manager_with_deepwiki_and_huggingface()

        with pytest.raises(ValueError, match="Tool read_wiki_structure not found"):
            manager._resolve_mcp_server_for_tool_call("huggingface", "read_wiki_structure")
        with pytest.raises(ValueError, match="Tool hub_repo_search not found"):
            manager._resolve_mcp_server_for_tool_call("deepwiki", "hub_repo_search")

        assert (
            manager._resolve_mcp_server_for_tool_call("deepwiki", "read_wiki_structure")
            is manager.registry["deepwiki-id"]
        )
        assert (
            manager._resolve_mcp_server_for_tool_call("huggingface", "hub_repo_search")
            is manager.registry["huggingface-id"]
        )

    def test_get_mcp_server_from_tool_name_rejects_other_servers_prefix(self):
        manager = self._manager_with_deepwiki_and_huggingface()

        assert manager._get_mcp_server_from_tool_name("huggingface-read_wiki_structure") is None
        assert manager._get_mcp_server_from_tool_name("deepwiki-hub_repo_search") is None
        assert manager._get_mcp_server_from_tool_name("deepwiki-read_wiki_structure") is manager.registry["deepwiki-id"]
        assert (
            manager._get_mcp_server_from_tool_name("huggingface-hub_repo_search") is manager.registry["huggingface-id"]
        )

    def test_resolve_mcp_server_for_tool_call_shared_bare_name_resolves_via_own_prefixed_spelling(self):
        manager = MCPServerManager()
        zapier = MCPServer(server_id="zapier-id", name="zapier", alias="zapier-alias", transport=MCPTransport.http)
        other = MCPServer(server_id="other-id", name="other", server_name="other", transport=MCPTransport.http)
        manager.registry = {"zapier-id": zapier, "other-id": other}
        manager.tool_name_to_mcp_server_name_mapping = {
            "create_zap": "other",
            "other-create_zap": "other",
            "zapier-alias-create_zap": "zapier-alias",
        }

        assert manager._resolve_mcp_server_for_tool_call("zapier", "create_zap") is zapier
        assert manager._resolve_mcp_server_for_tool_call("other", "create_zap") is other

    def test_remove_server_drops_only_its_own_tool_mapping_rows(self):
        manager = self._manager_with_deepwiki_and_huggingface()

        manager.remove_server(
            LiteLLM_MCPServerTable(
                server_id="huggingface-id",
                alias="huggingface",
                url="https://huggingface.co/mcp",
                transport=MCPTransport.http,
                created_at=datetime.now(),
                updated_at=datetime.now(),
            )
        )

        assert manager.tool_name_to_mcp_server_name_mapping == {
            "read_wiki_structure": "deepwiki",
            "deepwiki-read_wiki_structure": "deepwiki",
        }

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_skipped_when_not_user_oauth(self):
        """Returns input headers unchanged when server does not need user OAuth."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="plain",
            name="plain",
            transport=MCPTransport.http,
        )
        # needs_user_oauth_token defaults to False.
        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="bob")

        result = await manager._resolve_oauth2_headers_for_tool_call(
            server, oauth2_headers=None, user_api_key_auth=user_auth
        )
        assert result is None

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_returns_client_supplied_token(self):
        """Returns the client's oauth2_headers as-is when already set."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="oauth-srv",
            name="oauth-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
        )
        assert server.needs_user_oauth_token is True
        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
        supplied = {"Authorization": "Bearer client-supplied"}

        result = await manager._resolve_oauth2_headers_for_tool_call(
            server, oauth2_headers=supplied, user_api_key_auth=user_auth
        )
        assert result is supplied

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_looks_up_stored_token(self):
        """Falls back to stored per-user OAuth headers for a non-migrated (delegate) oauth2 server."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="oauth-srv",
            name="oauth-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            delegate_auth_to_upstream=True,  # stays on v1, so v1 still builds the header
        )
        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
        stored = {"Authorization": "Bearer stored-user-token"}

        with patch(
            "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
            new=AsyncMock(return_value=stored),
        ) as mock_lookup:
            result = await manager._resolve_oauth2_headers_for_tool_call(
                server, oauth2_headers=None, user_api_key_auth=user_auth
            )

        assert result == stored
        mock_lookup.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_steps_aside_for_migrated_server(self):
        """A migrated authorization_code server is owned by the v2 resolver, so v1 must not also
        build the token into extra_headers (which the v2 graft would defer to and shadow).
        """
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="oauth-srv",
            name="oauth-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,  # per-user, not delegate -> migrated to v2
        )
        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")

        with patch(
            "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
            new=AsyncMock(return_value={"Authorization": "Bearer should-not-be-used"}),
        ) as mock_lookup:
            result = await manager._resolve_oauth2_headers_for_tool_call(
                server, oauth2_headers=None, user_api_key_auth=user_auth
            )

        assert result is None  # stepped aside; the v2 resolver handles the token
        mock_lookup.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_swallows_lookup_exception(self):
        """Returns supplied headers (None) when the v1 stored-token lookup raises (delegate path)."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="oauth-srv",
            name="oauth-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            delegate_auth_to_upstream=True,  # non-migrated, so it reaches the v1 lookup
        )
        user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")

        with patch(
            "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
            new=AsyncMock(side_effect=RuntimeError("redis down")),
        ):
            result = await manager._resolve_oauth2_headers_for_tool_call(
                server, oauth2_headers=None, user_api_key_auth=user_auth
            )
        assert result is None

    @pytest.mark.asyncio
    async def test_has_user_oauth_token_delegates_to_provider(self):
        """has_user_oauth_token maps the server and delegates the verdict to the v2 resolver."""
        from litellm.proxy._types import UserAPIKeyAuth

        for verdict in (True, False):

            class _Provider:
                async def has_user_token(self, subject, spec):
                    return verdict

            manager = MCPServerManager(cred_provider=_Provider())
            server = MCPServer(
                server_id="s",
                name="n",
                transport=MCPTransport.http,
                auth_type=MCPAuth.oauth2,
            )
            user_auth = UserAPIKeyAuth(api_key="sk", user_id="alice")
            assert await manager.has_user_oauth_token(server, user_auth) is verdict

    @pytest.mark.asyncio
    async def test_has_user_oauth_token_short_circuits_for_unmigrated_server(self):
        """A server the resolver does not own (None spec, e.g. delegate) is False without a call."""
        from litellm.proxy._types import UserAPIKeyAuth

        calls: list = []

        class _Provider:
            async def has_user_token(self, subject, spec):
                calls.append(spec)
                return True

        manager = MCPServerManager(cred_provider=_Provider())
        server = MCPServer(
            server_id="s",
            name="n",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            delegate_auth_to_upstream=True,
        )
        user_auth = UserAPIKeyAuth(api_key="sk", user_id="alice")
        assert await manager.has_user_oauth_token(server, user_auth) is False
        assert calls == []  # short-circuited on the None spec, never hit the resolver

    @pytest.mark.asyncio
    async def test_invalidate_user_oauth_token_cache_delegates_to_store(self):
        """The write side's cache drop reaches the same per-user store the resolver reads."""

        class _Store:
            def __init__(self) -> None:
                self.invalidations: list[tuple[str, str]] = []

            async def fetch(self, user_id: str, server_id: str):
                return None

            async def invalidate(self, user_id: str, server_id: str) -> None:
                self.invalidations.append((user_id, server_id))

        store = _Store()
        manager = MCPServerManager(per_user_oauth_token_store=store)
        await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
        assert store.invalidations == [("alice", "srv-1")]

    @pytest.mark.asyncio
    async def test_invalidate_user_oauth_token_cache_drops_legacy_cache_too(self):
        """A per-user token can be served from the legacy per-user token cache as well as the v2
        store; the shared invalidation must evict both, or the path not evicted keeps serving a
        token minted for a replaced credential row until its TTL."""

        class _Store:
            async def fetch(self, user_id: str, server_id: str):
                return None

            async def invalidate(self, user_id: str, server_id: str) -> None:
                return None

        class _LegacyCache:
            def __init__(self) -> None:
                self.deletes: list[tuple[str, str]] = []

            async def delete(self, user_id: str, server_id: str) -> None:
                self.deletes.append((user_id, server_id))

        legacy_cache = _LegacyCache()
        manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
        await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
        assert legacy_cache.deletes == [("alice", "srv-1")]

    @pytest.mark.asyncio
    async def test_invalidate_user_oauth_token_cache_swallows_store_errors(self):
        """A cache-drop failure must not fail the credential write that triggered it, and the
        legacy cache must still be evicted after the v2 store drop fails."""

        class _Store:
            async def fetch(self, user_id: str, server_id: str):
                return None

            async def invalidate(self, user_id: str, server_id: str) -> None:
                raise RuntimeError("redis down")

        class _LegacyCache:
            def __init__(self) -> None:
                self.deletes: list[tuple[str, str]] = []

            async def delete(self, user_id: str, server_id: str) -> None:
                self.deletes.append((user_id, server_id))

        legacy_cache = _LegacyCache()
        manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
        await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
        assert legacy_cache.deletes == [("alice", "srv-1")]

    @pytest.mark.asyncio
    async def test_invalidate_user_oauth_token_cache_swallows_legacy_cache_errors(self):
        """The legacy cache drop is best-effort like the v2 drop: a failure must be logged, never
        raised into the credential write that triggered the invalidation."""

        class _Store:
            async def fetch(self, user_id: str, server_id: str):
                return None

            async def invalidate(self, user_id: str, server_id: str) -> None:
                return None

        class _RaisingLegacyCache:
            async def delete(self, user_id: str, server_id: str) -> None:
                raise RuntimeError("redis down")

        manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache())
        await manager.invalidate_user_oauth_token_cache("alice", "srv-1")

    @pytest.mark.asyncio
    async def test_resolve_oauth2_headers_no_user_id(self):
        """Skip lookup entirely when user_api_key_auth has no user_id."""
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()
        server = MCPServer(
            server_id="oauth-srv",
            name="oauth-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
        )
        # user_id is None -> lookup must not happen
        user_auth = UserAPIKeyAuth(api_key="sk-test")

        with patch(
            "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
            new=AsyncMock(return_value={"Authorization": "Bearer x"}),
        ) as mock_lookup:
            result = await manager._resolve_oauth2_headers_for_tool_call(
                server, oauth2_headers=None, user_api_key_auth=user_auth
            )
        assert result is None
        mock_lookup.assert_not_called()

    def test_create_prefixed_tools_updates_mapping_for_both_forms(self):
        """_create_prefixed_tools should populate mapping for prefixed and original names even when not adding prefix in output."""
        manager = MCPServerManager()

        server = MCPServer(
            server_id="jira",
            name="jira",
            transport=MCPTransport.http,
        )

        # Input tools as would come from upstream
        t1 = MCPTool(
            name="create_issue",
            description="",
            inputSchema={},
        )
        t2 = MCPTool(
            name="close_issue",
            description="",
            inputSchema={},
        )

        # Do not add prefix in returned objects
        out_tools = manager._create_prefixed_tools([t1, t2], server, add_prefix=False)

        # Returned names should be unprefixed
        names = sorted([t.name for t in out_tools])
        assert names == ["close_issue", "create_issue"]

        # Mapping should include both original and prefixed names -> resolves calls either way
        assert manager.tool_name_to_mcp_server_name_mapping["create_issue"] == "jira"
        assert manager.tool_name_to_mcp_server_name_mapping["jira-create_issue"] == "jira"
        assert manager.tool_name_to_mcp_server_name_mapping["close_issue"] == "jira"
        assert manager.tool_name_to_mcp_server_name_mapping["jira-close_issue"] == "jira"

    def test_get_mcp_server_from_tool_name_with_prefixed_and_unprefixed(self):
        """After mapping is populated, manager resolves both prefixed and unprefixed tool names to the same server."""
        manager = MCPServerManager()

        server = MCPServer(
            server_id="zapier",
            name="zapier",
            server_name="zapier",
            transport=MCPTransport.http,
        )

        # Register server so resolution can find it
        manager.registry = {server.server_id: server}

        # Populate mapping (add_prefix value doesn't matter for mapping population)
        base_tool = MCPTool(
            name="create_zap",
            description="",
            inputSchema={},
        )
        _ = manager._create_prefixed_tools([base_tool], server, add_prefix=False)

        # Unprefixed resolution
        resolved_server_unpref = manager._get_mcp_server_from_tool_name("create_zap")
        assert resolved_server_unpref is not None
        assert resolved_server_unpref.server_id == server.server_id

        # Prefixed resolution
        resolved_server_pref = manager._get_mcp_server_from_tool_name("zapier-create_zap")
        assert resolved_server_pref is not None
        assert resolved_server_pref.server_id == server.server_id

    @pytest.mark.asyncio
    async def test_rest_endpoint_filters_by_allowed_tools(self):
        """Test that REST endpoint _get_tools_for_single_server respects allowed_tools configuration"""
        from litellm.proxy._experimental.mcp_server.rest_endpoints import (
            _get_tools_for_single_server,
        )

        # Create server with allowed_tools configured
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            allowed_tools=["allowed_tool_1", "allowed_tool_2"],
        )
        server.mcp_info = {"server_name": "test-server"}

        # Mock tools returned from manager (3 tools, but only 2 are allowed)
        tool1 = MagicMock()
        tool1.name = "allowed_tool_1"
        tool1.description = "This tool is allowed"
        tool1.inputSchema = {}

        tool2 = MagicMock()
        tool2.name = "blocked_tool"
        tool2.description = "This tool is not allowed"
        tool2.inputSchema = {}

        tool3 = MagicMock()
        tool3.name = "allowed_tool_2"
        tool3.description = "This tool is also allowed"
        tool3.inputSchema = {}

        # Mock the global_mcp_server_manager._get_tools_from_server
        from litellm.proxy._experimental.mcp_server import rest_endpoints

        with patch.object(
            rest_endpoints.global_mcp_server_manager,
            "_get_tools_from_server",
            new=AsyncMock(return_value=[tool1, tool2, tool3]),
        ):
            # Call the REST endpoint helper
            filtered_response = await _get_tools_for_single_server(server, server_auth_header=None)

            # Verify only allowed tools are in the response
            assert len(filtered_response) == 2
            tool_names = [t.name for t in filtered_response]
            assert "allowed_tool_1" in tool_names
            assert "allowed_tool_2" in tool_names
            assert "blocked_tool" not in tool_names

    @pytest.mark.asyncio
    async def test_rest_endpoint_shows_all_when_allowed_tools_is_none(self):
        """Test that REST endpoint shows all tools when allowed_tools is None (backwards compatibility)"""
        from litellm.proxy._experimental.mcp_server.rest_endpoints import (
            _get_tools_for_single_server,
        )

        # Create server with allowed_tools as None
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            allowed_tools=None,  # No filtering
        )
        server.mcp_info = {"server_name": "test-server"}

        # Mock tools returned from manager
        tool1 = MagicMock()
        tool1.name = "tool_1"
        tool1.description = "Tool 1"
        tool1.inputSchema = {}

        tool2 = MagicMock()
        tool2.name = "tool_2"
        tool2.description = "Tool 2"
        tool2.inputSchema = {}

        tool3 = MagicMock()
        tool3.name = "tool_3"
        tool3.description = "Tool 3"
        tool3.inputSchema = {}

        # Mock the global_mcp_server_manager._get_tools_from_server
        from litellm.proxy._experimental.mcp_server import rest_endpoints

        with patch.object(
            rest_endpoints.global_mcp_server_manager,
            "_get_tools_from_server",
            new=AsyncMock(return_value=[tool1, tool2, tool3]),
        ):
            # Call the REST endpoint helper
            all_tools_response = await _get_tools_for_single_server(server, server_auth_header=None)

            # Verify all tools are returned (no filtering)
            assert len(all_tools_response) == 3
            tool_names = [t.name for t in all_tools_response]
            assert "tool_1" in tool_names
            assert "tool_2" in tool_names
            assert "tool_3" in tool_names

    @pytest.mark.asyncio
    async def test_rest_endpoint_shows_all_when_allowed_tools_is_empty_list(self):
        """Test that REST endpoint shows all tools when allowed_tools is empty list (backwards compatibility)"""
        from litellm.proxy._experimental.mcp_server.rest_endpoints import (
            _get_tools_for_single_server,
        )

        # Create server with allowed_tools as empty list
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            allowed_tools=[],  # Empty list means no filtering
        )
        server.mcp_info = {"server_name": "test-server"}

        # Mock tools returned from manager
        tool1 = MagicMock()
        tool1.name = "tool_1"
        tool1.description = "Tool 1"
        tool1.inputSchema = {}

        tool2 = MagicMock()
        tool2.name = "tool_2"
        tool2.description = "Tool 2"
        tool2.inputSchema = {}

        # Mock the global_mcp_server_manager._get_tools_from_server
        from litellm.proxy._experimental.mcp_server import rest_endpoints

        with patch.object(
            rest_endpoints.global_mcp_server_manager,
            "_get_tools_from_server",
            new=AsyncMock(return_value=[tool1, tool2]),
        ):
            # Call the REST endpoint helper
            all_tools_response = await _get_tools_for_single_server(server, server_auth_header=None)

            # Verify all tools are returned (no filtering)
            assert len(all_tools_response) == 2
            tool_names = [t.name for t in all_tools_response]
            assert "tool_1" in tool_names
            assert "tool_2" in tool_names

    async def test_add_db_mcp_server_to_registry(self):
        """Test that add_db_mcp_server_to_registry adds a MCP server to the registry"""
        manager = MCPServerManager()
        server = LiteLLM_MCPServerTable(
            **{
                "server_id": "4c679a81-acd9-4954-9f84-30b739362498",
                "server_name": "edc_mcp_server",
                "alias": "edc_mcp_server",
                "description": None,
                "url": "fake_mcp_url",
                "transport": "http",
                "auth_type": "none",
                "created_at": "2025-09-30T08:28:31.353000Z",
                "created_by": "a1248959",
                "updated_at": "2025-09-30T08:28:31.353000Z",
                "updated_by": "a1248959",
                "teams": [],
                "mcp_access_groups": [],
                "mcp_info": {
                    "server_name": "edc_mcp_server",
                    "mcp_server_cost_info": None,
                },
                "status": "unknown",
                "last_health_check": None,
                "health_check_error": None,
                "command": None,
                "args": [],
                "env": {},
            },
        )
        await manager.add_server(server)
        assert server.server_id in manager.get_registry()

    @pytest.mark.asyncio
    async def test_key_tool_permission_allows_permitted_tool(self):
        """
        Test that key can call tool when it's in mcp_tool_permissions allowed list.
        """
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()

        server = MCPServer(
            server_id="test_server_123",
            name="Test Server",
            transport=MCPTransport.http,
            allowed_tools=None,
            disallowed_tools=None,
        )

        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_123",
            mcp_tool_permissions={"test_server_123": ["read_wiki_structure"]},
        )

        user_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
        )

        proxy_logging = MagicMock()
        proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging.pre_call_hook = AsyncMock(return_value=None)

        # Should succeed
        await manager.pre_call_tool_check(
            server_name="Test Server",
            name="read_wiki_structure",
            arguments={"repoName": "facebook/react"},
            user_api_key_auth=user_auth,
            proxy_logging_obj=proxy_logging,
            server=server,
        )

    @pytest.mark.asyncio
    async def test_key_tool_permission_blocks_unpermitted_tool(self):
        """
        Test that key cannot call tool when it's NOT in mcp_tool_permissions allowed list.
        """
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()

        server = MCPServer(
            server_id="test_server_123",
            name="Test Server",
            transport=MCPTransport.http,
            allowed_tools=None,
            disallowed_tools=None,
        )

        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_123",
            mcp_tool_permissions={"test_server_123": ["read_wiki_structure"]},
        )

        user_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
        )

        proxy_logging = MagicMock()
        proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging.pre_call_hook = AsyncMock(return_value=None)

        # Should fail with 403
        with pytest.raises(HTTPException) as exc_info:
            await manager.pre_call_tool_check(
                server_name="Test Server",
                name="ask_question",
                arguments={"question": "test"},
                user_api_key_auth=user_auth,
                proxy_logging_obj=proxy_logging,
                server=server,
            )

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_check_tool_permission_for_key_team_allows_permitted_tool(self):
        """
        Test check_tool_permission_for_key_team directly - should allow permitted tool.
        """
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()

        server = MCPServer(
            server_id="github_server",
            name="GitHub Server",
            transport=MCPTransport.http,
        )

        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_456",
            mcp_tool_permissions={"github_server": ["read_repo", "list_issues"]},
        )

        user_auth = UserAPIKeyAuth(
            api_key="sk-test-key",
            user_id="user-456",
            object_permission=object_permission,
        )

        # Should not raise exception for allowed tool
        await manager.check_tool_permission_for_key_team(
            tool_name="read_repo",
            server=server,
            user_api_key_auth=user_auth,
        )

    @pytest.mark.asyncio
    async def test_check_tool_permission_for_key_team_blocks_unpermitted_tool(self):
        """
        Test check_tool_permission_for_key_team directly - should block unpermitted tool.
        """
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()

        server = MCPServer(
            server_id="github_server",
            name="GitHub Server",
            transport=MCPTransport.http,
        )

        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_456",
            mcp_tool_permissions={"github_server": ["read_repo"]},
        )

        user_auth = UserAPIKeyAuth(
            api_key="sk-test-key",
            user_id="user-456",
            object_permission=object_permission,
        )

        # Should raise HTTPException for unpermitted tool
        with pytest.raises(HTTPException) as exc_info:
            await manager.check_tool_permission_for_key_team(
                tool_name="delete_repo",
                server=server,
                user_api_key_auth=user_auth,
            )

        assert exc_info.value.status_code == 403
        assert "delete_repo" in exc_info.value.detail["error"]
        assert "not allowed" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_check_tool_permission_for_key_team_allows_all_when_no_restrictions(
        self,
    ):
        """
        Test check_tool_permission_for_key_team - should allow all tools when no restrictions set.
        """
        from litellm.proxy._types import UserAPIKeyAuth

        manager = MCPServerManager()

        server = MCPServer(
            server_id="github_server",
            name="GitHub Server",
            transport=MCPTransport.http,
        )

        # No object_permission set on user_auth
        user_auth = UserAPIKeyAuth(
            api_key="sk-test-key",
            user_id="user-456",
            object_permission=None,
        )

        # Should allow any tool when no restrictions
        await manager.check_tool_permission_for_key_team(
            tool_name="any_tool",
            server=server,
            user_api_key_auth=user_auth,
        )

    @pytest.mark.asyncio
    async def test_allowed_tools_with_mixed_prefixed_and_unprefixed_names(self):
        """
        Test that allowed_tools works with both unprefixed and prefixed tool names.
        This tests the scenario where allowed_tools = ["getpetbyid", "my_api_mcp-findpetsbystatus"]
        Both getpetbyid (unprefixed) and findpetsbystatus (called unprefixed but allowed via prefix) should work.
        """
        manager = MCPServerManager()

        # Create server with mixed prefixed/unprefixed allowed_tools
        server = MCPServer(
            server_id="my_api_mcp",
            name="my_api_mcp",
            transport=MCPTransport.stdio,
            allowed_tools=["getpetbyid", "my_api_mcp-findpetsbystatus"],
            disallowed_tools=None,
        )

        # Mock dependencies - set object_permission and object_permission_id to None
        # so permission checks return None (no restrictions)
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None
        proxy_logging_obj = MagicMock()

        # Mock the async methods that pre_call_tool_check calls
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})

        # Test 1: Call getpetbyid (unprefixed in allowed_tools) - should succeed
        await manager.pre_call_tool_check(
            name="getpetbyid",
            arguments={"petId": "1"},
            server_name="my_api_mcp",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

        # Test 2: Call findpetsbystatus (prefixed in allowed_tools as "my_api_mcp-findpetsbystatus") - should succeed
        await manager.pre_call_tool_check(
            name="findpetsbystatus",
            arguments={"status": "available"},
            server_name="my_api_mcp",
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
            server=server,
        )

        # Test 3: Call a tool that's not in allowed_tools - should fail
        with pytest.raises(HTTPException) as exc_info:
            await manager.pre_call_tool_check(
                name="deletepet",
                arguments={"petId": "1"},
                server_name="my_api_mcp",
                user_api_key_auth=user_api_key_auth,
                proxy_logging_obj=proxy_logging_obj,
                server=server,
            )

        assert exc_info.value.status_code == 403
        assert "Tool deletepet is not allowed for server my_api_mcp" in exc_info.value.detail["error"]
        assert "Contact proxy admin to allow this tool" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_call_tool_without_broken_pipe_error(self):
        """
        Test that call_tool awaits the client call even without a persistent context manager.
        Ensures the gathered tasks still include the MCP client call result.
        """
        from unittest.mock import AsyncMock, MagicMock

        from mcp.types import CallToolResult

        manager = MCPServerManager()

        # Create a test server
        server = MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            url="http://test-server.com",
        )

        # Register the server and map a tool to it
        manager.registry = {"test-server": server}
        manager.tool_name_to_mcp_server_name_mapping["test_tool"] = "test-server"
        manager.tool_name_to_mcp_server_name_mapping["test-server-test_tool"] = "test-server"

        # Create mock client that tracks call_tool usage
        mock_client = AsyncMock()

        async def mock_call_tool(params, host_progress_callback=None):
            # Return a mock CallToolResult
            result = MagicMock(spec=CallToolResult)
            result.content = [{"type": "text", "text": "Tool executed successfully"}]
            result.isError = False
            return result

        mock_client.call_tool.side_effect = mock_call_tool

        # Mock _create_mcp_client to return our mock client
        manager._create_mcp_client = AsyncMock(return_value=mock_client)

        # Mock user auth with no restrictions
        user_api_key_auth = MagicMock()
        user_api_key_auth.object_permission = None
        user_api_key_auth.object_permission_id = None

        # Mock proxy logging
        proxy_logging_obj = MagicMock()
        proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
        proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
        proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})
        proxy_logging_obj.during_call_hook = AsyncMock(return_value=None)

        # Call the tool
        result = await manager.call_tool(
            server_name="test-server",
            name="test_tool",
            arguments={"param": "value"},
            user_api_key_auth=user_api_key_auth,
            proxy_logging_obj=proxy_logging_obj,
        )

        # Verify the result
        assert result is not None
        assert result.isError is False
        assert len(result.content) > 0

        # Verify the MCP client call was awaited exactly once
        assert mock_client.call_tool.await_count == 1

    @pytest.mark.asyncio
    async def test_get_allowed_mcp_servers_with_user_api_key_auth(self):
        """
        Test that get_allowed_mcp_servers properly receives and uses user_api_key_auth
        when called. This verifies the fix where user_api_key_auth is passed through
        litellm_metadata from responses API.
        """
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()

        # Create a mock user_api_key_auth with object_permission
        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_123",
            mcp_servers=["test_server_1", "test_server_2"],
            mcp_access_groups=[],
        )

        user_api_key_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
            object_permission_id="perm_123",
        )

        # Mock MCPRequestHandler.get_allowed_mcp_servers to verify it receives user_api_key_auth
        with patch.object(
            MCPRequestHandler,
            "get_allowed_mcp_servers",
            new_callable=AsyncMock,
        ) as mock_get_allowed:
            # Configure mock to return servers from object_permission
            mock_get_allowed.return_value = ["test_server_1", "test_server_2"]

            # Call get_allowed_mcp_servers with user_api_key_auth
            result = await manager.get_allowed_mcp_servers(user_api_key_auth)

            # Verify MCPRequestHandler.get_allowed_mcp_servers was called with user_api_key_auth
            mock_get_allowed.assert_called_once()
            call_args = mock_get_allowed.call_args
            assert call_args[0][0] is user_api_key_auth  # First positional arg should be user_api_key_auth
            assert call_args[0][0].user_id == "user-123"
            assert call_args[0][0].object_permission_id == "perm_123"
            assert call_args[0][0].object_permission is not None
            assert call_args[0][0].object_permission.mcp_servers == [
                "test_server_1",
                "test_server_2",
            ]

            # Verify result contains the expected servers
            assert "test_server_1" in result
            assert "test_server_2" in result

    @pytest.mark.asyncio
    async def test_no_mcp_servers_sentinel_blocks_allow_all_keys(self):
        """A key scoped to no-mcp-servers gets zero servers even when allow_all_keys
        servers exist, and the inner resolver is never consulted."""
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        manager = MCPServerManager()
        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_no_mcp",
            mcp_servers=["no-mcp-servers"],
            mcp_access_groups=[],
        )
        user_api_key_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
            object_permission_id="perm_no_mcp",
        )

        with (
            patch.object(manager, "get_allow_all_keys_server_ids", return_value=["global-server"]),
            patch.object(
                MCPRequestHandler,
                "get_allowed_mcp_servers",
                new_callable=AsyncMock,
                return_value=["leaked-server"],
            ) as mock_inner,
        ):
            result = await manager.get_allowed_mcp_servers(user_api_key_auth)

        assert result == []
        mock_inner.assert_not_called()

    @pytest.mark.asyncio
    async def test_no_mcp_servers_sentinel_excludes_submitted_byom_servers(self):
        from litellm.proxy import proxy_server as proxy_server_module
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        class _Cache:
            async def async_get_cache(self, key: str):
                return ["submitted-server"]

        manager = MCPServerManager()
        manager.registry = {
            "submitted-server": MCPServer(
                server_id="submitted-server",
                name="submitted",
                transport=MCPTransport.http,
            )
        }
        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_no_mcp",
            mcp_servers=["no-mcp-servers"],
            mcp_access_groups=[],
        )
        user_api_key_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
            object_permission_id="perm_no_mcp",
        )

        with (
            patch.object(proxy_server_module, "user_api_key_cache", _Cache()),
            patch.object(proxy_server_module, "prisma_client", None),
            patch.object(manager, "get_allow_all_keys_server_ids", return_value=["global-server"]),
            patch.object(
                MCPRequestHandler,
                "get_allowed_mcp_servers",
                new_callable=AsyncMock,
                return_value=["leaked-server"],
            ) as mock_inner,
        ):
            result = await manager.get_allowed_mcp_servers(user_api_key_auth)

        assert result == []
        mock_inner.assert_not_called()

    @pytest.mark.asyncio
    async def test_explicitly_scoped_key_excludes_submitted_byom_servers(self):
        from litellm.proxy import proxy_server as proxy_server_module
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth

        cache = MagicMock()
        cache.async_get_cache = AsyncMock(return_value=["submitted-server"])

        manager = MCPServerManager()
        manager.registry = {
            "submitted-server": MCPServer(
                server_id="submitted-server",
                name="submitted",
                transport=MCPTransport.http,
            ),
            "scoped-server": MCPServer(
                server_id="scoped-server",
                name="scoped",
                transport=MCPTransport.http,
            ),
        }
        object_permission = LiteLLM_ObjectPermissionTable(
            object_permission_id="perm_scoped",
            mcp_servers=["scoped-server"],
            mcp_access_groups=[],
        )
        user_api_key_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
            object_permission=object_permission,
            object_permission_id="perm_scoped",
        )

        with (
            patch.object(proxy_server_module, "user_api_key_cache", cache),
            patch.object(proxy_server_module, "prisma_client", None),
            patch.object(manager, "get_allow_all_keys_server_ids", return_value=[]),
            patch.object(
                MCPRequestHandler,
                "get_allowed_mcp_servers",
                new_callable=AsyncMock,
                return_value=["scoped-server"],
            ),
        ):
            result = await manager.get_allowed_mcp_servers(user_api_key_auth)

        assert result == ["scoped-server"]
        cache.async_get_cache.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_toolset_scope_excludes_submitted_byom_servers(self):
        from litellm.proxy import proxy_server as proxy_server_module
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._experimental.mcp_server.mcp_context import (
            _mcp_active_toolset_id,
        )
        from litellm.proxy._types import UserAPIKeyAuth

        cache = MagicMock()
        cache.async_get_cache = AsyncMock(return_value=["submitted-server"])

        manager = MCPServerManager()
        manager.registry = {
            "submitted-server": MCPServer(
                server_id="submitted-server",
                name="submitted",
                transport=MCPTransport.http,
            ),
            "toolset-server": MCPServer(
                server_id="toolset-server",
                name="toolset",
                transport=MCPTransport.http,
            ),
        }
        user_api_key_auth = UserAPIKeyAuth(api_key="sk-test", user_id="user-123")

        token = _mcp_active_toolset_id.set("toolset-abc")
        try:
            with (
                patch.object(proxy_server_module, "user_api_key_cache", cache),
                patch.object(proxy_server_module, "prisma_client", None),
                patch.object(manager, "get_allow_all_keys_server_ids", return_value=["global-server"]),
                patch.object(
                    MCPRequestHandler,
                    "get_allowed_mcp_servers",
                    new_callable=AsyncMock,
                    return_value=["toolset-server"],
                ),
            ):
                result = await manager.get_allowed_mcp_servers(user_api_key_auth)
        finally:
            _mcp_active_toolset_id.reset(token)

        assert result == ["toolset-server"]

    @pytest.mark.asyncio
    async def test_invalidate_byom_submitted_servers_cache_deletes_key(self):
        from litellm.proxy import proxy_server as proxy_server_module

        cache = MagicMock()
        cache.async_delete_cache = AsyncMock()
        manager = MCPServerManager()

        with patch.object(proxy_server_module, "user_api_key_cache", cache):
            await manager.invalidate_byom_submitted_servers_cache("user-123")
            await manager.invalidate_byom_submitted_servers_cache(None)

        cache.async_delete_cache.assert_awaited_once_with(key="byom_submitted_servers:user-123")

    @pytest.mark.asyncio
    async def test_get_active_submitted_ids_cache_miss_queries_db_and_caches(self):
        from litellm.proxy import proxy_server as proxy_server_module
        from litellm.proxy._types import UserAPIKeyAuth

        cache = MagicMock()
        cache.async_get_cache = AsyncMock(return_value=None)
        cache.async_set_cache = AsyncMock()
        manager = MCPServerManager()
        manager.registry = {
            "submitted-server": MCPServer(
                server_id="submitted-server",
                name="submitted",
                transport=MCPTransport.http,
            )
        }
        user_api_key_auth = UserAPIKeyAuth(api_key="sk-test", user_id="user-123")

        with (
            patch.object(proxy_server_module, "user_api_key_cache", cache),
            patch.object(proxy_server_module, "prisma_client", MagicMock()),
            patch(
                "litellm.proxy._experimental.mcp_server.db.get_active_submitted_mcp_server_ids_for_user",
                AsyncMock(return_value=["submitted-server", "unknown-server"]),
            ),
        ):
            result = await manager._get_active_submitted_mcp_server_ids_for_user(user_api_key_auth)

        assert result == ["submitted-server"]
        cache.async_set_cache.assert_awaited_once_with(
            key="byom_submitted_servers:user-123",
            value=["submitted-server", "unknown-server"],
            ttl=60,
        )

    @pytest.mark.asyncio
    async def test_get_allowed_mcp_servers_fallback_keeps_submitted_byom_servers(self):
        from litellm.proxy import proxy_server as proxy_server_module
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )
        from litellm.proxy._types import UserAPIKeyAuth

        class _Cache:
            async def async_get_cache(self, key: str):
                assert key == "byom_submitted_servers:user-123"
                return ["submitted-server"]

        manager = MCPServerManager()
        manager.registry = {
            "submitted-server": MCPServer(
                server_id="submitted-server",
                name="submitted",
                transport=MCPTransport.http,
            )
        }
        user_api_key_auth = UserAPIKeyAuth(
            api_key="sk-test",
            user_id="user-123",
        )

        with (
            patch.object(proxy_server_module, "user_api_key_cache", _Cache()),
            patch.object(proxy_server_module, "prisma_client", None),
            patch.object(manager, "get_allow_all_keys_server_ids", return_value=["global-server"]),
            patch.object(
                MCPRequestHandler,
                "get_allowed_mcp_servers",
                new_callable=AsyncMock,
                side_effect=RuntimeError("permission resolver failed"),
            ),
        ):
            result = await manager.get_allowed_mcp_servers(user_api_key_auth)

        assert set(result) == {"global-server", "submitted-server"}

    @pytest.mark.asyncio
    async def test_get_allowed_mcp_servers_anonymous_delegate_requires_oauth2(self):
        """Anonymous delegated auth listing should only include oauth2 servers."""
        from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
            MCPRequestHandler,
        )

        manager = MCPServerManager()
        oauth_delegate_server = MCPServer(
            server_id="oauth-delegate",
            name="oauth_delegate",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            delegate_auth_to_upstream=True,
        )
        api_key_delegate_server = MCPServer(
            server_id="api-key-delegate",
            name="api_key_delegate",
            transport=MCPTransport.http,
            auth_type=MCPAuth.api_key,
            delegate_auth_to_upstream=True,
        )
        oauth_non_delegate_server = MCPServer(
            server_id="oauth-non-delegate",
            name="oauth_non_delegate",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            delegate_auth_to_upstream=False,
        )
        manager.registry = {
            oauth_delegate_server.server_id: oauth_delegate_server,
            api_key_delegate_server.server_id: api_key_delegate_server,
            oauth_non_delegate_server.server_id: oauth_non_delegate_server,
        }

        with patch.object(
            MCPRequestHandler,
            "get_allowed_mcp_servers",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await manager.get_allowed_mcp_servers(None)

        assert set(result) == {"oauth-delegate"}

    def test_get_mcp_server_from_tool_name_uses_server_name_not_name(self):
        """
        Test that _get_mcp_server_from_tool_name uses server.server_name instead of server.name
        when extracting server name from prefixed tool name (second case).
        This ensures the fix for using server_name instead of name works correctly.
        """
        from litellm.proxy._experimental.mcp_server.utils import (
            add_server_prefix_to_name,
        )

        manager = MCPServerManager()

        # Create a server where server_name differs from name
        # This tests the scenario where server.name != server.server_name
        server = MCPServer(
            server_id="test-server-id",
            name="Test Server Name",  # Different from server_name
            server_name="test_server",  # This is what should be used
            alias="test_server",
            transport=MCPTransport.http,
        )

        # Register the server
        manager.registry = {server.server_id: server}

        # Create a tool with prefixed name
        tool_name = "test_tool"
        prefixed_tool_name = add_server_prefix_to_name(tool_name, "test_server")

        # Populate the mapping with the original tool name
        manager.tool_name_to_mcp_server_name_mapping[tool_name] = "test_server"
        manager.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = "test_server"

        # Test: _get_mcp_server_from_tool_name should find the server using server.server_name
        # even when server.name is different
        resolved_server = manager._get_mcp_server_from_tool_name(prefixed_tool_name)

        # Verify the server was found correctly
        assert resolved_server is not None
        assert resolved_server.server_id == server.server_id
        assert resolved_server.server_name == "test_server"
        # Verify it matched using server_name, not name
        assert resolved_server.name == "Test Server Name"  # name is different
        assert resolved_server.server_name == "test_server"  # server_name matches


class TestMCPServerTimestamps:
    """Regression tests: created_at/updated_at must be preserved, not overwritten with datetime.now()."""

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_preserves_timestamps(self):
        """build_mcp_server_from_table must carry created_at and updated_at into MCPServer."""
        manager = MCPServerManager()

        created = datetime(2024, 1, 15, 10, 0, 0)
        updated = datetime(2024, 6, 20, 12, 30, 0)

        table_record = LiteLLM_MCPServerTable(
            server_id="ts-server-1",
            server_name="ts_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            created_at=created,
            updated_at=updated,
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.created_at == created
        assert mcp_server.updated_at == updated

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_reads_token_endpoint_auth_method(self):
        """token_endpoint_auth_method stored in the credentials JSON is loaded onto the MCPServer (LIT-4091)."""
        manager = MCPServerManager()

        basic_record = LiteLLM_MCPServerTable(
            server_id="basic-db-1",
            server_name="basic_db",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            credentials={"token_endpoint_auth_method": "client_secret_basic"},
        )
        basic_server = await manager.build_mcp_server_from_table(basic_record, credentials_are_encrypted=False)
        assert basic_server.token_endpoint_auth_method == "client_secret_basic"

        default_record = LiteLLM_MCPServerTable(
            server_id="default-db-1",
            server_name="default_db",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            credentials={},
        )
        default_server = await manager.build_mcp_server_from_table(default_record, credentials_are_encrypted=False)
        assert default_server.token_endpoint_auth_method is None

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_discovers_obo_token_url_when_unset(self):
        """DB path: an OBO server with no token_exchange_endpoint in credentials and no token_url
        column runs discovery, and the resolved endpoint lands on the returned MCPServer."""
        manager = MCPServerManager()
        calls: list[bool] = []

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            calls.append(allow_origin_fallback)
            return MCPOAuthMetadata(
                scopes=None,
                authorization_url=None,
                token_url="https://discovered.example.com/token",
                registration_url=None,
            )

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        record = LiteLLM_MCPServerTable(
            server_id="obo-discover-db-1",
            server_name="obo_discover_db",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            credentials={"client_id": "cid", "client_secret": "csec"},
        )

        # prisma_client None -> the write-back no-ops; this test isolates the discovery behavior.
        with patch("litellm.proxy.proxy_server.prisma_client", None):
            server = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)

        assert calls == [False]  # discovery ran once, origin fallback disabled for OBO
        assert server.token_url == "https://discovered.example.com/token"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_skips_discovery_when_obo_endpoint_configured(self):
        """DB path: a configured token_exchange_endpoint in the credentials JSON wins and skips
        discovery entirely, even though the token_url column is empty (the DB-specific lookup uses
        credentials_dict["token_exchange_endpoint"], not the column)."""
        manager = MCPServerManager()
        calls: list[str] = []

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            calls.append(server_url)
            raise AssertionError("discovery must not run when token_exchange_endpoint is configured")

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        record = LiteLLM_MCPServerTable(
            server_id="obo-configured-db-1",
            server_name="obo_configured_db",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            credentials={
                "client_id": "cid",
                "client_secret": "csec",
                "token_exchange_endpoint": "https://idp.example.com/token",
            },
        )

        server = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)

        assert calls == []  # discovery never ran
        assert server.token_exchange_endpoint == "https://idp.example.com/token"

    @pytest.mark.asyncio
    async def test_discovery_never_writes_the_database(self):
        """The #34985 regression, stated as the design invariant that fixes it: the gateway never
        writes discovery results to the row. The OAuth columns and credentials.scopes carry admin
        intent alone, so nothing the gateway learns can read back as an admin pin on a later build
        (which is what anchored stamped servers fail-closed and 400ed /authorize). Discovery output
        lives on the in-memory registry entry only, for oauth2 and OBO alike."""
        manager = MCPServerManager()

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            return MCPOAuthMetadata(
                scopes=["mcp.read"],
                authorization_url="https://idp.example.com/authorize",
                token_url="https://idp.example.com/token",
                registration_url="https://idp.example.com/register",
                discovered_issuer="https://idp.example.com",
            )

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        update_mcp_server_mock = AsyncMock()
        repo_instance = MagicMock()
        repo_instance.table.update = AsyncMock()
        with (
            patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=update_mcp_server_mock),
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository",
                return_value=repo_instance,
            ),
            patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
        ):
            for auth_type, flow in ((MCPAuth.oauth2, "authorization_code"), (MCPAuth.oauth2_token_exchange, None)):
                record = LiteLLM_MCPServerTable(
                    server_id=f"no-write-{auth_type}",
                    server_name=f"no_write_{auth_type}",
                    url="https://example.com/mcp",
                    transport=MCPTransport.http,
                    auth_type=auth_type,
                    oauth2_flow=flow,
                    credentials={"client_id": "cid", "client_secret": "csec", "audience": "aud"},
                )
                built = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)
                assert built.token_url == "https://idp.example.com/token"

        update_mcp_server_mock.assert_not_awaited()
        repo_instance.table.update.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_declared_endpoints_survive_a_failed_discovery(self):
        """The reporter's configuration: explicit authorization_url/token_url/registration_url,
        issuer left empty. With the gateway never stamping the issuer column, the server never turns
        anchored, so the declared endpoints resolve on every build, including one whose discovery
        fails entirely; /authorize keeps redirecting instead of serving the 400."""
        manager = MCPServerManager()
        record = LiteLLM_MCPServerTable(
            server_id="declared-1",
            alias="declared",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            built = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)

        assert built.issuer_is_anchored is False
        assert built.authorization_url == "https://idp.example.com/authorize"
        assert built.token_url == "https://idp.example.com/token"
        assert built.registration_url == "https://idp.example.com/register"

    def test_flow_endpoints_missing_arms(self):
        """The reload fast-path exemption's completeness rule. Interactive needs authorize+token,
        client_credentials and OBO need token only, an OBO server with a configured exchange
        endpoint never discovers and must not be sent into a rebuild loop, and non-OAuth auth types
        are never unresolved."""
        assert _flow_endpoints_missing(MCPAuth.oauth2, "authorization_code", "https://idp/auth", None) is True
        assert _flow_endpoints_missing(MCPAuth.oauth2, "authorization_code", None, "https://idp/token") is True
        assert (
            _flow_endpoints_missing(MCPAuth.oauth2, "authorization_code", "https://idp/auth", "https://idp/token")
            is False
        )
        assert _flow_endpoints_missing(MCPAuth.oauth2, "client_credentials", None, "https://idp/token") is False
        assert _flow_endpoints_missing(MCPAuth.oauth2, "client_credentials", None, None) is True
        assert _flow_endpoints_missing(MCPAuth.oauth2_token_exchange, None, None, None) is True
        assert _flow_endpoints_missing(MCPAuth.oauth2_token_exchange, None, None, "https://idp/token") is False
        assert _flow_endpoints_missing(MCPAuth.oauth2_token_exchange, None, None, None, "https://idp/exchange") is False
        assert _flow_endpoints_missing(MCPAuth.api_key, None, None, None) is False

    def test_unresolved_check_uses_the_flow_judge_not_the_raw_column(self):
        """A legacy row the startup backfill deliberately left unstamped (token_url plus client
        credentials, no authorization_url: the ambiguous M2M shape) serves client_credentials at
        request time via effective_oauth2_flow. The reload check must reach the same verdict, or the
        row is classified as interactive-missing-endpoints and re-runs discovery on every reload
        forever. A null-flow row without the M2M shape stays interactive and genuinely unresolved."""
        m2m_shaped = MCPServer(
            server_id="null-flow-m2m",
            name="null_flow_m2m",
            server_name="null_flow_m2m",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow=None,
            token_url="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
        )
        assert _oauth_endpoints_unresolved(m2m_shaped) is False

        interactive_unresolved = m2m_shaped.model_copy(update={"client_id": None, "client_secret": None})
        assert _oauth_endpoints_unresolved(interactive_unresolved) is True

    def test_dcr_bridge_relay_arm_needs_its_registration_endpoint(self):
        """A dcr_bridge server with no admin-configured client can only register callers through the
        upstream registration endpoint, so a partial discovery that resolved authorize and token but
        not registration_endpoint leaves it silently degraded to the short-circuit arm. That counts as
        unresolved so it keeps retrying. A bridge with a configured client_id uses the short-circuit
        arm by design and is unaffected."""
        relay_arm = MCPServer(
            server_id="bridge-partial",
            name="bridge_partial",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            # dcr_bridge is only valid on the client-forwarded modes (see MCPServer.is_dcr_bridge)
            auth_type=MCPAuth.oauth_delegate,
            dcr_bridge=True,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url=None,
        )
        assert _oauth_endpoints_unresolved(relay_arm) is True
        assert (
            _oauth_endpoints_unresolved(relay_arm.model_copy(update={"registration_url": "https://idp/reg"})) is False
        )
        assert _oauth_endpoints_unresolved(relay_arm.model_copy(update={"client_id": "admin-client"})) is False

    def test_entra_obo_without_scopes_is_unresolved(self):
        """entra_obo token exchange fails closed without a scope, and scopes can come from resource
        discovery, so an entra_obo server that resolved its token endpoint but no scopes is still
        unresolved for its flow. The default rfc8693 profile has no such requirement."""
        entra = MCPServer(
            server_id="entra-noscope",
            name="entra_noscope",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_profile="entra_obo",
            token_url="https://idp.example.com/token",
            scopes=None,
        )
        assert _oauth_endpoints_unresolved(entra) is True
        assert _oauth_endpoints_unresolved(entra.model_copy(update={"scopes": ["api://app/.default"]})) is False
        assert _oauth_endpoints_unresolved(entra.model_copy(update={"token_exchange_profile": "rfc8693"})) is False

    @pytest.mark.asyncio
    async def test_reload_fast_path_retries_unresolved_oauth_servers(self):
        """A server whose discovery failed must not be pinned broken by the updated_at fast path:
        the next reload rebuilds it, retrying discovery on the normal cadence instead of waiting for
        an unrelated config write. A resolved server with an unchanged row still takes the fast path,
        so the exemption costs nothing in the steady state."""
        manager = MCPServerManager()
        stamp = datetime.now()
        row = LiteLLM_MCPServerTable(
            server_id="retry-1",
            server_name="retry_server",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            created_at=stamp,
            updated_at=stamp,
        )

        def entry(authorization_url, token_url):
            return MCPServer(
                server_id="retry-1",
                name="retry_server",
                server_name="retry_server",
                url="https://up.example.com/mcp",
                transport=MCPTransport.http,
                auth_type=MCPAuth.oauth2,
                oauth2_flow="authorization_code",
                authorization_url=authorization_url,
                token_url=token_url,
                updated_at=stamp,
            )

        raw_row = MagicMock()
        raw_row.model_dump.return_value = row.model_dump()
        repo_instance = MagicMock()
        repo_instance.table.find_many = AsyncMock(return_value=[raw_row])

        async def run_reload(previous_entry):
            manager.registry = {"retry-1": previous_entry}
            build_mock = AsyncMock(return_value=previous_entry)
            with (
                patch(
                    "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository",
                    return_value=repo_instance,
                ),
                patch(
                    "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
                    return_value=MagicMock(),
                ),
                patch.object(manager, "build_mcp_server_from_table", new=build_mock),
            ):
                await manager.reload_servers_from_database()
            return build_mock

        unresolved_build = await run_reload(entry(None, None))
        unresolved_build.assert_awaited_once()

        resolved_build = await run_reload(entry("https://idp.example.com/authorize", "https://idp.example.com/token"))
        resolved_build.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_anchored_issuer_discarding_stored_endpoints_warns(self, caplog):
        """An anchored server ignoring stored endpoint columns must say so: that state is exactly
        what a row stamped by an earlier release looks like after upgrade, and the warning names the
        remedy (clear the Issuer field) instead of leaving the 400 undiagnosable."""
        manager = MCPServerManager()
        record = LiteLLM_MCPServerTable(
            server_id="stamped-1",
            alias="stamped_row",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            issuer="https://idp.example.com",
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

        with (
            patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)),
            caplog.at_level(logging.WARNING, logger="LiteLLM"),
        ):
            built = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)

        assert built.issuer_is_anchored is True
        assert built.authorization_url is None
        assert "stamped_row" in caplog.text
        assert "authorization_url, token_url" in caplog.text
        assert "clear the Issuer" in caplog.text

    @pytest.mark.asyncio
    async def test_update_server_carries_forward_last_known_good_oauth_endpoints(self):
        """A rebuild whose re-discovery fails must not downgrade a working registry entry: the
        previous entry's resolved endpoints and scopes carry forward instead of being replaced
        with None (which turns every /authorize into a 400 with no configuration change)."""
        manager = MCPServerManager()
        manager.registry["lkg-1"] = MCPServer(
            server_id="lkg-1",
            name="lkg_server",
            server_name="lkg_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["mcp.read"],
        )

        manager._descovery_metadata = AsyncMock(return_value=None)  # type: ignore[attr-defined]

        record = LiteLLM_MCPServerTable(
            server_id="lkg-1",
            server_name="lkg_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
            credentials={"client_id": "cid", "client_secret": "csec"},
        )

        with patch("litellm.proxy.proxy_server.prisma_client", None):
            await manager.update_server(record)

        rebuilt = manager.registry["lkg-1"]
        assert rebuilt.authorization_url == "https://idp.example.com/authorize"
        assert rebuilt.token_url == "https://idp.example.com/token"
        assert rebuilt.registration_url == "https://idp.example.com/register"
        assert rebuilt.scopes == ["mcp.read"]

    def test_carry_forward_skips_when_url_or_auth_type_changed(self):
        """Stale endpoints from a different upstream or auth mode must not carry forward."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _carry_forward_resolved_oauth_endpoints,
        )

        def make_server(url: str, auth_type: MCPAuth, authorization_url: Optional[str]) -> MCPServer:
            return MCPServer(
                server_id="s1",
                name="s1",
                url=url,
                transport=MCPTransport.http,
                auth_type=auth_type,
                authorization_url=authorization_url,
            )

        previous = make_server("https://old.example.com/mcp", MCPAuth.oauth2, "https://idp.example.com/authorize")

        url_changed = make_server("https://new.example.com/mcp", MCPAuth.oauth2, None)
        _carry_forward_resolved_oauth_endpoints(new_server=url_changed, previous_server=previous)
        assert url_changed.authorization_url is None

        auth_changed = make_server("https://old.example.com/mcp", MCPAuth.true_passthrough, None)
        _carry_forward_resolved_oauth_endpoints(new_server=auth_changed, previous_server=previous)
        assert auth_changed.authorization_url is None

        same = make_server("https://old.example.com/mcp", MCPAuth.oauth2, None)
        _carry_forward_resolved_oauth_endpoints(new_server=same, previous_server=previous)
        assert same.authorization_url == "https://idp.example.com/authorize"

        explicit = make_server("https://old.example.com/mcp", MCPAuth.oauth2, "https://configured.example.com/auth")
        _carry_forward_resolved_oauth_endpoints(new_server=explicit, previous_server=previous)
        assert explicit.authorization_url == "https://configured.example.com/auth"

    def test_carry_forward_does_not_revive_token_url_across_authorization_url_change(self):
        """Carry-forward is a non-manual endpoint source, so it obeys the same trust rule as
        discovery: a previous token_url/registration_url belongs to the previous authorization
        server, so it must not be pinned to a NEW authorization_url the admin re-pointed to. Without
        this, re-pointing authorize to server B while the same MCP url keeps serving A's token
        endpoint recreates the RFC 9700 mix-up, durably, and the discovery gate alone cannot catch
        it because the stale endpoint comes from the registry, not from discovery."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _carry_forward_resolved_oauth_endpoints,
        )

        previous = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp-a.example.com/authorize",
            token_url="https://idp-a.example.com/token",
            registration_url="https://idp-a.example.com/register",
        )
        repointed = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp-b.example.com/authorize",
        )

        _carry_forward_resolved_oauth_endpoints(new_server=repointed, previous_server=previous)

        assert repointed.authorization_url == "https://idp-b.example.com/authorize"
        assert repointed.token_url is None
        assert repointed.registration_url is None

    def test_carry_forward_restores_endpoints_when_authorization_url_unchanged(self):
        """The last-known-good path still works: a rebuild whose discovery blipped (no authorize
        endpoint) adopts the previous authorize endpoint AND its token endpoint together as a
        consistent group, and a rebuild that re-pins the same authorize endpoint (formatting aside)
        keeps carrying the corroborated token endpoint."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _carry_forward_resolved_oauth_endpoints,
        )

        def previous() -> MCPServer:
            return MCPServer(
                server_id="s1",
                name="s1",
                url="https://up.example.com/mcp",
                transport=MCPTransport.http,
                auth_type=MCPAuth.oauth2,
                authorization_url="https://idp.example.com/authorize",
                token_url="https://idp.example.com/token",
                registration_url="https://idp.example.com/register",
            )

        blipped = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url=None,
        )
        _carry_forward_resolved_oauth_endpoints(new_server=blipped, previous_server=previous())
        assert blipped.authorization_url == "https://idp.example.com/authorize"
        assert blipped.token_url == "https://idp.example.com/token"
        assert blipped.registration_url == "https://idp.example.com/register"

        same_authorize = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://IDP.example.com:443/authorize/",
        )
        _carry_forward_resolved_oauth_endpoints(new_server=same_authorize, previous_server=previous())
        assert same_authorize.token_url == "https://idp.example.com/token"
        assert same_authorize.registration_url == "https://idp.example.com/register"

    def test_carry_forward_does_not_restore_endpoints_for_issuer_anchored_server(self):
        """When the server is issuer-anchored the endpoints come solely from the §3.3-validated issuer
        document, so a failed issuer fetch (token_url None) must stay fail-closed. Carry-forward must
        NOT resurrect the previous registry entry's token endpoint, or the very attacker-controlled
        endpoint the issuer anchor distrusts would keep being served across rebuilds. Resource-driven
        scopes still carry as last-known-good. Anchoring is keyed on the explicit issuer_is_anchored
        flag, not on issuer truthiness, so a discovered issuer does not trip this fail-closed branch."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _carry_forward_resolved_oauth_endpoints,
        )

        previous = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            issuer_is_anchored=True,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["read"],
        )
        failed_rebuild = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            issuer_is_anchored=True,
        )

        _carry_forward_resolved_oauth_endpoints(new_server=failed_rebuild, previous_server=previous)

        assert failed_rebuild.authorization_url is None
        assert failed_rebuild.token_url is None
        assert failed_rebuild.registration_url is None
        assert failed_rebuild.scopes == ["read"]

    def test_carry_forward_restores_endpoints_for_discovered_issuer_not_anchored(self):
        """A server that merely DISCOVERED its issuer trust-on-first-use is not anchored: issuer is set
        for token identity but the endpoints are resource-rooted, so on a transient discovery blip they
        must still carry forward as last-known-good, the same as any resource-rooted server. This is the
        regression the explicit issuer_is_anchored flag prevents: keying fail-closed on issuer truthiness
        alone would drop the working endpoints the moment the server learned its issuer."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _carry_forward_resolved_oauth_endpoints,
        )

        previous = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            issuer_is_anchored=False,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
            scopes=["read"],
        )
        blipped_rebuild = MCPServer(
            server_id="s1",
            name="s1",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            issuer="https://idp.example.com",
            issuer_is_anchored=False,
            authorization_url=None,
        )

        _carry_forward_resolved_oauth_endpoints(new_server=blipped_rebuild, previous_server=previous)

        assert blipped_rebuild.authorization_url == "https://idp.example.com/authorize"
        assert blipped_rebuild.token_url == "https://idp.example.com/token"
        assert blipped_rebuild.registration_url == "https://idp.example.com/register"
        assert blipped_rebuild.scopes == ["read"]

    def test_normalized_authorize_endpoint_treats_default_port_and_slash_as_identity(self):
        """The corroboration check must not fail on formatting-only differences an IdP legitimately
        emits: default port, trailing slash, host case, and query string are not identity, but a
        non-default port is."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _normalized_authorize_endpoint,
        )

        canonical = _normalized_authorize_endpoint("https://idp.example.com/authorize")
        assert _normalized_authorize_endpoint("https://idp.example.com:443/authorize") == canonical
        assert _normalized_authorize_endpoint("https://IDP.example.com/authorize/") == canonical
        assert _normalized_authorize_endpoint("https://idp.example.com/authorize?prompt=consent") == canonical
        assert _normalized_authorize_endpoint("https://idp.example.com:8443/authorize") != canonical

    def test_issuer_matches_rfc8414_section_3_3(self):
        """Issuer equality tolerates only URL-insignificant differences (scheme/host case, default
        port, a trailing slash). A different host, a non-string, an empty string, or a None issuer
        never matches, so a document that omits issuer fails closed under issuer-anchored discovery."""
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import _issuer_matches

        assert _issuer_matches("https://mcp.slack.com", "https://mcp.slack.com")
        assert _issuer_matches("https://MCP.slack.com/", "https://mcp.slack.com")
        assert _issuer_matches("https://mcp.slack.com:443", "https://mcp.slack.com")
        assert _issuer_matches("https://login.example.com/tenant/v2.0", "https://login.example.com/tenant/v2.0")
        assert not _issuer_matches("https://attacker.example.com", "https://mcp.slack.com")
        assert not _issuer_matches("https://login.example.com/other/v2.0", "https://login.example.com/tenant/v2.0")
        assert not _issuer_matches(None, "https://mcp.slack.com")
        assert not _issuer_matches("", "https://mcp.slack.com")
        assert not _issuer_matches(123, "https://mcp.slack.com")

    def test_build_mcp_server_table_preserves_timestamps(self):
        """_build_mcp_server_table must use the MCPServer's stored timestamps, not datetime.now()."""
        manager = MCPServerManager()

        created = datetime(2024, 1, 15, 10, 0, 0)
        updated = datetime(2024, 6, 20, 12, 30, 0)

        server = MCPServer(
            server_id="ts-server-2",
            name="ts_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            created_at=created,
            updated_at=updated,
        )

        table = manager._build_mcp_server_table(server)

        assert table.created_at == created
        assert table.updated_at == updated

    def test_build_mcp_server_table_none_timestamps_when_not_set(self):
        """_build_mcp_server_table must return None timestamps when not set on MCPServer."""
        manager = MCPServerManager()

        server = MCPServer(
            server_id="ts-server-3",
            name="ts_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
        )

        table = manager._build_mcp_server_table(server)

        assert table.created_at is None
        assert table.updated_at is None

    def test_build_mcp_server_table_preserves_tool_overrides(self):
        """Tool display/description overrides must survive registry -> API table conversion."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="override-server",
            name="deepwiki",
            server_name="deepwiki_mcp",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            tool_name_to_display_name={"read_wiki_structure": "browse_docs"},
            tool_name_to_description={"read_wiki_structure": "Browse repository documentation"},
        )

        table = manager._build_mcp_server_table(server)

        assert table.tool_name_to_display_name == {"read_wiki_structure": "browse_docs"}
        assert table.tool_name_to_description == {"read_wiki_structure": "Browse repository documentation"}

    @pytest.mark.asyncio
    async def test_round_trip_timestamps_preserved(self):
        """Timestamps survive the full round-trip: LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable."""
        manager = MCPServerManager()

        created = datetime(2023, 3, 10, 8, 0, 0)
        updated = datetime(2023, 9, 5, 16, 45, 0)

        table_record = LiteLLM_MCPServerTable(
            server_id="ts-server-4",
            server_name="ts_server_rt",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            created_at=created,
            updated_at=updated,
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)
        rebuilt_table = manager._build_mcp_server_table(mcp_server)

        assert rebuilt_table.created_at == created
        assert rebuilt_table.updated_at == updated

    def test_deserialize_json_list_normalizes_pydantic_models(self):
        """Prisma hydrates the ``env_vars`` JSON column into ``MCPEnvVar`` models;
        ``_deserialize_json_list`` must hand back plain dicts so ``MCPServer``
        (typed ``List[Dict[str, Any]]``) validates."""
        env_vars = [
            MCPEnvVar(name="GITHUB_TOKEN", scope=MCPEnvVarScope.user, description="PAT"),
            MCPEnvVar(name="REGION", value="us-east-1", scope=MCPEnvVarScope.global_),
        ]
        result = _deserialize_json_list(env_vars)
        assert result is not None
        assert all(isinstance(item, dict) for item in result)
        assert result[0]["name"] == "GITHUB_TOKEN"
        assert result[0]["scope"] == "user"
        assert result[1]["value"] == "us-east-1"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_with_model_env_vars(self):
        """Regression: a DB row whose ``env_vars`` is a list of ``MCPEnvVar``
        models (as Prisma returns) must build into an ``MCPServer`` instead of
        raising a Pydantic ``dict_type`` validation error that silently drops
        the server from the registry."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="env-var-server-1",
            server_name="github_peruser",
            url="https://api.githubcopilot.com/mcp/",
            transport=MCPTransport.http,
            static_headers={"Authorization": "Bearer ${GITHUB_TOKEN}"},
            env_vars=[
                MCPEnvVar(
                    name="GITHUB_TOKEN",
                    scope=MCPEnvVarScope.user,
                    description="Your personal GitHub PAT",
                )
            ],
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.env_vars == [
            {
                "name": "GITHUB_TOKEN",
                "value": "",
                "scope": "user",
                "description": "Your personal GitHub PAT",
            }
        ]

    @pytest.mark.asyncio
    async def test_round_trip_source_url_preserved(self):
        """source_url survives the full round-trip: LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.

        Regression test: the list endpoint (GET /v1/mcp/server) builds its
        response from the registry via this round-trip, so a dropped field
        here surfaces as a null source_url in the list response even though
        the value is stored in the DB.
        """
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="src-url-server",
            server_name="src_url_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            source_url="https://github.com/org/mcp-server",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)
        assert mcp_server.source_url == "https://github.com/org/mcp-server"

        rebuilt_table = manager._build_mcp_server_table(mcp_server)
        assert rebuilt_table.source_url == "https://github.com/org/mcp-server"

    @pytest.mark.asyncio
    async def test_round_trip_timeout_preserved(self):
        """timeout survives the full round-trip: LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable."""
        manager = MCPServerManager()
        table_record = LiteLLM_MCPServerTable(
            server_id="timeout-server",
            server_name="timeout_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            timeout=120.0,
        )
        mcp_server = await manager.build_mcp_server_from_table(table_record)
        assert mcp_server.timeout == 120.0

        rebuilt_table = manager._build_mcp_server_table(mcp_server)
        assert rebuilt_table.timeout == 120.0

    @pytest.mark.asyncio
    async def test_create_mcp_client_uses_server_timeout(self):
        """_create_mcp_client must pass server.timeout to MCPClient when set."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="timeout-client-server",
            name="timeout_client_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            timeout=180.0,
        )
        client = await manager._create_mcp_client(server)
        assert client.timeout == 180.0

    @pytest.mark.asyncio
    async def test_create_mcp_client_falls_back_to_global_timeout(self):
        """_create_mcp_client must fall back to MCP_CLIENT_TIMEOUT when server.timeout is None."""
        from litellm.constants import MCP_CLIENT_TIMEOUT

        manager = MCPServerManager()
        server = MCPServer(
            server_id="default-timeout-server",
            name="default_timeout_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
        )
        client = await manager._create_mcp_client(server)
        assert client.timeout == MCP_CLIENT_TIMEOUT

    @pytest.mark.asyncio
    async def test_create_mcp_client_zero_timeout_not_treated_as_falsy(self):
        """server.timeout=0.0 must be passed through, not fall back to MCP_CLIENT_TIMEOUT."""
        manager = MCPServerManager()
        server = MCPServer(
            server_id="zero-timeout-server",
            name="zero_timeout_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            timeout=0.0,
        )
        client = await manager._create_mcp_client(server)
        assert client.timeout == 0.0

    @pytest.mark.asyncio
    async def test_load_servers_from_config_preserves_timeout(self):
        """timeout from proxy config is loaded into MCPServer."""
        manager = MCPServerManager()
        config = {
            "my_server": {
                "url": "https://example.com/mcp",
                "transport": MCPTransport.http,
                "timeout": 90.0,
            }
        }
        await manager.load_servers_from_config(config)
        servers = list(manager.config_mcp_servers.values())
        assert len(servers) == 1
        assert servers[0].timeout == 90.0

    @pytest.mark.asyncio
    async def test_call_regular_mcp_tool_timeout_returns_504(self):
        """When the MCP client call is cancelled (timeout), _call_regular_mcp_tool raises HTTPException 504."""
        from unittest.mock import AsyncMock, patch

        manager = MCPServerManager()

        async def _slow_call(*args, **kwargs):
            await asyncio.sleep(999)

        mock_client = AsyncMock()
        mock_client.call_tool = _slow_call

        server = MCPServer(
            server_id="timeout-tool-server",
            name="timeout_tool_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            timeout=0.01,
        )

        with patch.object(manager, "_create_mcp_client", return_value=mock_client):
            with pytest.raises(HTTPException) as exc_info:
                await manager._call_regular_mcp_tool(
                    mcp_server=server,
                    original_tool_name="some_tool",
                    arguments={},
                    tasks=[],
                    mcp_auth_header=None,
                    mcp_server_auth_headers=None,
                    oauth2_headers=None,
                    raw_headers=None,
                    proxy_logging_obj=None,
                )

        assert exc_info.value.status_code == 504
        assert exc_info.value.detail["error"] == "timeout"
        assert "0.01s" in exc_info.value.detail["message"]


class TestMCPServerTokenExchangeColumns:
    """Token-exchange (RFC 8693) config persists through the dedicated columns added for the
    create/update REST + DB path, mirroring how ``token_url`` is stored. The credentials JSON
    blob is kept as a read-fallback so servers persisted before the columns existed still load."""

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_reads_token_exchange_columns(self):
        """The DB->runtime loader must read the three fields from the dedicated columns. Before the
        columns existed it only read the credentials blob, so column values would be dropped."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-cols",
            server_name="te_cols",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/oauth2/token",
            audience="https://upstream.example.com",
            subject_token_type="urn:ietf:params:oauth:token-type:jwt",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
        assert mcp_server.audience == "https://upstream.example.com"
        assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:jwt"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_falls_back_to_credentials_blob(self):
        """Backwards compatibility: a server whose token-exchange config lives only in the
        credentials blob (no columns) must still load with those values."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-blob",
            server_name="te_blob",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            credentials={
                "token_exchange_endpoint": "https://idp.example.com/legacy/token",
                "audience": "legacy-audience",
                "subject_token_type": "urn:ietf:params:oauth:token-type:saml2",
            },
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.token_exchange_endpoint == "https://idp.example.com/legacy/token"
        assert mcp_server.audience == "legacy-audience"
        assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:saml2"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_subject_token_type_defaults(self):
        """subject_token_type falls back to the RFC 8693 access_token URN when unset."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-default",
            server_name="te_default",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/oauth2/token",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:access_token"

    @pytest.mark.asyncio
    async def test_round_trip_token_exchange_columns_preserved(self):
        """The three fields survive LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.
        Before the table builder wrote them back, a registry round-trip dropped them."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-rt",
            server_name="te_rt",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/oauth2/token",
            audience="https://upstream.example.com",
            subject_token_type="urn:ietf:params:oauth:token-type:jwt",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)
        rebuilt_table = manager._build_mcp_server_table(mcp_server)

        assert rebuilt_table.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
        assert rebuilt_table.audience == "https://upstream.example.com"
        assert rebuilt_table.subject_token_type == "urn:ietf:params:oauth:token-type:jwt"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_reads_token_exchange_profile_column(self):
        """The profile dialect selector (rfc8693 vs entra_obo) is read from its dedicated column
        so a server created via the REST API/UI as entra_obo resolves to the Entra dialect."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-profile",
            server_name="te_profile",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
            token_exchange_profile="entra_obo",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.token_exchange_profile == "entra_obo"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_token_exchange_profile_defaults_rfc8693(self):
        """token_exchange_profile falls back to rfc8693 when neither column nor blob sets it."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-profile-default",
            server_name="te_profile_default",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/oauth2/token",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.token_exchange_profile == "rfc8693"

    @pytest.mark.asyncio
    async def test_build_mcp_server_from_table_token_exchange_profile_blob_fallback(self):
        """Backwards compatibility: a server with the profile only in the credentials blob still loads."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-profile-blob",
            server_name="te_profile_blob",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            credentials={"token_exchange_profile": "entra_obo"},
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)

        assert mcp_server.token_exchange_profile == "entra_obo"

    @pytest.mark.asyncio
    async def test_round_trip_token_exchange_profile_preserved(self):
        """token_exchange_profile survives LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable."""
        manager = MCPServerManager()

        table_record = LiteLLM_MCPServerTable(
            server_id="te-profile-rt",
            server_name="te_profile_rt",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_profile="entra_obo",
        )

        mcp_server = await manager.build_mcp_server_from_table(table_record)
        rebuilt_table = manager._build_mcp_server_table(mcp_server)

        assert rebuilt_table.token_exchange_profile == "entra_obo"


class TestInternalDelegatePkceWarningLog:
    @pytest.mark.asyncio
    async def test_build_mcp_server_logs_on_internal_delegate_interactive(self, caplog):
        caplog.set_level(logging.WARNING, logger="LiteLLM")
        manager = MCPServerManager()
        table_record = LiteLLM_MCPServerTable(
            server_id="warn-del-1",
            server_name="warn_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            available_on_public_internet=False,
            delegate_auth_to_upstream=True,
        )
        await manager.build_mcp_server_from_table(table_record)
        combined = " ".join(r.getMessage() for r in caplog.records)
        assert "internal-only" in combined
        assert "delegate_auth_to_upstream=true" in combined

    @pytest.mark.asyncio
    async def test_build_mcp_server_no_internal_delegate_log_when_public(self, caplog):
        caplog.set_level(logging.WARNING, logger="LiteLLM")
        manager = MCPServerManager()
        table_record = LiteLLM_MCPServerTable(
            server_id="warn-del-2",
            server_name="warn_server_pub",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            available_on_public_internet=True,
            delegate_auth_to_upstream=True,
        )
        await manager.build_mcp_server_from_table(table_record)
        combined = " ".join(r.getMessage() for r in caplog.records)
        assert "internal-only" not in combined

    def test_warn_skipped_for_client_credentials(self, caplog):
        caplog.set_level(logging.WARNING, logger="LiteLLM")
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _warn_internal_delegate_pkce_if_applicable,
        )

        server = MCPServer(
            server_id="m2m-1",
            name="x",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="client_credentials",
            available_on_public_internet=False,
            delegate_auth_to_upstream=True,
        )
        _warn_internal_delegate_pkce_if_applicable(server, source="test")
        combined = " ".join(r.getMessage() for r in caplog.records)
        assert "internal-only" not in combined


class TestHasClientCredentialsOAuth2Flow:
    """
    Regression tests for the M2M auto-detection bug.

    Before the fix, has_client_credentials returned True whenever
    client_id + client_secret + token_url were all set, even for
    interactive OAuth setups (e.g. GitHub Enterprise). This silently
    dropped user tokens and fetched M2M tokens instead.

    The fix: M2M must be opted in explicitly via oauth2_flow="client_credentials".
    """

    def _make_server(self, **kwargs) -> MCPServer:
        return MCPServer(
            server_id="test-server",
            name="test-server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            url="https://github.example.com/mcp",
            **kwargs,
        )

    def test_all_three_fields_set_without_oauth2_flow_is_not_m2m(self):
        """
        GitHub Enterprise regression: client_id + client_secret + token_url
        should NOT trigger M2M flow unless oauth2_flow is explicitly set.
        """
        server = self._make_server(
            client_id="gh-client-id",
            client_secret="gh-client-secret",
            token_url="https://github.example.com/login/oauth/access_token",
        )
        assert server.has_client_credentials is False

    def test_explicit_client_credentials_flow_enables_m2m(self):
        """oauth2_flow='client_credentials' opts in to M2M."""
        server = self._make_server(
            client_id="svc-client-id",
            client_secret="svc-client-secret",
            token_url="https://idp.example.com/token",
            oauth2_flow="client_credentials",
        )
        assert server.has_client_credentials is True

    def test_explicit_authorization_code_flow_disables_m2m(self):
        """oauth2_flow='authorization_code' always returns False."""
        server = self._make_server(
            client_id="gh-client-id",
            client_secret="gh-client-secret",
            token_url="https://github.example.com/login/oauth/access_token",
            oauth2_flow="authorization_code",
        )
        assert server.has_client_credentials is False

    def test_no_fields_no_flow_is_not_m2m(self):
        """No credentials configured — not M2M."""
        server = self._make_server()
        assert server.has_client_credentials is False

    def test_partial_fields_without_flow_is_not_m2m(self):
        """Partial credential fields without explicit flow — not M2M."""
        server = self._make_server(
            client_id="only-client-id",
        )
        assert server.has_client_credentials is False

    def test_needs_user_oauth_token_true_without_explicit_m2m(self):
        """
        Without oauth2_flow='client_credentials', an oauth2 server with
        client fields set still needs a user OAuth token (interactive flow).
        """
        server = self._make_server(
            client_id="gh-client-id",
            client_secret="gh-client-secret",
            token_url="https://github.example.com/login/oauth/access_token",
        )
        assert server.needs_user_oauth_token is True

    def test_needs_user_oauth_token_false_with_explicit_m2m(self):
        """With oauth2_flow='client_credentials', no per-user token needed."""
        server = self._make_server(
            client_id="svc-client-id",
            client_secret="svc-client-secret",
            token_url="https://idp.example.com/token",
            oauth2_flow="client_credentials",
        )
        assert server.needs_user_oauth_token is False


# ---------------------------------------------------------------------------
# Upstream initialize-instructions cache
# ---------------------------------------------------------------------------


class TestMCPServerManagerUpstreamInstructionsCache:
    """Tests for the upstream initialize-instructions cache."""

    def test_get_returns_none_when_empty(self):
        """Empty cache returns None for any key."""
        manager = MCPServerManager()
        assert manager._upstream_initialize_instructions_by_server_id.get("nonexistent") is None

    def test_remember_stores_stripped_value(self):
        """_remember_upstream_initialize_instructions stores a stripped string."""
        manager = MCPServerManager()
        fake_server = MagicMock(server_id="srv")
        fake_client = MagicMock(_last_initialize_instructions="  hello \n")
        manager._remember_upstream_initialize_instructions(fake_server, fake_client)
        assert manager._upstream_initialize_instructions_by_server_id.get("srv") == "hello"

    def test_remember_ignores_empty_string(self):
        """Whitespace-only instructions are not stored."""
        manager = MCPServerManager()
        fake_server = MagicMock(server_id="srv")
        fake_client = MagicMock(_last_initialize_instructions="   ")
        manager._remember_upstream_initialize_instructions(fake_server, fake_client)
        assert manager._upstream_initialize_instructions_by_server_id.get("srv") is None

    def test_remember_ignores_none(self):
        """None instructions are not stored."""
        manager = MCPServerManager()
        fake_server = MagicMock(server_id="srv")
        fake_client = MagicMock(_last_initialize_instructions=None)
        manager._remember_upstream_initialize_instructions(fake_server, fake_client)
        assert manager._upstream_initialize_instructions_by_server_id.get("srv") is None

    @pytest.mark.asyncio
    async def test_load_servers_from_config_clears_cache(self):
        """Reloading config clears any previously cached upstream instructions."""
        manager = MCPServerManager()
        manager._upstream_initialize_instructions_by_server_id["old"] = "stale"
        await manager.load_servers_from_config(
            mcp_servers_config={
                "fresh_srv": {
                    "url": "https://example.com",
                    "instructions": "from yaml",
                }
            }
        )
        assert manager._upstream_initialize_instructions_by_server_id.get("old") is None

    @pytest.mark.asyncio
    async def test_load_servers_reads_instructions_from_config(self):
        """instructions field from YAML config is persisted on the MCPServer."""
        manager = MCPServerManager()
        await manager.load_servers_from_config(
            mcp_servers_config={
                "srv_a": {
                    "url": "https://a.example.com",
                    "instructions": "A instructions",
                },
                "srv_b": {
                    "url": "https://b.example.com",
                },
            }
        )
        by_name = {s.server_name: s for s in manager.config_mcp_servers.values()}
        assert "srv_a" in by_name and by_name["srv_a"].instructions == "A instructions"
        assert "srv_b" in by_name and by_name["srv_b"].instructions is None


class TestMCPServerManagerExpandPermissionList:
    """Tests for the alias/name-aware permission list expansion used by team MCP permissions."""

    def _make_server(
        self,
        server_id: str,
        server_name: str,
        alias=None,
        name=None,
    ) -> MCPServer:
        return MCPServer(
            server_id=server_id,
            name=name if name is not None else (alias or server_name),
            alias=alias,
            server_name=server_name,
            url=f"https://{server_id}.example.com",
            transport=MCPTransport.http,
        )

    def test_empty_list_returns_empty(self):
        manager = MCPServerManager()
        assert manager.expand_permission_list([]) == []

    def test_expands_server_name(self):
        manager = MCPServerManager()
        manager.config_mcp_servers["id-usw1"] = self._make_server("id-usw1", server_name="a")

        assert manager.expand_permission_list(["a"]) == ["id-usw1"]

    def test_expands_alias(self):
        manager = MCPServerManager()
        manager.config_mcp_servers["id-1"] = self._make_server(
            "id-1", server_name="internal_name", alias="public_alias"
        )

        assert manager.expand_permission_list(["public_alias"]) == ["id-1"]

    def test_passes_through_unknown_entry(self):
        """Unresolved entries pass through unchanged (with a debug log) —
        the downstream access check denies them when compared to the
        concrete request server_id."""
        manager = MCPServerManager()
        manager.config_mcp_servers["id-1"] = self._make_server("id-1", server_name="b")

        assert manager.expand_permission_list(["a"]) == ["a"]

    def test_name_collision_expands_to_all_matches(self):
        """Two servers sharing a server_name both resolve — the documented behavior."""
        manager = MCPServerManager()
        manager.config_mcp_servers["id-config"] = self._make_server("id-config", server_name="shared")
        manager.registry["id-db"] = self._make_server("id-db", server_name="shared")

        assert sorted(manager.expand_permission_list(["shared"])) == [
            "id-config",
            "id-db",
        ]

    def test_searches_config_and_registry_union(self):
        manager = MCPServerManager()
        manager.config_mcp_servers["cfg-id"] = self._make_server("cfg-id", server_name="a")
        manager.registry["reg-id"] = self._make_server("reg-id", server_name="b")

        assert manager.expand_permission_list(["a"]) == ["cfg-id"]
        assert manager.expand_permission_list(["b"]) == ["reg-id"]

    def test_id_match_takes_precedence_over_name_match(self):
        """
        If a permission entry matches a server_id directly, don't also add
        servers whose server_name happens to equal that id.
        """
        manager = MCPServerManager()
        manager.config_mcp_servers["id-1"] = self._make_server("id-1", server_name="other_name")
        manager.config_mcp_servers["id-2"] = self._make_server("id-2", server_name="id-1")

        assert manager.expand_permission_list(["id-1"]) == ["id-1"]

    def test_mixed_ids_and_names_in_same_list(self):
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-1"] = self._make_server("uuid-1", server_name="a")
        manager.config_mcp_servers["uuid-2"] = self._make_server("uuid-2", server_name="b")

        # ["uuid-1", "b"] -> uuid-1 passes through, "b" resolves to uuid-2
        assert sorted(manager.expand_permission_list(["uuid-1", "b"])) == [
            "uuid-1",
            "uuid-2",
        ]

    def test_deduplicates_overlapping_id_and_name_entries(self):
        """If a list references the same server by both id and name, return it once."""
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-1"] = self._make_server("uuid-1", server_name="a")

        assert manager.expand_permission_list(["uuid-1", "a"]) == ["uuid-1"]

    def test_simulates_cross_region_portability(self):
        """
        Same permission entry "a" resolves to different concrete IDs per region —
        the cross-region portability the customer is asking for.
        """
        usw1 = MCPServerManager()
        usw1.config_mcp_servers["hash-usw1"] = self._make_server("hash-usw1", server_name="a")

        usc1 = MCPServerManager()
        usc1.config_mcp_servers["hash-usc1"] = self._make_server("hash-usc1", server_name="a")

        assert usw1.expand_permission_list(["a"]) == ["hash-usw1"]
        assert usc1.expand_permission_list(["a"]) == ["hash-usc1"]


class TestMCPServerManagerExpandToolPermissions:
    """Tests for tool-permission dict rewriting — the privilege-escalation guard."""

    def _make_server(self, server_id: str, server_name: str, alias=None) -> MCPServer:
        return MCPServer(
            server_id=server_id,
            name=alias or server_name,
            alias=alias,
            server_name=server_name,
            url=f"https://{server_id}.example.com",
            transport=MCPTransport.http,
        )

    def test_empty_or_none_returns_empty_dict(self):
        manager = MCPServerManager()
        assert manager.expand_tool_permissions(None) == {}
        assert manager.expand_tool_permissions({}) == {}

    def test_rewrites_name_key_to_server_id(self):
        """Privilege-escalation guard: a name-based key must resolve to the
        concrete server_id, otherwise `.get(server_id)` misses and the tool
        restriction is silently dropped (caller treats None as allow-all)."""
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="my-alias")

        result = manager.expand_tool_permissions({"my-alias": ["read_file"]})
        assert result == {"uuid-a": ["read_file"]}

    def test_passes_through_existing_server_id_key(self):
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="alpha")

        result = manager.expand_tool_permissions({"uuid-a": ["read_file"]})
        assert result == {"uuid-a": ["read_file"]}

    def test_unresolved_key_passes_through_unchanged(self):
        """A stale id-keyed restriction (server since deleted, or just a
        test-fixture placeholder) must still apply when something looks it
        up by that same string — dropping would silently remove the
        restriction."""
        manager = MCPServerManager()

        result = manager.expand_tool_permissions({"stale-uuid": ["read_file"]})
        assert result == {"stale-uuid": ["read_file"]}

    def test_name_collision_unions_tool_lists(self):
        """Two servers sharing a server_name both match; their tool lists get
        the restriction (matches the list-expansion collision semantics)."""
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-1"] = self._make_server("uuid-1", server_name="shared")
        manager.registry["uuid-2"] = self._make_server("uuid-2", server_name="shared")

        result = manager.expand_tool_permissions({"shared": ["read_file"]})
        assert sorted(result.keys()) == ["uuid-1", "uuid-2"]
        assert result["uuid-1"] == ["read_file"]
        assert result["uuid-2"] == ["read_file"]

    def test_id_and_name_keys_pointing_at_same_server_union_tools(self):
        """If the admin writes both {"uuid-a": [...], "alias-a": [...]} and
        both refer to the same server, the tool lists are unioned rather
        than one overwriting the other."""
        manager = MCPServerManager()
        manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="alias-a")

        result = manager.expand_tool_permissions({"uuid-a": ["read_file"], "alias-a": ["write_file"]})
        assert sorted(result["uuid-a"]) == ["read_file", "write_file"]


class TestOAuthDiscoverySSRFGuard:
    """SSRF guard for the OAuth metadata discovery follow-up fetches.

    The vulnerability: a malicious MCP server returns a ``WWW-Authenticate``
    header pointing at an attacker-chosen ``resource_metadata`` URL, then a
    PRM JSON whose ``authorization_servers[0]`` points at internal/loopback
    addresses, coercing the proxy into making blind GETs to cloud-metadata
    services, internal admin panels, or loopback debug endpoints.
    """

    @staticmethod
    def _patch_resolves(monkeypatch, mapping):
        """Patch ``socket.getaddrinfo`` for a deterministic SSRF-guard test.

        ``mapping`` is ``{hostname: [ip-string, ...]}``; unknown hosts raise
        ``gaierror`` (treated as "unresolvable" -> blocked by async_safe_get).
        """
        import socket as _socket

        def fake_getaddrinfo(host, port, *args, **kwargs):
            if host not in mapping:
                raise _socket.gaierror(f"unknown host {host}")
            family = _socket.AF_INET
            return [(family, _socket.SOCK_STREAM, 0, "", (ip, port)) for ip in mapping[host]]

        monkeypatch.setattr(
            "litellm.litellm_core_utils.url_utils.socket.getaddrinfo",
            fake_getaddrinfo,
        )

    def test_same_authority_url_is_direct_fetch_eligible(self):
        # Same scheme + host + port skips DNS entirely — the well-known
        # endpoint construction in _attempt_well_known_discovery always
        # produces same-authority URLs against the admin's server_url.
        assert MCPServerManager._is_same_authority_metadata_url(
            "https://example.com/.well-known/oauth-protected-resource",
            "https://example.com/mcp",
        )

    def test_same_host_different_port_uses_safe_fetch_path(self):
        assert not MCPServerManager._is_same_authority_metadata_url(
            "https://example.com:9999/.well-known/oauth-protected-resource",
            "https://example.com/mcp",
        )

    @pytest.mark.parametrize(
        "ip",
        [
            "127.0.0.1",  # loopback
            "10.0.0.5",  # RFC1918
            "172.16.0.1",  # RFC1918
            "192.168.1.1",  # RFC1918
            "169.254.169.254",  # AWS / Azure / GCP IMDS
            "100.100.100.200",  # Alibaba Cloud metadata
            "0.0.0.0",  # unspecified
            "::1",  # IPv6 loopback
            "fe80::1",  # IPv6 link-local
            "fc00::1",  # IPv6 ULA
        ],
    )
    @pytest.mark.asyncio
    async def test_cross_origin_blocked_when_resolves_to_unsafe_ip(self, monkeypatch, ip):
        self._patch_resolves(monkeypatch, {"attacker.example.com": [ip]})
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(
                "https://attacker.example.com",
                "https://legit-mcp.example.com/mcp",
            )

        assert result is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_cross_origin_allowed_when_resolves_to_public_ip(self, monkeypatch):
        self._patch_resolves(monkeypatch, {"login.microsoftonline.com": ["20.190.151.7"]})
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.is_redirect = False
        mock_response.raise_for_status = MagicMock()
        mock_response.json.return_value = {
            "authorization_servers": ["https://login.microsoftonline.com/tenant/v2.0"],
            "scopes_supported": ["mcp.read"],
        }

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=mock_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration",
                "https://atlassian-mcp.example.com/mcp",
            )

        assert servers == ["https://login.microsoftonline.com/tenant/v2.0"]
        assert scopes == ["mcp.read"]
        mock_client.get.assert_awaited_once()
        assert mock_client.get.await_args.kwargs["follow_redirects"] is False
        assert mock_client.get.await_args.kwargs["headers"]["Host"] == "login.microsoftonline.com"

    @pytest.mark.asyncio
    async def test_cross_origin_blocked_when_unresolvable(self, monkeypatch):
        self._patch_resolves(monkeypatch, {})
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://nope.example.invalid/.well-known/oauth-authorization-server",
                "https://legit-mcp.example.com/mcp",
            )

        assert servers == []
        assert scopes is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_non_http_scheme_is_not_safe(self):
        manager = MCPServerManager()
        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "file:///etc/passwd",
                "https://example.com/mcp",
            )
            result = await manager._fetch_single_authorization_server_metadata(
                "gopher://example.com/",
                "https://example.com/mcp",
            )

        assert servers == []
        assert scopes is None
        assert result is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_dual_resolution_blocked_if_any_ip_unsafe(self, monkeypatch):
        # If the attacker controls a DNS record returning multiple A records,
        # one of which is private, async_safe_get rejects before any network call.
        self._patch_resolves(monkeypatch, {"dual-stack.example.com": ["8.8.8.8", "127.0.0.1"]})
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://dual-stack.example.com/.well-known/oauth-authorization-server",
                "https://legit-mcp.example.com/mcp",
            )

        assert servers == []
        assert scopes is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_fetch_oauth_metadata_refuses_unsafe_url(self, monkeypatch):
        # End-to-end: a malicious WWW-Authenticate redirecting to a loopback
        # resource_metadata URL must not produce a network call.
        self._patch_resolves(monkeypatch, {"attacker.example.com": ["127.0.0.1"]})
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://attacker.example.com/meta",
                "https://legit-mcp.example.com/mcp",
            )

        assert servers == []
        assert scopes is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_empty_getaddrinfo_result_blocks_url(self, monkeypatch):
        # POSIX doesn't strictly forbid an empty success-list from getaddrinfo.
        # async_safe_get must fail closed rather than making a network call.
        monkeypatch.setattr(
            "litellm.litellm_core_utils.url_utils.socket.getaddrinfo",
            lambda *a, **k: [],
        )
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            servers, scopes = await manager._fetch_oauth_metadata_from_resource(
                "https://no-records.example.com/.well-known/oauth-authorization-server",
                "https://legit-mcp.example.com/mcp",
            )

        assert servers == []
        assert scopes is None
        mock_client.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_cross_origin_redirect_is_revalidated(self, monkeypatch):
        self._patch_resolves(
            monkeypatch,
            {
                "provider.example.com": ["8.8.8.8"],
                "127.0.0.1": ["127.0.0.1"],
            },
        )
        manager = MCPServerManager()

        redirect_response = MagicMock()
        redirect_response.is_redirect = True
        redirect_response.headers = {"location": "http://127.0.0.1/admin"}

        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=redirect_response)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(
                "https://provider.example.com",
                "https://legit-mcp.example.com/mcp",
            )

        assert result is None
        assert mock_client.get.await_count == 3

    @pytest.mark.asyncio
    async def test_same_authority_fetch_does_not_follow_redirects(self):
        # Same-authority URLs may be internal admin-configured MCP servers, so
        # they are fetched directly. Redirects are still disabled because a
        # Location target would not inherit the same-authority guarantee.
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.json.return_value = {
            "authorization_servers": ["https://auth.example.com"],
        }
        mock_response.raise_for_status = MagicMock()

        captured_kwargs: Dict[str, Any] = {}

        async def fake_get(url, **kwargs):
            captured_kwargs.update(kwargs)
            return mock_response

        mock_client = MagicMock()
        mock_client.get = AsyncMock(side_effect=fake_get)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            await manager._fetch_oauth_metadata_from_resource(
                "https://protected.example.com/.well-known/oauth",
                "https://protected.example.com/mcp",
            )
        assert captured_kwargs.get("follow_redirects") is False

    @pytest.mark.asyncio
    async def test_same_authority_auth_server_fetch_does_not_follow_redirects(self):
        # Same redirect-bypass concern for the authorization-server fetch path.
        manager = MCPServerManager()

        mock_response = MagicMock()
        mock_response.json.return_value = {
            "authorization_endpoint": "https://provider.example.com/authorize",
            "token_endpoint": "https://provider.example.com/token",
        }
        mock_response.raise_for_status = MagicMock()

        captured_kwargs: Dict[str, Any] = {}

        async def fake_get(url, **kwargs):
            captured_kwargs.update(kwargs)
            return mock_response

        mock_client = MagicMock()
        mock_client.get = AsyncMock(side_effect=fake_get)

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            await manager._fetch_single_authorization_server_metadata(
                "https://provider.example.com",
                "https://provider.example.com",
            )
        assert captured_kwargs.get("follow_redirects") is False

    @pytest.mark.asyncio
    async def test_fetch_authorization_server_refuses_unsafe_issuer(self, monkeypatch):
        # Mirrors the GHSA-mrfv repro: PRM lists a loopback issuer URL.
        self._patch_resolves(monkeypatch, {"attacker.example.com": ["127.0.0.1"]})
        manager = MCPServerManager()

        mock_client = MagicMock()
        mock_client.get = AsyncMock()

        with patch(
            "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
            return_value=mock_client,
        ):
            result = await manager._fetch_single_authorization_server_metadata(
                "http://attacker.example.com:19999",
                "https://legit-mcp.example.com/mcp",
            )

        assert result is None
        mock_client.get.assert_not_called()


class TestApprovalStatusGate:
    """
    Regression tests for GHSA-gm4g-h72v-jhc3.

    The runtime registry must only contain servers an admin has approved.
    A non-admin can submit a pending stdio MCP server with an attacker-chosen
    command/args; before this gate, an admin opening the per-row endpoint
    triggered ``add_server`` + ``health_check_server``, which spawned the
    attacker's process under the proxy. The data-layer gate in
    ``add_server`` / ``update_server`` blocks pending and rejected rows
    from entering the registry regardless of which caller passes them in.
    """

    def _make_server(self, server_id: str, approval_status):
        return LiteLLM_MCPServerTable(
            server_id=server_id,
            alias=f"server_{server_id}",
            description="test",
            url=None,
            transport=MCPTransport.stdio,
            command="python",
            args=["-c", "print('attacker payload')"],
            env={},
            approval_status=approval_status,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

    @pytest.mark.parametrize(
        "approval_status,expect_in_registry",
        [
            (MCPApprovalStatus.pending_review, False),
            (MCPApprovalStatus.rejected, False),
            (MCPApprovalStatus.active, True),
            # Legacy rows: NULL predates the approval workflow; "approved" is
            # a legacy alias for "active" still present in older deployments.
            # Both must continue to load to match the DB-level filter in
            # reload_servers_from_database().
            (None, True),
            ("approved", True),
        ],
    )
    async def test_add_server_respects_approval_status(self, approval_status, expect_in_registry):
        manager = MCPServerManager()
        server_id = f"sid-{approval_status}"
        await manager.add_server(self._make_server(server_id, approval_status))
        assert (server_id in manager.registry) is expect_in_registry

    async def test_update_server_evicts_when_transitioned_away_from_active(self):
        # An admin updates a previously-active server to rejected (or pending).
        # The stale registry entry must be evicted so subsequent tool calls
        # and health probes can't reach it.
        manager = MCPServerManager()
        await manager.add_server(self._make_server("evict-me", MCPApprovalStatus.active))
        assert "evict-me" in manager.registry

        await manager.update_server(self._make_server("evict-me", MCPApprovalStatus.rejected))
        assert "evict-me" not in manager.registry

    async def test_update_server_eviction_clears_openapi_routing_artifacts(self, tmp_path):
        """Rejecting a server must remove its OpenAPI tools and name mappings."""
        from litellm.proxy._experimental.mcp_server.tool_registry import (
            global_mcp_tool_registry,
        )
        from litellm.proxy._experimental.mcp_server.utils import (
            add_server_prefix_to_name,
            get_server_prefix,
        )

        manager = MCPServerManager()
        await manager.add_server(self._make_server("evict-openapi", MCPApprovalStatus.active))
        assert "evict-openapi" in manager.registry

        server = manager.registry["evict-openapi"]
        server.spec_path = str(tmp_path / "unused.yaml")
        prefix = get_server_prefix(server)
        prefixed = add_server_prefix_to_name("demo_tool", prefix)

        async def _noop_handler(**kwargs):
            return None

        global_mcp_tool_registry.register_tool(
            name=prefixed,
            description="demo",
            input_schema={"type": "object"},
            handler=_noop_handler,
        )
        manager.tool_name_to_mcp_server_name_mapping["demo_tool"] = prefix
        manager.tool_name_to_mcp_server_name_mapping[prefixed] = prefix

        await manager.update_server(self._make_server("evict-openapi", MCPApprovalStatus.rejected))

        assert "evict-openapi" not in manager.registry
        assert prefixed not in global_mcp_tool_registry.tools
        assert "demo_tool" not in manager.tool_name_to_mcp_server_name_mapping
        assert prefixed not in manager.tool_name_to_mcp_server_name_mapping

    async def test_update_server_noop_for_unregistered_pending(self):
        # update_server called with a pending row that was never registered
        # should silently return without adding it. Locks in the early-return
        # so a future refactor can't accidentally route the pending row to
        # build_mcp_server_from_table.
        manager = MCPServerManager()
        await manager.update_server(self._make_server("never-seen", MCPApprovalStatus.pending_review))
        assert "never-seen" not in manager.registry


class TestRegistryTableConversionPreservesEnvVars:
    """The registry ``MCPServer`` -> ``LiteLLM_MCPServerTable`` conversions back
    the GET /v1/mcp/server list and health responses, which populate the admin
    edit form. When they dropped ``env_vars`` the form loaded an empty list and
    saving any edit silently wiped the stored vars, so ``${VAR}`` static headers
    were forwarded upstream un-interpolated.
    """

    @staticmethod
    def _server_with_env_vars() -> MCPServer:
        return MCPServer(
            server_id="env-vars-server",
            name="env_vars_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            static_headers={"X-Db-Url": "${DB_PROTOCOL}://${CORP_USER}@${DB_HOST}"},
            env_vars=[
                {
                    "name": "DB_PROTOCOL",
                    "value": "postgresql",
                    "scope": "global",
                    "description": None,
                },
                {
                    "name": "CORP_USER",
                    "value": "",
                    "scope": "user",
                    "description": "Your DB username",
                },
            ],
        )

    @staticmethod
    def _assert_env_vars_round_tripped(table: LiteLLM_MCPServerTable) -> None:
        assert table.env_vars is not None
        by_name = {entry.name: entry for entry in table.env_vars}
        assert set(by_name) == {"DB_PROTOCOL", "CORP_USER"}
        assert by_name["DB_PROTOCOL"].scope == MCPEnvVarScope.global_
        assert by_name["DB_PROTOCOL"].value == "postgresql"
        assert by_name["CORP_USER"].scope == MCPEnvVarScope.user
        assert by_name["CORP_USER"].description == "Your DB username"

    def test_build_mcp_server_table_preserves_env_vars(self):
        manager = MCPServerManager()
        table = manager._build_mcp_server_table(self._server_with_env_vars())
        self._assert_env_vars_round_tripped(table)

    @pytest.mark.asyncio
    async def test_health_check_server_preserves_env_vars(self):
        # OAuth2 without client credentials needs a per-user token, so the
        # health check is skipped (no network) and we exercise the table
        # construction path directly.
        manager = MCPServerManager()
        server = self._server_with_env_vars()
        assert server.requires_per_user_auth is True
        manager.registry[server.server_id] = server
        table = await manager.health_check_server(server.server_id)
        self._assert_env_vars_round_tripped(table)


class TestHealthCheckInterpolatesGlobalEnvVars:
    """The upstream probes (health check and the initialize-instructions
    prefetch) must substitute global ``${NAME}`` env vars into static headers
    before opening the connection. Forwarding the raw placeholder makes any
    server whose auth header is backed by a global env var fail authentication
    and flip to 'unhealthy', even though real tool calls (which do interpolate)
    keep working.
    """

    @staticmethod
    def _server() -> MCPServer:
        return MCPServer(
            server_id="global-env-server",
            name="global_env_server",
            url="https://example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
            static_headers={"Authorization": "Bearer ${API_TOKEN}"},
            env_vars=[
                {
                    "name": "API_TOKEN",
                    "value": "secret-token",
                    "scope": "global",
                    "description": None,
                }
            ],
        )

    @staticmethod
    def _capture_headers(manager: MCPServerManager) -> Dict[str, Any]:
        captured: Dict[str, Any] = {}
        mock_client = AsyncMock()
        mock_client.run_with_session = AsyncMock(return_value="ok")

        async def _create(server, mcp_auth_header, extra_headers, stdio_env):
            captured["extra_headers"] = extra_headers
            return mock_client

        manager._create_mcp_client = AsyncMock(side_effect=_create)
        return captured

    @pytest.mark.asyncio
    async def test_health_check_interpolates_global_env_vars(self):
        manager = MCPServerManager()
        server = self._server()
        assert server.requires_per_user_auth is False
        manager.get_mcp_server_by_id = MagicMock(return_value=server)
        manager._remember_upstream_initialize_instructions = MagicMock()
        captured = self._capture_headers(manager)

        result = await manager.health_check_server(server.server_id)

        assert captured["extra_headers"] == {"Authorization": "Bearer secret-token"}
        assert result.status == "healthy"

    @pytest.mark.asyncio
    async def test_initialize_instructions_prefetch_interpolates_global_env_vars(self):
        manager = MCPServerManager()
        server = self._server()
        captured = self._capture_headers(manager)
        manager._remember_upstream_initialize_instructions = MagicMock()

        await manager._ensure_upstream_initialize_instructions_cached(server)

        assert captured["extra_headers"] == {"Authorization": "Bearer secret-token"}


class TestUserEnvVarsCacheEviction:
    """At capacity the per-user env var cache must shed a single oldest entry
    rather than wiping every entry, so a steady stream of distinct callers does
    not periodically stampede the DB by invalidating every still-valid value.
    """

    @staticmethod
    def _patch_cache(monkeypatch, max_size):
        from litellm.proxy._experimental.mcp_server import mcp_server_manager as m

        cache: Dict[Any, Any] = {}
        monkeypatch.setattr(m, "_user_env_vars_cache", cache)
        monkeypatch.setattr(m, "_USER_ENV_VARS_CACHE_MAX_SIZE", max_size)
        return m, cache

    def test_eviction_drops_single_oldest_entry_not_whole_cache(self, monkeypatch):
        m, cache = self._patch_cache(monkeypatch, max_size=3)

        for i in range(3):
            m._write_user_env_vars_cache(f"user{i}", "srv", {"V": str(i)})
        assert set(cache) == {("user0", "srv"), ("user1", "srv"), ("user2", "srv")}

        m._write_user_env_vars_cache("user3", "srv", {"V": "3"})

        assert len(cache) == 3
        assert ("user0", "srv") not in cache
        assert ("user3", "srv") in cache
        assert cache[("user1", "srv")][0] == {"V": "1"}

    def test_refreshing_existing_key_does_not_evict(self, monkeypatch):
        m, cache = self._patch_cache(monkeypatch, max_size=2)

        m._write_user_env_vars_cache("a", "srv", {"V": "1"})
        m._write_user_env_vars_cache("b", "srv", {"V": "2"})
        m._write_user_env_vars_cache("a", "srv", {"V": "1-new"})

        assert set(cache) == {("a", "srv"), ("b", "srv")}
        assert cache[("a", "srv")][0] == {"V": "1-new"}
        # The just-refreshed key must now sit at the tail so the next insert
        # evicts the genuinely older entry instead.
        m._write_user_env_vars_cache("c", "srv", {"V": "3"})
        assert ("b", "srv") not in cache
        assert ("a", "srv") in cache


class TestGetPublicMCPServers:
    """
    /public/mcp_hub strict-whitelist semantics — mirrors /public/model_hub
    and /public/agent_hub. Regression test for the PR #20607 OR-with-default
    behavior that made `litellm.public_mcp_servers` ignored by the hub.
    """

    def _make_server(self, server_id, available_on_public_internet=True):
        return MCPServer(
            server_id=server_id,
            name=server_id,
            server_name=server_id,
            transport=MCPTransport.http,
            available_on_public_internet=available_on_public_internet,
        )

    def _make_manager(self, servers):
        manager = MCPServerManager()
        for s in servers:
            manager.config_mcp_servers[s.server_id] = s
        return manager

    @patch("litellm.public_mcp_servers", None)
    def test_returns_empty_when_whitelist_is_none(self):
        """No /make_public call yet → hub returns nothing, regardless of
        per-server flags."""
        manager = self._make_manager(
            [
                self._make_server("a", available_on_public_internet=True),
                self._make_server("b", available_on_public_internet=True),
            ]
        )
        assert manager.get_public_mcp_servers() == []

    @patch("litellm.public_mcp_servers", [])
    def test_returns_empty_when_whitelist_is_empty(self):
        """Explicit empty whitelist → hub returns nothing."""
        manager = self._make_manager([self._make_server("a", available_on_public_internet=True)])
        assert manager.get_public_mcp_servers() == []

    @patch("litellm.public_mcp_servers", ["a"])
    def test_returns_only_whitelisted_when_flag_defaults_to_true(self):
        """
        Regression: prior to the fix, every server with
        available_on_public_internet=True (the default) leaked into the hub
        regardless of the whitelist. Whitelist must be authoritative.
        """
        manager = self._make_manager(
            [
                self._make_server("a", available_on_public_internet=True),
                self._make_server("b", available_on_public_internet=True),
            ]
        )
        result = manager.get_public_mcp_servers()
        assert [s.server_id for s in result] == ["a"]

    @patch("litellm.public_mcp_servers", ["a"])
    def test_does_not_leak_servers_via_internal_flag(self):
        """
        available_on_public_internet is an IP-gating flag, not a hub flag.
        A server with the flag True that is not in the whitelist must not
        appear in the hub.
        """
        manager = self._make_manager(
            [
                self._make_server("a", available_on_public_internet=False),
                self._make_server("b", available_on_public_internet=True),
            ]
        )
        result = manager.get_public_mcp_servers()
        assert [s.server_id for s in result] == ["a"]

    @patch("litellm.public_mcp_servers", ["does-not-exist"])
    def test_stale_whitelist_id_returns_empty(self):
        """Whitelist references an unknown server_id → no spurious results."""
        manager = self._make_manager([self._make_server("a", available_on_public_internet=True)])
        assert manager.get_public_mcp_servers() == []


class TestGetPublicMCPServersLegacyMode:
    """
    Legacy migration knob: litellm.public_mcp_hub_strict_whitelist=False
    preserves the pre-fix OR-with-default semantics for one release so
    operators that relied on the old behavior have a window to call
    /v1/mcp/make_public before /public/mcp_hub goes empty.
    """

    def _make_server(self, server_id, available_on_public_internet=True):
        return MCPServer(
            server_id=server_id,
            name=server_id,
            server_name=server_id,
            transport=MCPTransport.http,
            available_on_public_internet=available_on_public_internet,
        )

    def _make_manager(self, servers):
        manager = MCPServerManager()
        for s in servers:
            manager.config_mcp_servers[s.server_id] = s
        return manager

    @patch("litellm.public_mcp_hub_strict_whitelist", False)
    @patch("litellm.public_mcp_servers", None)
    def test_legacy_returns_default_flag_servers_when_whitelist_is_none(self):
        """Legacy mode + no whitelist → every server with the default
        available_on_public_internet=True appears (old behavior)."""
        manager = self._make_manager(
            [
                self._make_server("a", available_on_public_internet=True),
                self._make_server("b", available_on_public_internet=False),
            ]
        )
        result = manager.get_public_mcp_servers()
        assert [s.server_id for s in result] == ["a"]

    @patch("litellm.public_mcp_hub_strict_whitelist", False)
    @patch("litellm.public_mcp_servers", ["b"])
    def test_legacy_unions_whitelist_and_default_flag(self):
        """Legacy mode unions the whitelist with any
        available_on_public_internet=True server."""
        manager = self._make_manager(
            [
                self._make_server("a", available_on_public_internet=True),
                self._make_server("b", available_on_public_internet=False),
            ]
        )
        result = manager.get_public_mcp_servers()
        assert sorted(s.server_id for s in result) == ["a", "b"]


class TestCreateMcpClientV2Graft:
    """The PR4 v2-resolver graft in ``_create_mcp_client``.

    Migrated HTTP/SSE modes (``none`` plus the static ``api_key`` family) resolve through the
    injected ``UpstreamCredentialProvider`` into the ``resolved_auth`` slot; every other mode,
    and every stdio server, defers to v1's ``auth_value`` path unchanged.
    """

    def _http_server(self, **overrides: Any) -> MCPServer:
        base: Dict[str, Any] = dict(
            server_id="http-graft",
            name="graft_server",
            url="https://upstream.example.com/mcp",
            transport=MCPTransport.http,
        )
        base.update(overrides)
        return MCPServer(**base)

    async def test_none_mode_resolves_to_noop_auth(self):
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            NoOpAuth,
        )

        client = await MCPServerManager()._create_mcp_client(self._http_server(auth_type=None))

        assert isinstance(client._resolved_auth, NoOpAuth)
        assert client._mcp_auth_value is None

    @pytest.mark.parametrize(
        "auth_type, token, expected_name, expected_value",
        [
            (MCPAuth.api_key, "k-123", "X-API-Key", "k-123"),
            (MCPAuth.bearer_token, "b-123", "Authorization", "Bearer b-123"),
            (MCPAuth.token, "t-123", "Authorization", "token t-123"),
            (MCPAuth.authorization, "raw-123", "Authorization", "raw-123"),
        ],
    )
    async def test_static_family_emits_expected_header(self, auth_type, token, expected_name, expected_value):
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )

        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=auth_type, authentication_token=token)
        )

        assert isinstance(client._resolved_auth, StaticHeaderAuth)
        assert client._resolved_auth.header_name == expected_name
        assert client._resolved_auth._header_value.get_secret_value() == expected_value
        assert client._mcp_auth_value is None

    async def test_basic_mode_base64_encodes(self):
        import base64

        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )

        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=MCPAuth.basic, authentication_token="user:pass")
        )

        encoded = base64.b64encode(b"user:pass").decode()
        assert isinstance(client._resolved_auth, StaticHeaderAuth)
        assert client._resolved_auth.header_name == "Authorization"
        assert client._resolved_auth._header_value.get_secret_value() == f"Basic {encoded}"

    async def test_m2m_client_credentials_resolves_via_v2(self):
        # M2M (oauth2 client_credentials) is migrated: to_server_spec owns the server and the
        # v2 arm mints the token through the injected source; nothing flows to v1's auth_value.
        from litellm.proxy._experimental.mcp_server.outbound_credentials import (
            UpstreamCredentialProvider,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
            ClientCredentialsBearerAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
            OAuthToken,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok

        class _FakeM2MSource:
            async def get(self, server_id, config):
                return Ok(OAuthToken(access_token="m2m-at"))

            async def refetch(self, server_id, config, failed_access_token):
                return None

        client = await MCPServerManager()._create_mcp_client(
            self._http_server(
                auth_type=MCPAuth.oauth2,
                oauth2_flow="client_credentials",
                client_id="cid",
                client_secret="csec",
                token_url="https://idp.example.com/token",
            ),
            cred_provider=UpstreamCredentialProvider(client_credentials_source=_FakeM2MSource()),
        )

        assert isinstance(client._resolved_auth, ClientCredentialsBearerAuth)
        assert client._resolved_auth._access_token.get_secret_value() == "m2m-at"
        assert client._mcp_auth_value is None

    async def test_m2m_client_credentials_incomplete_config_fails_closed(self):
        # An M2M server missing its grant fields is still owned by v2 and surfaces a 500
        # misconfigured naming the missing fields, rather than deferring to v1 and connecting
        # unauthenticated (which masked the upstream 401 as an empty tool list).
        with pytest.raises(HTTPException) as exc_info:
            await MCPServerManager()._create_mcp_client(
                self._http_server(
                    auth_type=MCPAuth.oauth2,
                    oauth2_flow="client_credentials",
                    authentication_token="legacy-token",
                )
            )

        assert exc_info.value.status_code == 500
        assert "misconfigured" in str(exc_info.value.detail)
        assert "token_url" in str(exc_info.value.detail)

    async def test_static_token_missing_defers_to_v1(self):
        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=MCPAuth.api_key, authentication_token=None)
        )

        assert client._resolved_auth is None

    async def test_stdio_migrated_auth_type_still_defers_to_v1(self):
        client = await MCPServerManager()._create_mcp_client(
            MCPServer(
                server_id="stdio-graft",
                name="stdio_graft",
                transport=MCPTransport.stdio,
                command="node",
                args=["server.js"],
                auth_type=MCPAuth.api_key,
                authentication_token="k-stdio",
            )
        )

        assert client.transport_type == MCPTransport.stdio
        assert client._resolved_auth is None
        assert client._mcp_auth_value == "k-stdio"

    async def test_resolver_error_maps_to_http_exception(self):
        from litellm.proxy._experimental.mcp_server.outbound_credentials import Error
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
            CredError,
        )

        class _UnauthorizedProvider:
            async def resolve_credentials(self, subject, server):
                return Error(CredError.of_unauthorized("denied"))

        manager = MCPServerManager(cred_provider=_UnauthorizedProvider())

        with pytest.raises(HTTPException) as exc:
            await manager._create_mcp_client(self._http_server(auth_type=None))

        assert exc.value.status_code == 401

    async def test_per_request_override_defers_to_v1(self):
        # A per-request override (mcp_auth_header) must win over the shared static token,
        # exactly as v1 did, so a migrated static server defers to v1 when one is present.
        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=MCPAuth.bearer_token, authentication_token="shared-tok"),
            mcp_auth_header="caller-override",
        )

        assert client._resolved_auth is None
        assert client._mcp_auth_value == "caller-override"

    async def test_conflicting_extra_header_skips_resolved_auth_on_v2(self):
        # An Authorization already supplied via extra_headers (guardrail hook like the JWT
        # signer, static_headers, or a forwarded caller header) must win. The server stays on
        # the v2 path but skips resolved_auth, so nothing overwrites the inbound header.
        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=MCPAuth.bearer_token, authentication_token="shared-tok"),
            extra_headers={"Authorization": "Bearer hook-jwt"},
        )

        assert client._resolved_auth is None
        assert client._mcp_auth_value is None
        assert client._get_auth_headers()["Authorization"] == "Bearer hook-jwt"

    async def test_none_with_extra_header_stays_v2_without_clobbering(self):
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            NoOpAuth,
        )

        # none resolves to NoOpAuth, which writes no header, so it cannot clobber an inbound
        # Authorization; it stays on the v2 path and the inbound header is preserved verbatim.
        client = await MCPServerManager()._create_mcp_client(
            self._http_server(auth_type=None),
            extra_headers={"Authorization": "Bearer hook-jwt"},
        )

        assert isinstance(client._resolved_auth, NoOpAuth)
        assert client._get_auth_headers()["Authorization"] == "Bearer hook-jwt"


def _upstream_status_error(status_code: int, challenge: str) -> httpx.HTTPStatusError:
    request = httpx.Request("POST", "https://upstream.example/mcp")
    response = httpx.Response(
        status_code,
        headers={"WWW-Authenticate": challenge},
        request=request,
    )
    return httpx.HTTPStatusError("upstream rejected token", request=request, response=response)


class TestMCPToolsListAuthSurfacing:
    """Regression: MCP tools/list failures must surface as typed exceptions, never a silent [].

    Previously a missing/expired per-user OAuth token, or an upstream 401 for any
    non-carveout auth_type, was swallowed to an empty tool list, so a single-server
    client saw a 200 with no tools instead of a 401 challenge. The listing helpers
    now raise MCPUpstreamAuthError on an upstream 401 or 403 and MCPServerListError
    with a classified fault for every other failure; single-server routes relay a
    truthful HTTP status while the aggregator absorbs each failure into that
    server's outcome, so a broken upstream is never indistinguishable from a
    healthy server with no tools.
    """

    @pytest.mark.asyncio
    async def test_fetch_tools_with_timeout_surfaces_upstream_401(self):
        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )

        manager = MCPServerManager()
        challenge = 'Bearer resource_metadata="https://upstream.example/.well-known/oauth-protected-resource"'
        client = MagicMock()
        client.list_tools = AsyncMock(side_effect=_upstream_status_error(401, challenge))

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._fetch_tools_with_timeout(client, "static-key-server")

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate == challenge
        assert exc_info.value.server_name == "static-key-server"

    @pytest.mark.asyncio
    async def test_fetch_tools_with_timeout_surfaces_upstream_403(self):
        """An upstream 403 (authenticated but forbidden, e.g. insufficient scope) now raises
        MCPUpstreamAuthError instead of absorbing to []: the silent empty list made a forbidden
        upstream indistinguishable from a healthy server with no tools. The upstream
        WWW-Authenticate is preserved so single-server routes can relay the real challenge."""
        manager = MCPServerManager()
        challenge = 'Bearer error="insufficient_scope", scope="read:tools"'
        client = MagicMock()
        client.list_tools = AsyncMock(side_effect=_upstream_status_error(403, challenge))

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._fetch_tools_with_timeout(client, "forbidden-server")

        assert exc_info.value.status_code == 403
        assert exc_info.value.www_authenticate == challenge
        assert exc_info.value.server_name == "forbidden-server"

    @pytest.mark.asyncio
    async def test_fetch_tools_with_timeout_raises_classified_fault_on_non_auth_error(self):
        """A non-auth listing failure now raises MCPServerListError carrying a classified fault
        instead of absorbing to []: the silent empty list made a broken upstream indistinguishable
        from a healthy server with no tools. An unrecognized exception classifies as the gateway's
        own fault ("internal")."""
        manager = MCPServerManager()
        client = MagicMock()
        client.list_tools = AsyncMock(side_effect=RuntimeError("upstream 500"))

        with pytest.raises(MCPServerListError) as exc_info:
            await manager._fetch_tools_with_timeout(client, "srv")

        assert exc_info.value.fault == ServerListFault(tag="internal")
        assert exc_info.value.server_name == "srv"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_surfaces_unusable_user_token(self):
        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )

        manager = MCPServerManager()
        server = MCPServer(server_id="oauth-srv", name="oauth-srv", transport=MCPTransport.http)
        challenge = 'Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/oauth-srv"'
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(
                status_code=401,
                detail="Unauthorized",
                headers={"WWW-Authenticate": challenge},
            )
        )

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate == challenge
        assert exc_info.value.server_name == "oauth-srv"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_surfaces_non_challenge_http_error_as_internal_fault(self):
        """A non-auth HTTPException (500) now raises MCPServerListError with an "internal" fault
        carrying the status code instead of absorbing to []: the silent empty list made a
        misconfigured server indistinguishable from a healthy server with no tools. The aggregate
        absorbs it into that server's outcome; single-server routes relay a truthful status.
        401/403 remain the challenge-class statuses routed to MCPUpstreamAuthError."""
        manager = MCPServerManager()
        server = MCPServer(server_id="stdio-srv", name="stdio-srv", transport=MCPTransport.http)
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(
                status_code=500,
                detail="MCP stdio command 'foo' is not in the allowlist",
            )
        )

        with pytest.raises(MCPServerListError) as exc_info:
            await manager._get_tools_from_server(server)

        assert exc_info.value.fault == ServerListFault(tag="internal", status_code=500)
        assert exc_info.value.server_name == "stdio-srv"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_generic_arm_extracts_nested_auth_challenge(self):
        """A 401 buried in the exception tree at client-build time must travel the same channel as
        one raised during the fetch: MCPUpstreamAuthError with the upstream's own challenge. Before
        the shared choice-point it classified into a challenge-less fault, so single-server routes
        answered 401 without the WWW-Authenticate the client needs to start the OAuth flow."""
        import httpx

        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )

        manager = MCPServerManager()
        server = MCPServer(server_id="nested-srv", name="nested-srv", transport=MCPTransport.http)
        causal = httpx.HTTPStatusError(
            "auth",
            request=httpx.Request("POST", "https://mcp.example.com/mcp"),
            response=httpx.Response(
                401,
                headers={"www-authenticate": "Bearer realm=upstream"},
                request=httpx.Request("POST", "https://mcp.example.com/mcp"),
            ),
        )
        wrapper = RuntimeError("client build failed")
        wrapper.__cause__ = causal
        manager._create_mcp_client = AsyncMock(side_effect=wrapper)

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate == "Bearer realm=upstream"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_generic_arm_strips_challenge_for_dcr_bridge(self):
        """The dcr_bridge challenge suppression must hold on the generic arm too, not only when the
        fetch itself raised MCPUpstreamAuthError: a bridge client following the upstream challenge
        would fail the RFC 9728 resource match against the gateway URL it dialed."""
        import httpx

        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )
        from litellm.types.mcp import MCPAuth

        manager = MCPServerManager()
        bridge_server = MCPServer(
            server_id="bridge-nested",
            name="bridge-nested",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
            dcr_bridge=True,
        )
        causal = httpx.HTTPStatusError(
            "auth",
            request=httpx.Request("POST", "https://upstream.example/mcp"),
            response=httpx.Response(
                401,
                headers={"www-authenticate": 'Bearer resource_metadata="https://upstream.example/.wk"'},
                request=httpx.Request("POST", "https://upstream.example/mcp"),
            ),
        )
        wrapper = RuntimeError("client build failed")
        wrapper.__cause__ = causal
        manager._create_mcp_client = AsyncMock(side_effect=wrapper)

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(bridge_server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate is None

    @pytest.mark.asyncio
    async def test_get_tools_from_server_suppresses_upstream_challenge_for_dcr_bridge(self):
        """A dcr_bridge server must never relay the upstream's own WWW-Authenticate: it points
        clients at the upstream protected-resource metadata, which fails the RFC 9728 resource
        match against the gateway URL they dialed. Stripping it makes the single-server route
        fabricate the gateway well-known challenge, whose content is the bridge facade."""
        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )
        from litellm.types.mcp import MCPAuth

        manager = MCPServerManager()
        bridge_server = MCPServer(
            server_id="bridge-srv",
            name="bridge-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.true_passthrough,
            dcr_bridge=True,
        )
        upstream_challenge = 'Bearer resource_metadata="https://upstream.example/.well-known/oauth-protected-resource"'
        client = MagicMock()
        client.list_tools = AsyncMock(side_effect=_upstream_status_error(401, upstream_challenge))
        manager._create_mcp_client = AsyncMock(return_value=client)

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(bridge_server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate is None
        assert exc_info.value.server_name == "bridge-srv"

    @pytest.mark.asyncio
    async def test_get_tools_from_server_suppresses_resolver_challenge_for_dcr_bridge(self):
        """The client-build-time HTTPException conversion path applies the same suppression."""
        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )
        from litellm.types.mcp import MCPAuth

        manager = MCPServerManager()
        bridge_server = MCPServer(
            server_id="bridge-srv",
            name="bridge-srv",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
            dcr_bridge=True,
        )
        manager._create_mcp_client = AsyncMock(
            side_effect=HTTPException(
                status_code=401,
                detail="Unauthorized",
                headers={"WWW-Authenticate": 'Bearer resource_metadata="https://upstream.example/prm"'},
            )
        )

        with pytest.raises(MCPUpstreamAuthError) as exc_info:
            await manager._get_tools_from_server(bridge_server)

        assert exc_info.value.status_code == 401
        assert exc_info.value.www_authenticate is None

    @pytest.mark.asyncio
    async def test_aggregate_list_tools_absorbs_unauthenticated_server(self):
        from litellm.proxy._experimental.mcp_server.exceptions import (
            MCPUpstreamAuthError,
        )

        manager = MCPServerManager()
        good = MCPServer(server_id="good", name="good", transport=MCPTransport.http)
        bad = MCPServer(server_id="bad", name="bad", transport=MCPTransport.http)
        manager.get_allowed_mcp_servers = AsyncMock(return_value=["good", "bad"])
        manager.get_mcp_server_by_id = MagicMock(
            side_effect=lambda server_id: {"good": good, "bad": bad}.get(server_id)
        )
        good_tool = MCPTool(name="good-do_thing", description="do thing", inputSchema={})

        async def fake_get_tools(server, **kwargs):
            if server.server_id == "bad":
                raise MCPUpstreamAuthError(
                    status_code=401,
                    www_authenticate='Bearer realm="x"',
                    server_name="bad",
                )
            return [good_tool]

        manager._get_tools_from_server = fake_get_tools

        result = await manager.list_tools()

        assert [t.name for t in result] == ["good-do_thing"]


def test_should_strip_caller_authorization_for_token_exchange():
    """OBO: the inbound bearer is the subject token (exchanged), never forwarded upstream raw."""
    server = MCPServer(
        server_id="te-strip",
        name="te-strip-server",
        url="https://up.example.com",
        transport=MCPTransport.http,
        auth_type=MCPAuth.oauth2_token_exchange,
        token_exchange_endpoint="https://idp.example.com/token",
        client_id="cid",
        client_secret="csec",
    )
    assert _should_strip_caller_authorization(mcp_server=server, raw_headers=None, user_api_key_auth=None) is True


def _retry_gate_server(auth_type: MCPAuthType) -> MCPServer:
    return MCPServer(
        server_id="retry-gate",
        name="retry-gate-server",
        url="https://up.example.com",
        transport=MCPTransport.http,
        auth_type=auth_type,
    )


def test_obo_retry_applies_to_id_jag_without_an_inbound_subject_token():
    """ID-JAG can source its subject from the user's stored SSO assertion, so the upstream-401
    invalidate-and-retry path must engage even when the caller presented no token of its own;
    otherwise a store-sourced bearer is replayed until its TTL after being rejected."""
    assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_id_jag), None) is True
    assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_id_jag), "inbound-id-token") is True


def test_obo_retry_still_requires_a_subject_token_for_token_exchange():
    """token_exchange can only mint from an inbound token, so with none there is nothing to re-mint."""
    assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_token_exchange), None) is False
    assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_token_exchange), "inbound-token") is True


def test_obo_retry_does_not_apply_to_other_auth_modes():
    for auth_type in (MCPAuth.none, MCPAuth.api_key, MCPAuth.oauth2, MCPAuth.true_passthrough):
        assert _obo_retry_applies(_retry_gate_server(auth_type), "some-token") is False


class _UpstreamAuthError(Exception):
    """Mimics a wrapped upstream 401 the way _extract_upstream_auth_failure detects it."""

    def __init__(self, status_code: int = 401) -> None:
        super().__init__(f"HTTP {status_code}")
        self.response = httpx.Response(status_code)


class _RetryFakeClient:
    """A fake MCPClient whose call_tool fails on the first attempt and (optionally) succeeds after."""

    def __init__(self, *, raises=None, result=None) -> None:
        from litellm.experimental_mcp_client.client import MCPClient

        self._raises = raises
        self._result = result
        self._MCPClient = MCPClient
        self.attempts = 0

    async def call_tool(self, params, host_progress_callback=None, raise_on_error=False):
        self.attempts += 1
        if self._raises is not None:
            if raise_on_error:
                raise self._raises
            return self._MCPClient.error_tool_result(self._raises)
        return self._result


def _obo_server() -> MCPServer:
    return MCPServer(
        server_id="obo-srv",
        name="obo",
        url="https://upstream.example/mcp",
        transport=MCPTransport.sse,
        auth_type=MCPAuth.oauth2_token_exchange,
        token_exchange_endpoint="https://idp.example.com/token",
        client_id="cid",
        client_secret="csec",
    )


class TestOBOCallToolRetry:
    """The token_exchange (OBO) tool-call path re-mints the exchanged token once on an upstream 401."""

    def _manager(self):
        manager = MCPServerManager()
        manager._cred_provider = MagicMock()
        manager._cred_provider.invalidate_credentials = AsyncMock()
        return manager

    @pytest.mark.asyncio
    async def test_upstream_401_invalidates_and_retries_once(self):
        manager = self._manager()
        success = CallToolResult(content=[], isError=False)
        first = _RetryFakeClient(raises=_UpstreamAuthError(401))
        retry = _RetryFakeClient(result=success)
        manager._create_mcp_client = AsyncMock(return_value=retry)

        result = await manager._obo_call_tool_with_retry(
            client=first,
            call_tool_params=MagicMock(),
            host_progress_callback=None,
            mcp_server=_obo_server(),
            server_auth_header=None,
            extra_headers=None,
            stdio_env=None,
            subject_token="caller-jwt",
            user_api_key_auth=None,
        )

        assert result is success
        manager._cred_provider.invalidate_credentials.assert_awaited_once()
        manager._create_mcp_client.assert_awaited_once()
        assert first.attempts == 1 and retry.attempts == 1

    @pytest.mark.asyncio
    async def test_upstream_401_on_id_jag_evicts_the_cached_bearer_and_retries(self):
        """The retry path must invalidate the ID-JAG leg-2 bearer too: without eviction the rebuilt
        client resolves the same rejected token from the cache and the retry 401s identically."""
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
            IdJagConfig,
        )

        manager = self._manager()
        success = CallToolResult(content=[], isError=False)
        first = _RetryFakeClient(raises=_UpstreamAuthError(401))
        retry = _RetryFakeClient(result=success)
        manager._create_mcp_client = AsyncMock(return_value=retry)
        server = MCPServer(
            server_id="id-jag-srv",
            name="id-jag",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2_id_jag,
            client_id="gateway-client",
            client_secret="gateway-secret",
            token_exchange_endpoint="https://org-idp.example/oauth2/token",
            id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token",
        )

        result = await manager._obo_call_tool_with_retry(
            client=first,
            call_tool_params=MagicMock(),
            host_progress_callback=None,
            mcp_server=server,
            server_auth_header=None,
            extra_headers=None,
            stdio_env=None,
            subject_token="caller-id-token",
            user_api_key_auth=None,
        )

        assert result is success
        manager._cred_provider.invalidate_credentials.assert_awaited_once()
        invalidated_spec = manager._cred_provider.invalidate_credentials.await_args.args[1]
        assert isinstance(invalidated_spec.config, IdJagConfig)
        assert first.attempts == 1 and retry.attempts == 1

    @pytest.mark.asyncio
    async def test_call_regular_routes_id_jag_through_the_retry_path(self):
        """An oauth2_id_jag tool call with a subject token must take the invalidate-and-retry branch
        of _call_regular_mcp_tool, not the plain single call, so an upstream 401 re-exchanges."""
        manager = self._manager()
        success = CallToolResult(content=[], isError=False)
        first = _RetryFakeClient(raises=_UpstreamAuthError(401))
        retry = _RetryFakeClient(result=success)
        manager._create_mcp_client = AsyncMock(side_effect=[first, retry])
        server = MCPServer(
            server_id="id-jag-srv",
            name="id-jag",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2_id_jag,
            client_id="gateway-client",
            client_secret="gateway-secret",
            token_exchange_endpoint="https://org-idp.example/oauth2/token",
            id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token",
        )

        result = await manager._call_regular_mcp_tool(
            mcp_server=server,
            original_tool_name="tool",
            arguments={},
            tasks=[],
            mcp_auth_header=None,
            mcp_server_auth_headers=None,
            oauth2_headers={"Authorization": "Bearer caller-id-token"},
            raw_headers=None,
            proxy_logging_obj=None,
        )

        assert result is success
        manager._cred_provider.invalidate_credentials.assert_awaited_once()
        assert first.attempts == 1 and retry.attempts == 1

    @pytest.mark.asyncio
    async def test_non_auth_error_does_not_retry(self):
        manager = self._manager()
        first = _RetryFakeClient(raises=ValueError("tool blew up"))
        manager._create_mcp_client = AsyncMock()

        result = await manager._obo_call_tool_with_retry(
            client=first,
            call_tool_params=MagicMock(),
            host_progress_callback=None,
            mcp_server=_obo_server(),
            server_auth_header=None,
            extra_headers=None,
            stdio_env=None,
            subject_token="caller-jwt",
            user_api_key_auth=None,
        )

        assert result.isError is True
        manager._cred_provider.invalidate_credentials.assert_not_awaited()
        manager._create_mcp_client.assert_not_awaited()
        assert first.attempts == 1

    @pytest.mark.asyncio
    async def test_second_401_degrades_without_looping(self):
        manager = self._manager()
        first = _RetryFakeClient(raises=_UpstreamAuthError(401))
        # The retry client still fails; with raise_on_error defaulting False it returns isError.
        retry = _RetryFakeClient(raises=_UpstreamAuthError(401))
        manager._create_mcp_client = AsyncMock(return_value=retry)

        result = await manager._obo_call_tool_with_retry(
            client=first,
            call_tool_params=MagicMock(),
            host_progress_callback=None,
            mcp_server=_obo_server(),
            server_auth_header=None,
            extra_headers=None,
            stdio_env=None,
            subject_token="caller-jwt",
            user_api_key_auth=None,
        )

        assert result.isError is True
        manager._create_mcp_client.assert_awaited_once()
        assert first.attempts == 1 and retry.attempts == 1


class TestOBOConcurrencyLimit:
    """OBO (token_exchange) tool calls must honor the server's max_concurrent_requests.

    Regression: the token_exchange dispatch built its coroutine outside
    _limit_outbound_concurrency, so OBO calls skipped the per-server semaphore the
    non-OBO path enforces and a caller could exceed the admin-configured cap.
    """

    @pytest.mark.asyncio
    async def test_obo_dispatch_respects_max_concurrent_requests(self):
        max_concurrent = 2
        overflow = 3
        server = MCPServer(
            server_id="obo-concurrency",
            name="obo",
            url="https://upstream.example/mcp",
            transport=MCPTransport.sse,
            auth_type=MCPAuth.oauth2_token_exchange,
            token_exchange_endpoint="https://idp.example.com/token",
            client_id="cid",
            client_secret="csec",
            max_concurrent_requests=max_concurrent,
        )

        release = asyncio.Event()
        inflight = {"current": 0, "peak": 0}

        class _ConcurrencyRecordingClient:
            async def call_tool(self, params, host_progress_callback=None, raise_on_error=False):
                inflight["current"] += 1
                inflight["peak"] = max(inflight["peak"], inflight["current"])
                try:
                    await release.wait()
                finally:
                    inflight["current"] -= 1
                return CallToolResult(content=[], isError=False)

        manager = MCPServerManager()
        manager._create_mcp_client = AsyncMock(return_value=_ConcurrencyRecordingClient())

        async def _dispatch():
            return await manager._call_regular_mcp_tool(
                mcp_server=server,
                original_tool_name="do_thing",
                arguments={},
                tasks=[],
                mcp_auth_header=None,
                mcp_server_auth_headers=None,
                oauth2_headers={"Authorization": "Bearer subject-jwt"},
                raw_headers=None,
                proxy_logging_obj=None,
            )

        callers = [asyncio.create_task(_dispatch()) for _ in range(max_concurrent + overflow)]

        stable = 0
        previous = -1
        for _ in range(1000):
            await asyncio.sleep(0)
            current = inflight["current"]
            if current == previous:
                stable += 1
                if current > 0 and stable >= 10:
                    break
            else:
                stable = 0
                previous = current

        peak_while_blocked = inflight["peak"]
        release.set()
        results = await asyncio.gather(*callers)

        assert peak_while_blocked == max_concurrent
        assert inflight["current"] == 0
        assert all(result.isError is False for result in results)


class TestOBOEndpointDiscovery:
    """An oauth2_token_exchange server with no configured token endpoint discovers it (RFC 9728 ->
    RFC 8414) like the oauth2 flow does; an explicitly configured endpoint skips discovery."""

    @pytest.mark.parametrize(
        "auth_type, endpoint, token_url, expected",
        [
            (MCPAuth.oauth2_token_exchange, None, None, True),  # OBO, nothing configured -> discover
            (MCPAuth.oauth2_token_exchange, "https://idp/token", None, False),  # endpoint set -> skip
            (MCPAuth.oauth2_token_exchange, None, "https://idp/token", False),  # token_url set -> skip
            (MCPAuth.oauth2, None, None, False),  # not OBO
            (MCPAuth.none, None, None, False),
            (None, None, None, False),
        ],
    )
    def test_decision(self, auth_type, endpoint, token_url, expected):
        assert MCPServerManager._obo_needs_endpoint_discovery(auth_type, endpoint, token_url) is expected

    @pytest.mark.asyncio
    async def test_config_obo_without_endpoint_discovers_token_endpoint(self):
        manager = MCPServerManager()
        discovered = MCPOAuthMetadata(
            scopes=None,
            authorization_url=None,
            token_url="https://discovered.example.com/token",
            registration_url=None,
        )
        seen = []

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            seen.append((server_url, allow_origin_fallback))
            return discovered

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        await manager.load_servers_from_config(
            {
                "obo": {
                    "url": "https://example.com/mcp",
                    "transport": MCPTransport.http,
                    "auth_type": MCPAuth.oauth2_token_exchange,
                    "client_id": "cid",
                    "client_secret": "csec",
                }
            }
        )

        server = next(iter(manager.config_mcp_servers.values()))
        # OBO discovery runs, and never guesses the resource origin as the IdP (origin fallback off).
        assert seen == [("https://example.com/mcp", False)]
        # The discovered token endpoint lands on token_url, which _token_exchange_spec reads.
        assert server.token_url == "https://discovered.example.com/token"

    @pytest.mark.asyncio
    async def test_config_obo_with_configured_endpoint_skips_discovery(self):
        manager = MCPServerManager()

        async def fake_discovery(
            server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False
        ):
            raise AssertionError("discovery must not run when the endpoint is configured")

        manager._descovery_metadata = fake_discovery  # type: ignore[attr-defined]

        await manager.load_servers_from_config(
            {
                "obo": {
                    "url": "https://example.com/mcp",
                    "transport": MCPTransport.http,
                    "auth_type": MCPAuth.oauth2_token_exchange,
                    "token_exchange_endpoint": "https://configured.example.com/token",
                    "client_id": "cid",
                    "client_secret": "csec",
                }
            }
        )

        server = next(iter(manager.config_mcp_servers.values()))
        assert server.token_exchange_endpoint == "https://configured.example.com/token"

    @pytest.mark.asyncio
    async def test_descovery_metadata_does_not_guess_origin_when_disallowed(self):
        # The authoritative RFC 9728 -> RFC 8414 chain stays, but with allow_origin_fallback=False the
        # resource origin is never assumed to be the IdP, so no token endpoint is invented.
        manager = MCPServerManager()
        server_url = "https://example.com/public/mcp"
        request = httpx.Request("GET", server_url)
        response_obj = httpx.Response(
            status_code=401, request=request, headers={"WWW-Authenticate": 'Bearer scope="read"'}
        )
        response_obj.raise_for_status = MagicMock(
            side_effect=lambda: (_ for _ in ()).throw(
                httpx.HTTPStatusError("unauthorized", request=request, response=response_obj)
            )
        )
        mock_client = MagicMock()
        mock_client.get = AsyncMock(return_value=response_obj)

        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=mock_client,
            ),
            patch.object(manager, "_fetch_oauth_metadata_from_resource", AsyncMock(return_value=([], None))),
            patch.object(manager, "_attempt_well_known_discovery", AsyncMock(return_value=([], None))),
            patch.object(manager, "_fetch_authorization_server_metadata", AsyncMock()) as mock_fetch_auth,
        ):
            result = await manager._descovery_metadata(server_url, allow_origin_fallback=False)

        # No advertised AS -> with the guess disabled, the AS-metadata fetch is never attempted, so no
        # token endpoint is discovered (only the scopes parsed from the challenge survive).
        mock_fetch_auth.assert_not_awaited()
        assert result is None or result.token_url is None


if __name__ == "__main__":
    pytest.main([__file__])


@pytest.mark.asyncio
async def test_preflight_challenge_carries_step_up_error_and_claims():
    """An Entra Conditional Access rejection must surface error=insufficient_claims plus the base64
    claims in the single-server 401 challenge, so an MSAL-family client can drive the step-up and retry."""
    import base64

    from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error
    from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError

    claims = '{"access_token":{"acrs":{"essential":true,"value":"c1"}}}'

    class _FakeProvider:
        async def resolve_credentials(self, subject, server):
            return Error(CredError.of_unauthorized("step-up required", claims=claims))

    manager = MCPServerManager(cred_provider=_FakeProvider())
    server = MCPServer(
        server_id="te-ca",
        name="te-ca-server",
        url="https://up.example.com/mcp",
        transport=MCPTransport.http,
        auth_type=MCPAuth.oauth2_token_exchange,
        token_exchange_endpoint="https://idp.example.com/token",
        client_id="cid",
        client_secret="csec",
    )
    with pytest.raises(HTTPException) as exc_info:
        await manager.preflight_token_exchange(
            server=server,
            oauth2_headers={"Authorization": "Bearer subj"},
            user_api_key_auth=None,
        )
    assert exc_info.value.status_code == 401
    headers = exc_info.value.headers or {}
    www = headers.get("WWW-Authenticate") or headers.get("www-authenticate") or ""
    assert 'error="insufficient_claims"' in www
    assert base64.b64encode(claims.encode()).decode() in www
    assert "resource_metadata" in www


@pytest.mark.asyncio
async def test_aggregate_list_still_absorbs_step_up_challenged_server():
    """A step-up (claims-bearing) 401 from one server must not change the aggregate contract: the
    multi-server listing still absorbs it and returns the healthy servers' tools."""
    from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError

    manager = MCPServerManager()
    good = MCPServer(server_id="good", name="good", transport=MCPTransport.http)
    ca = MCPServer(server_id="ca", name="ca", transport=MCPTransport.http)
    manager.get_allowed_mcp_servers = AsyncMock(return_value=["good", "ca"])
    manager.get_mcp_server_by_id = MagicMock(side_effect=lambda server_id: {"good": good, "ca": ca}.get(server_id))
    good_tool = MCPTool(name="good-do_thing", description="do thing", inputSchema={})

    async def fake_get_tools(server, **kwargs):
        if server.server_id == "ca":
            raise MCPUpstreamAuthError(
                status_code=401,
                www_authenticate=('Bearer resource_metadata="/x", error="insufficient_claims", claims="eyJhIjoxfQ=="'),
                server_name="ca",
            )
        return [good_tool]

    manager._get_tools_from_server = fake_get_tools

    result = await manager.list_tools()

    assert [t.name for t in result] == ["good-do_thing"]


class TestDbBuildReadsOauth2FlowColumnVerbatim:
    """The DB build must not re-infer the flow from field shape: rows are stamped at
    write time and by the startup backfill, and a DCR-registered interactive server
    has the exact M2M shape (client creds + token_url, no persisted authorization_url)
    whenever discovery is unavailable. Inference survives only for config-loaded
    servers and the request-time backstop in _get_allowed_mcp_servers."""

    def _row(self, oauth2_flow):
        return LiteLLM_MCPServerTable(
            server_id="flow-column-row",
            alias="flow_column_row",
            description="",
            url="https://up.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow=oauth2_flow,
            token_url="https://idp.example.com/token",
            credentials={"client_id": "cid", "client_secret": "csec"},
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )

    @pytest.mark.asyncio
    async def test_null_flow_m2m_shape_row_is_not_inferred_m2m(self):
        manager = MCPServerManager()
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            built = await manager.build_mcp_server_from_table(self._row(None), credentials_are_encrypted=False)

        assert built.oauth2_flow is None
        assert built.has_client_credentials is False
        assert built.needs_user_oauth_token is True

    @pytest.mark.asyncio
    async def test_explicit_flow_column_is_read_verbatim(self):
        manager = MCPServerManager()
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            built = await manager.build_mcp_server_from_table(
                self._row("client_credentials"), credentials_are_encrypted=False
            )

        assert built.oauth2_flow == "client_credentials"
        assert built.has_client_credentials is True
        assert built.needs_user_oauth_token is False

    @pytest.mark.asyncio
    async def test_authorization_code_flow_column_is_read_verbatim(self):
        manager = MCPServerManager()
        with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
            built = await manager.build_mcp_server_from_table(
                self._row("authorization_code"), credentials_are_encrypted=False
            )

        assert built.oauth2_flow == "authorization_code"
        assert built.has_client_credentials is False
        assert built.needs_user_oauth_token is True


class TestRequestTimeOauth2FlowBackstop:
    """The single request-time resolution helpers every security site shares:
    effective_oauth2_flow (the enum/boolean decision) and
    resolve_oauth2_flow_for_request (the egress object copy)."""

    def _oauth2_server(self, **overrides):
        base = dict(
            server_id="flow-server",
            name="flow_server",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
        )
        base.update(overrides)
        return MCPServer(**base)

    def test_effective_flow_stamped_values_returned_verbatim(self):
        assert (
            MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow="client_credentials"))
            == "client_credentials"
        )
        assert (
            MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow="authorization_code"))
            == "authorization_code"
        )

    def test_effective_flow_null_m2m_shape_resolves_client_credentials(self):
        server = self._oauth2_server(
            oauth2_flow=None,
            client_id="cid",
            client_secret="csecret",
            token_url="https://idp.example.com/token",
        )
        assert MCPServerManager.effective_oauth2_flow(server) == "client_credentials"

    def test_effective_flow_null_pure_pkce_resolves_none(self):
        assert MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow=None)) is None

    def test_resolve_for_request_stamped_row_is_unchanged_identity(self):
        server = self._oauth2_server(oauth2_flow="client_credentials")
        assert MCPServerManager.resolve_oauth2_flow_for_request(server) is server

    def test_resolve_for_request_null_pure_pkce_is_unchanged_identity(self):
        server = self._oauth2_server(oauth2_flow=None)
        assert MCPServerManager.resolve_oauth2_flow_for_request(server) is server

    def test_resolve_for_request_null_m2m_shape_copies_client_credentials(self, caplog):
        import logging

        server = self._oauth2_server(
            oauth2_flow=None,
            client_id="cid",
            client_secret="csecret",
            token_url="https://idp.example.com/token",
        )
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            resolved = MCPServerManager.resolve_oauth2_flow_for_request(server)

        assert resolved is not server
        assert resolved.oauth2_flow == "client_credentials"
        assert server.oauth2_flow is None  # original untouched
        # Finding 2: the warning must NOT promise the backfill will stamp this row.
        joined = " ".join(caplog.messages)
        assert "no persisted oauth2_flow" in joined
        assert "next proxy boot" not in joined
        assert "will NOT self-heal" in joined


def test_build_mcp_server_table_carries_oauth2_flow():
    """GET /v1/mcp/server (list and by-id) serves registry servers through this
    conversion; dropping oauth2_flow here blinds the dashboard to the persisted
    flow, so the edit page cannot prefill and M2M gating never activates."""
    manager = MCPServerManager()
    server = MCPServer(
        server_id="flow-table-server",
        name="flow_table_server",
        server_name="flow_table_server",
        alias="flow_table_server",
        url="https://up.example.com/mcp",
        transport=MCPTransport.http,
        auth_type=MCPAuth.oauth2,
        oauth2_flow="client_credentials",
    )

    table = manager._build_mcp_server_table(server)

    assert table.oauth2_flow == "client_credentials"


def test_build_mcp_server_table_carries_null_oauth2_flow():
    """A legacy row the backfill left unstamped must surface as oauth2_flow=None in
    the GET response, so the dashboard maps it to undefined and prompts the admin to
    choose a flow rather than showing a guessed default."""
    manager = MCPServerManager()
    server = MCPServer(
        server_id="null-flow-server",
        name="null_flow_server",
        server_name="null_flow_server",
        alias="null_flow_server",
        url="https://up.example.com/mcp",
        transport=MCPTransport.http,
        auth_type=MCPAuth.oauth2,
        oauth2_flow=None,
    )

    table = manager._build_mcp_server_table(server)

    assert table.oauth2_flow is None


@pytest.mark.asyncio
async def test_resolve_toolset_tool_permissions_single_db_fetch_across_checks():
    """The server-level and tool-level permission primitives each resolve the
    key's toolsets during one request; the shared cache must dedupe the DB
    fetch so the request costs a single toolset query however many checks run"""
    from litellm.caching.caching import DualCache
    from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
        MCPServerManager,
    )

    manager = MCPServerManager()
    toolset = MagicMock()
    toolset.tools = [{"server_id": "server-a", "tool_name": "lookup_status"}]
    list_toolsets_mock = AsyncMock(return_value=[toolset])

    with (
        patch(
            "litellm.proxy._experimental.mcp_server.toolset_db.list_mcp_toolsets",
            list_toolsets_mock,
        ),
        patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
        patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
    ):
        first = await manager.resolve_toolset_tool_permissions(toolset_ids=["ts-1"])
        second = await manager.resolve_toolset_tool_permissions(toolset_ids=["ts-1"])

    assert first == {"server-a": ["lookup_status"]}
    assert second == first
    list_toolsets_mock.assert_awaited_once()


class TestMaterializeAuthHeaders:
    """_materialize_auth_headers drives one step of a resolved httpx.Auth's own flow to turn it
    into a header dict for the OpenAPI egress arm, which sends plain headers and cannot carry an
    httpx.Auth. Generic across auth shapes via the resolver-arm header_name convention."""

    @pytest.mark.asyncio
    async def test_static_header_auth_materializes_its_header(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _materialize_auth_headers,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            StaticHeaderAuth,
        )

        headers = await _materialize_auth_headers(StaticHeaderAuth("Bearer stored-token"))
        assert headers == {"Authorization": "Bearer stored-token"}

    @pytest.mark.asyncio
    async def test_client_credentials_bearer_auth_materializes_bearer(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _materialize_auth_headers,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
            ClientCredentialsBearerAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
            ClientCredentialsConfig,
        )

        async def _refetch(_stale: str):
            return None

        default_carrier = ClientCredentialsConfig()
        headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, default_carrier))
        assert headers == {"Authorization": "Bearer m2m-token"}

    @pytest.mark.asyncio
    async def test_materialize_follows_the_minted_token_to_a_custom_header(self):
        # The OpenAPI arm reads header_name off the auth object rather than assuming Authorization,
        # so it carries the knob with no per-arm change. This pins that it stays that way.
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _materialize_auth_headers,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
            ClientCredentialsBearerAuth,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
            ClientCredentialsConfig,
        )

        async def _refetch(_stale: str):
            return None

        esb_carrier = ClientCredentialsConfig(header_name="esb-oauth")
        headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, esb_carrier))
        assert headers == {"esb-oauth": "Bearer m2m-token"}

    @pytest.mark.asyncio
    async def test_noop_and_none_materialize_to_none(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
            _materialize_auth_headers,
        )
        from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
            NoOpAuth,
        )

        assert await _materialize_auth_headers(None) is None
        assert await _materialize_auth_headers(NoOpAuth()) is None


class TestUrllessIssuerDiscovery:
    """LIT-4629: servers with no url (OpenAPI spec_path, stdio) run no resource discovery, so
    their OAuth endpoints could only ever come from manual entry; an admin-pinned issuer is a
    url-independent trust anchor (RFC 8414 section 3.3) and must unlock discovery for them."""

    def _urlless_row(self, **overrides):
        fields = dict(
            server_id="urlless-1",
            alias="sheets_urlless",
            description="spec-only server",
            url=None,
            spec_path="https://example.com/sheets-openapi.yaml",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )
        fields.update(overrides)
        return LiteLLM_MCPServerTable(**fields)

    @pytest.mark.asyncio
    async def test_urlless_server_with_issuer_discovers_endpoints(self):
        """The gate previously required bool(server_url), so a url-less server with an issuer
        configured never ran the issuer-anchored fetch and /authorize 400d. Kills the mutant that
        restores the bare bool(server_url) term."""
        manager = MCPServerManager()
        row = self._urlless_row(issuer="https://accounts.google.com")

        resolved = MCPOAuthMetadata(
            authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
            token_url="https://oauth2.googleapis.com/token",
        )
        resource_rooted = AsyncMock(return_value=None)
        with (
            patch.object(
                manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=resolved)
            ) as anchored,
            patch.object(manager, "_descovery_metadata", new=resource_rooted),
        ):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        anchored.assert_awaited_once_with("https://accounts.google.com", None)
        resource_rooted.assert_not_awaited()
        assert built.issuer_is_anchored is True
        assert built.authorization_url == "https://accounts.google.com/o/oauth2/v2/auth"
        assert built.token_url == "https://oauth2.googleapis.com/token"

    @pytest.mark.asyncio
    async def test_urlless_server_without_issuer_stays_undiscovered(self):
        """With neither a url nor an issuer there is no discovery source; the build must not
        attempt any fetch and the endpoints stay unset (manual entry remains the only path)."""
        manager = MCPServerManager()
        row = self._urlless_row()

        anchored = AsyncMock()
        resource_rooted = AsyncMock()
        with (
            patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=anchored),
            patch.object(manager, "_descovery_metadata", new=resource_rooted),
        ):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        anchored.assert_not_awaited()
        resource_rooted.assert_not_awaited()
        assert built.authorization_url is None
        assert built.token_url is None
        assert built.issuer_is_anchored is False

    @pytest.mark.asyncio
    async def test_urlless_obo_with_issuer_discovers_token_url(self):
        """oauth2_token_exchange is not a discovery auth type, so the plain gate relax alone
        would leave a url-less OBO server undiscovered; with an issuer pinned and no configured
        exchange endpoint it must resolve token_url through the issuer-anchored fetch. Kills the
        mutant that drops the OBO widening from the anchor computation."""
        manager = MCPServerManager()
        row = self._urlless_row(
            alias="obo_urlless",
            auth_type=MCPAuth.oauth2_token_exchange,
            issuer="https://idp.example.com",
        )

        resolved = MCPOAuthMetadata(token_url="https://idp.example.com/token")
        resource_rooted = AsyncMock(return_value=None)
        with (
            patch.object(
                manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=resolved)
            ) as anchored,
            patch.object(manager, "_descovery_metadata", new=resource_rooted),
        ):
            built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)

        anchored.assert_awaited_once_with("https://idp.example.com", None)
        resource_rooted.assert_not_awaited()
        assert built.token_url == "https://idp.example.com/token"


class TestDiscoveryFailureLogging:
    """LIT-4658: a misconfigured MCP server url must be diagnosable from default-level server logs.

    Discovery failures used to die at debug level and the config-load path emitted no warning at
    all, so the only operator-facing signal was the bare 400 at /authorize."""

    def _connect_error_client(self, url: str) -> MagicMock:
        client = MagicMock()
        client.get = AsyncMock(side_effect=httpx.ConnectError(f"[Errno 8] nodename nor servname provided for {url}"))
        return client

    @pytest.mark.asyncio
    async def test_descovery_metadata_warns_with_redacted_attempts_on_connect_error(self, caplog):
        manager = MCPServerManager()
        secret_url = "https://typo-host.example.com/mcp/s/PATHSECRET/mcp"
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=self._connect_error_client(secret_url),
            ),
            caplog.at_level(logging.WARNING, logger="LiteLLM"),
        ):
            result = await manager._descovery_metadata(secret_url, warn_when_no_metadata=True)
        assert result is None
        assert "found no authorization server metadata" in caplog.text
        assert "ConnectError" in caplog.text
        assert "https://typo-host.example.com" in caplog.text
        # hosted MCP urls embed credentials in the path; neither the url nor the exception
        # text may leak it into warning-level logs
        assert "PATHSECRET" not in caplog.text

    @pytest.mark.asyncio
    async def test_descovery_metadata_stays_silent_without_warn_flag(self, caplog):
        manager = MCPServerManager()
        url = "https://typo-host.example.com/mcp"
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=self._connect_error_client(url),
            ),
            caplog.at_level(logging.WARNING, logger="LiteLLM"),
        ):
            result = await manager._descovery_metadata(url)
        assert result is None
        assert "found no authorization server metadata" not in caplog.text

    @pytest.mark.asyncio
    async def test_descovery_metadata_attempt_trail_names_each_failed_step(self, caplog):
        manager = MCPServerManager()
        url = "https://real-host.example.com/mcp-typo"
        client = MagicMock()
        client.get = AsyncMock(return_value=httpx.Response(404, request=httpx.Request("GET", url)))
        with (
            patch(
                "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
                return_value=client,
            ),
            caplog.at_level(logging.WARNING, logger="LiteLLM"),
        ):
            result = await manager._descovery_metadata(url, warn_when_no_metadata=True)
        assert result is None
        assert "HTTP 404" in caplog.text
        assert "well-known protected-resource lookup found no authorization servers" in caplog.text
        assert "origin fallback" in caplog.text

    @pytest.mark.asyncio
    async def test_load_servers_from_config_warns_when_endpoints_unresolved(self, caplog):
        manager = MCPServerManager()
        manager._descovery_metadata = AsyncMock(return_value=None)  # type: ignore[attr-defined]
        config = {
            "typo_server": {
                "url": "https://typo.example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2,
                "oauth2_flow": "authorization_code",
            }
        }
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.load_servers_from_config(config)
        assert "typo_server" in caplog.text
        assert "authorization_url, token_url" in caplog.text
        assert "unresolved" in caplog.text
        assert "verify the configured server url" in caplog.text

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "extra_config",
        [
            {
                "authorization_url": "https://idp.example.com/auth",
                "token_url": "https://idp.example.com/token",
            },
            {
                "oauth2_flow": "client_credentials",
                "token_url": "https://idp.example.com/token",
                "client_id": "cid",
                "client_secret": "csec",
            },
        ],
    )
    async def test_load_servers_from_config_silent_when_flow_needs_covered(self, caplog, extra_config):
        """Manually covered endpoints and M2M servers (which never need authorization_url) must not
        warn on every reload; the warning is a misconfiguration signal, not discovery telemetry."""
        manager = MCPServerManager()
        manager._descovery_metadata = AsyncMock(return_value=None)  # type: ignore[attr-defined]
        config = {
            "covered_server": {
                "url": "https://up.example.com/mcp",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2,
                "oauth2_flow": "authorization_code",
                **extra_config,
            }
        }
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.load_servers_from_config(config)
        assert "unresolved" not in caplog.text
        assert "no discovery source" not in caplog.text

    @pytest.mark.asyncio
    async def test_config_server_without_discovery_source_warns_about_missing_endpoints(self, caplog):
        manager = MCPServerManager()
        manager._register_openapi_tools = AsyncMock()  # type: ignore[attr-defined]
        config = {
            "spec_only": {
                "spec_path": "https://example.com/openapi.yaml",
                "transport": MCPTransport.http,
                "auth_type": MCPAuth.oauth2,
                "oauth2_flow": "authorization_code",
            }
        }
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.load_servers_from_config(config)
        assert "no discovery source" in caplog.text
        assert "authorization_url and token_url are not set manually" in caplog.text

    @pytest.mark.asyncio
    async def test_db_build_warns_when_discovery_fails_for_oauth2_row(self, caplog):
        manager = MCPServerManager()
        manager._descovery_metadata = AsyncMock(return_value=None)  # type: ignore[attr-defined]
        record = LiteLLM_MCPServerTable(
            server_id="typo-row-1",
            server_name="typo_row",
            url="https://typo.example.com/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth2,
            oauth2_flow="authorization_code",
        )
        with caplog.at_level(logging.WARNING, logger="LiteLLM"):
            await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)
        assert "typo_row" in caplog.text
        assert "authorization_url, token_url" in caplog.text
        assert "unresolved" in caplog.text


def _unrestricted_auth() -> MagicMock:
    """A caller with no object_permission, so only server-level checks apply."""
    user_api_key_auth = MagicMock()
    user_api_key_auth.object_permission = None
    user_api_key_auth.object_permission_id = None
    return user_api_key_auth


def _permissive_proxy_logging() -> MagicMock:
    proxy_logging_obj = MagicMock()
    proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(return_value={})
    proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
    proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})
    return proxy_logging_obj


ALIAS_LESS_SERVER_ID = "117c814c-1a2b-4c4d-8e8f-0a1b2c3d4e5f"


class TestServerToolListsHonorThePrefixBoundary:
    """The server-level allowed_tools / disallowed_tools / allowed_params checks
    receive a BARE tool name. Every caller resolves the prefix boundary before
    dispatch (``server.py``'s ``original_tool_name``, the Responses handler's
    ``sanitized_tool_name``), and ``call_tool`` hands that same value to the
    upstream client verbatim, which only works because it carries no prefix.

    Stored entries may also carry a prefix, and routing accepts *every* prefix
    from ``iter_known_server_prefixes`` (short ID, alias, server_name,
    server_id), so enforcement derives that whole set via
    ``iter_known_tool_name_spellings``. Rebuilding a single comparand as
    ``f"{server.name}-{tool_name}"`` used a field the prefix chain never reads
    (``get_server_prefix`` is short_prefix, then alias, then server_name, then
    server_id) and hardcoded the separator; deriving only ``get_server_prefix``
    covers just the currently published spelling. Either way the check answers
    for fewer spellings than are reachable, and on the blocklist arm that is a
    fail-open.
    """

    async def _run_check(self, server: MCPServer, name: str, arguments: dict[str, Any] | None = None) -> None:
        await MCPServerManager().pre_call_tool_check(
            name=name,
            arguments=arguments if arguments is not None else {},
            server_name=server.name,
            user_api_key_auth=_unrestricted_auth(),
            proxy_logging_obj=_permissive_proxy_logging(),
            server=server,
        )

    @staticmethod
    def _aliased_server(**overrides: Any) -> MCPServer:
        return MCPServer(
            server_id="dd7f2b9e-2c4a-4f1b-9e0a-8d3c6b5a4f21",
            name="petstore_prod",
            alias="petstore",
            server_name="petstore_prod",
            url="https://petstore.example.com/mcp",
            transport=MCPTransport.http,
            **overrides,
        )

    @staticmethod
    def _alias_less_server(**overrides: Any) -> MCPServer:
        # No alias and no server_name, so the published prefix is the UUID
        # server_id, which itself contains the prefix separator.
        return MCPServer(
            server_id=ALIAS_LESS_SERVER_ID,
            name=ALIAS_LESS_SERVER_ID,
            url="https://wiki.example.com/mcp",
            transport=MCPTransport.http,
            **overrides,
        )

    @pytest.mark.asyncio
    async def test_allowlist_entry_prefixed_with_the_alias_matches_a_bare_call(self):
        # The dashboard shows tools under the published prefix, so admins store
        # "petstore-getpetbyid"; the display name "petstore_prod" is not it.
        server = self._aliased_server(allowed_tools=["petstore-getpetbyid"])

        await self._run_check(server, "getpetbyid")

    @pytest.mark.asyncio
    async def test_bare_allowlist_entry_matches_on_an_alias_less_server(self):
        server = self._alias_less_server(allowed_tools=["read_wiki_contents"])

        await self._run_check(server, "read_wiki_contents")

    @pytest.mark.asyncio
    async def test_wire_form_allowlist_entry_matches_on_an_alias_less_server(self):
        # The published prefix is the UUID server_id, so it contains the
        # separator; the derived wire form has to reproduce it whole.
        server = self._alias_less_server(allowed_tools=[f"{ALIAS_LESS_SERVER_ID}-read_wiki_contents"])

        await self._run_check(server, "read_wiki_contents")

    @pytest.mark.asyncio
    async def test_wire_form_entry_matches_a_native_name_that_opens_with_the_prefix(self):
        # "petstore-getpetbyid" is a real upstream tool name here, so its wire
        # form is "petstore-petstore-getpetbyid". Stripping the stored entry
        # instead of deriving the wire form cut a boundary the caller had already
        # consumed, leaving asymmetric operands that denied a permitted call.
        server = self._aliased_server(allowed_tools=["petstore-petstore-getpetbyid"])

        await self._run_check(server, "petstore-getpetbyid")

    @pytest.mark.asyncio
    async def test_wire_form_blocklist_entry_blocks_a_native_name_that_opens_with_the_prefix(self):
        server = self._aliased_server(disallowed_tools=["petstore-petstore-getpetbyid"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "petstore-getpetbyid")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_wire_form_blocklist_entry_blocks_under_the_short_prefix_mode(self, monkeypatch):
        # short_prefix wins in get_server_prefix but is never server.name, so the
        # hand-built comparand could not match a stored wire-form entry and the
        # blocklisted tool stayed callable.
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", disallowed_tools=["F3X-deletepet"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "deletepet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_alias_form_blocklist_entry_still_blocks_under_the_short_prefix_mode(self, monkeypatch):
        # Turning short prefixes on republishes every tool under the short ID,
        # but routing still resolves the alias form, so an entry an admin stored
        # before the flip stays reachable and has to stay enforced. Deriving only
        # the published spelling silently stops honoring it: a fail-open on a
        # config nobody edited.
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", disallowed_tools=["petstore-deletepet"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "deletepet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_alias_form_allowlist_entry_still_matches_under_the_short_prefix_mode(self, monkeypatch):
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", allowed_tools=["petstore-getpetbyid"])

        await self._run_check(server, "getpetbyid")

    @pytest.mark.asyncio
    async def test_server_name_form_blocklist_entry_still_blocks_under_the_short_prefix_mode(self, monkeypatch):
        # server_name sits third in the prefix chain, so it is published only
        # when alias and short_prefix are both absent, yet routing accepts it
        # regardless.
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", disallowed_tools=["petstore_prod-deletepet"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "deletepet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_raw_server_id_form_blocklist_entry_still_blocks_under_the_short_prefix_mode(self, monkeypatch):
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._alias_less_server(disallowed_tools=[f"{ALIAS_LESS_SERVER_ID}-read_wiki_contents"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "read_wiki_contents")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_allowed_params_keyed_by_the_alias_form_are_enforced_under_the_short_prefix_mode(self, monkeypatch):
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", allowed_params={"petstore-getpetbyid": ["petid"]})

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(
                server,
                "getpetbyid",
                arguments={"petid": "7", "include_internal": "true"},
            )

        assert exc_info.value.status_code == 403
        assert "include_internal" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_foreign_prefix_entry_does_not_match_under_the_short_prefix_mode(self, monkeypatch):
        # Honoring every known prefix must not become "honor any prefix": the
        # widened set is this server's spellings only.
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
        server = self._aliased_server(short_prefix="F3X", allowed_tools=["other_server-getpetbyid"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "getpetbyid")

        assert exc_info.value.status_code == 403

    @pytest.mark.parametrize("short_prefix_mode", [False, True])
    @pytest.mark.asyncio
    async def test_every_spelling_routing_registers_is_also_enforced(self, monkeypatch, short_prefix_mode):
        """The invariant, driven through production code on both sides.

        ``_create_prefixed_tools`` decides which spellings reach dispatch, so
        every key it registers has to be a spelling the blocklist can refuse.
        Any key routing accepts but enforcement misses is a callable blocked
        tool.
        """
        monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true" if short_prefix_mode else "false")
        shape = self._aliased_server(short_prefix="F3X")

        manager = MCPServerManager()
        manager._create_prefixed_tools([MCPTool(name="deletepet", description="", inputSchema={})], shape)
        registered = sorted(manager.tool_name_to_mcp_server_name_mapping)
        assert len(registered) > 1

        for spelling in registered:
            server = self._aliased_server(short_prefix="F3X", disallowed_tools=[spelling])
            with pytest.raises(HTTPException) as exc_info:
                await self._run_check(server, "deletepet")
            assert exc_info.value.status_code == 403, spelling

    @pytest.mark.asyncio
    async def test_wire_form_allowlist_entry_follows_a_non_default_separator(self):
        from litellm.proxy._experimental.mcp_server import utils as mcp_utils

        server = self._aliased_server(allowed_tools=["petstore__getpetbyid"])

        with patch.object(mcp_utils, "MCP_TOOL_PREFIX_SEPARATOR", "__"):
            await self._run_check(server, "getpetbyid")

    @pytest.mark.asyncio
    async def test_tool_outside_the_allowlist_is_still_denied(self):
        server = self._aliased_server(allowed_tools=["petstore-getpetbyid"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "deletepet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_allowlist_entry_prefixed_for_another_server_does_not_match(self):
        # Reducing both sides must not widen the allowlist across servers: a
        # foreign prefix is not one of this server's known prefixes, so the
        # entry keeps it and never collapses onto a bare name.
        server = self._aliased_server(allowed_tools=["other_server-getpetbyid"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "getpetbyid")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_prefixed_disallowed_entry_blocks_a_bare_call(self):
        # Fail-open regression: the blocklist arm answered "not banned" whenever
        # the stored entry carried a prefix it failed to reconstruct.
        server = self._aliased_server(disallowed_tools=["petstore-deletepet"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "deletepet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_tool_outside_the_blocklist_is_still_allowed(self):
        server = self._aliased_server(disallowed_tools=["petstore-deletepet"])

        await self._run_check(server, "getpetbyid")

    @pytest.mark.asyncio
    async def test_allowed_params_are_enforced_for_a_bare_key(self):
        server = self._alias_less_server(allowed_params={"read_wiki_contents": ["repo"]})

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(
                server,
                "read_wiki_contents",
                arguments={"repo": "acme/wiki", "internal_only": "true"},
            )

        assert exc_info.value.status_code == 403
        assert "internal_only" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_allowed_params_are_enforced_for_a_wire_form_key(self):
        # A key stored under the published prefix matched nothing, so the lookup
        # returned None and the check silently allowed every parameter instead of
        # enforcing the configured list.
        server = self._alias_less_server(allowed_params={f"{ALIAS_LESS_SERVER_ID}-read_wiki_contents": ["repo"]})

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(
                server,
                "read_wiki_contents",
                arguments={"repo": "acme/wiki", "internal_only": "true"},
            )

        assert exc_info.value.status_code == 403
        assert "internal_only" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_allowed_params_still_accept_the_configured_parameters(self):
        server = self._alias_less_server(allowed_params={f"{ALIAS_LESS_SERVER_ID}-read_wiki_contents": ["repo"]})

        await self._run_check(server, "read_wiki_contents", arguments={"repo": "acme/wiki"})

    @pytest.mark.asyncio
    async def test_allowed_params_are_enforced_for_a_native_name_that_opens_with_the_prefix(self):
        server = self._aliased_server(allowed_params={"petstore-petstore-getpetbyid": ["petid"]})

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(
                server,
                "petstore-getpetbyid",
                arguments={"petid": "7", "include_internal": "true"},
            )

        assert exc_info.value.status_code == 403
        assert "include_internal" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_an_entry_does_not_decide_an_operation_id_registration_keeps_separate(self):
        server = self._aliased_server(disallowed_tools=["foo/bar"], spec_path="/specs/petstore.yaml")

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "foo/bar")

        assert exc_info.value.status_code == 403
        await self._run_check(server, "foo.bar")

    @pytest.mark.asyncio
    async def test_a_blocklist_entry_does_not_reach_a_case_variant_sibling_tool(self):
        server = self._aliased_server(disallowed_tools=["petstore-getPet"])

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "getPet")

        assert exc_info.value.status_code == 403
        await self._run_check(server, "getpet")

    @pytest.mark.asyncio
    async def test_an_allowlist_entry_does_not_grant_a_case_variant_sibling_tool(self):
        server = self._aliased_server(allowed_tools=["petstore-getPet"])

        await self._run_check(server, "getPet")

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "getpet")

        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_an_explicitly_empty_allowed_params_list_refuses_every_parameter(self):
        server = self._alias_less_server(allowed_params={"read_wiki_contents": []})

        with pytest.raises(HTTPException) as exc_info:
            await self._run_check(server, "read_wiki_contents", arguments={"repo": "acme/wiki"})

        assert exc_info.value.status_code == 403
        assert "repo" in exc_info.value.detail["error"]

    @pytest.mark.asyncio
    async def test_an_explicitly_empty_allowed_params_list_still_permits_an_argument_free_call(self):
        server = self._alias_less_server(allowed_params={"read_wiki_contents": []})

        await self._run_check(server, "read_wiki_contents", arguments={})


class TestOpenAPIRegistryKeyMatchesRegistration:
    """OpenAPI tools are registered under ``add_server_prefix_to_name(base, get_server_prefix(server))``,
    so the dispatch lookup has to build its key the same way from the bare name ``call_tool``
    hands it. Rebuilding it as ``f"{server.name}-{bare_name}"`` used a field the prefix chain
    never reads and hardcoded the separator, so every call on a server whose published prefix
    differs from its display name failed with "not found in registry" instead of dispatching.
    """

    @staticmethod
    def _register(server: MCPServer, base_tool_name: str) -> str:
        from litellm.proxy._experimental.mcp_server.utils import (
            add_server_prefix_to_name,
            get_server_prefix,
        )

        return add_server_prefix_to_name(base_tool_name, get_server_prefix(server))

    async def _call(self, server: MCPServer, registered_key: str, bare_tool_name: str) -> CallToolResult:
        from litellm.proxy._experimental.mcp_server.tool_registry import (
            global_mcp_tool_registry,
        )

        async def handler(**kwargs: Any) -> str:
            return "dispatched"

        tool = MagicMock()
        tool.handler = handler

        with patch.dict(global_mcp_tool_registry.tools, {registered_key: tool}, clear=True):
            return await MCPServerManager()._call_openapi_tool_handler(server, bare_tool_name, {})

    @pytest.mark.asyncio
    async def test_aliased_server_dispatches_when_name_differs_from_published_prefix(self):
        server = MCPServer(
            server_id="dd7f2b9e-2c4a-4f1b-9e0a-8d3c6b5a4f21",
            name="petstore_prod",
            alias="petstore",
            server_name="petstore_prod",
            url=None,
            transport=MCPTransport.http,
            spec_path="https://example.com/petstore.yaml",
        )
        registered_key = self._register(server, "list_pets")
        assert registered_key == "petstore-list_pets"

        result = await self._call(server, registered_key, "list_pets")

        assert result.isError is False
        assert result.content[0].text == "dispatched"

    @pytest.mark.asyncio
    async def test_alias_less_server_dispatches_when_the_prefix_contains_the_separator(self):
        server = MCPServer(
            server_id=ALIAS_LESS_SERVER_ID,
            name=ALIAS_LESS_SERVER_ID,
            url=None,
            transport=MCPTransport.http,
            spec_path="https://example.com/wiki.yaml",
        )
        registered_key = self._register(server, "read_wiki_contents")
        assert registered_key == f"{ALIAS_LESS_SERVER_ID}-read_wiki_contents"

        result = await self._call(server, registered_key, "read_wiki_contents")

        assert result.isError is False
        assert result.content[0].text == "dispatched"

    @pytest.mark.asyncio
    async def test_dispatch_keeps_a_native_name_that_opens_with_the_prefix(self):
        # Registration prefixes the upstream name whatever it looks like, so
        # "petstore-list_pets" is registered as "petstore-petstore-list_pets".
        # Stripping the bare name again before rebuilding the key cut that
        # leading segment back off and the lookup missed.
        server = MCPServer(
            server_id="dd7f2b9e-2c4a-4f1b-9e0a-8d3c6b5a4f21",
            name="petstore_prod",
            alias="petstore",
            server_name="petstore_prod",
            url=None,
            transport=MCPTransport.http,
            spec_path="https://example.com/petstore.yaml",
        )
        registered_key = self._register(server, "petstore-list_pets")
        assert registered_key == "petstore-petstore-list_pets"

        result = await self._call(server, registered_key, "petstore-list_pets")

        assert result.isError is False
        assert result.content[0].text == "dispatched"

    @pytest.mark.asyncio
    async def test_dispatch_follows_a_non_default_prefix_separator(self):
        from litellm.proxy._experimental.mcp_server import utils as mcp_utils

        server = MCPServer(
            server_id="dd7f2b9e-2c4a-4f1b-9e0a-8d3c6b5a4f21",
            name="petstore_prod",
            alias="petstore",
            server_name="petstore_prod",
            url=None,
            transport=MCPTransport.http,
            spec_path="https://example.com/petstore.yaml",
        )

        with patch.object(mcp_utils, "MCP_TOOL_PREFIX_SEPARATOR", "__"):
            registered_key = self._register(server, "list_pets")
            assert registered_key == "petstore__list_pets"

            result = await self._call(server, registered_key, "list_pets")

        assert result.isError is False
        assert result.content[0].text == "dispatched"

    @pytest.mark.asyncio
    async def test_unregistered_tool_is_still_reported_missing(self):
        server = MCPServer(
            server_id="dd7f2b9e-2c4a-4f1b-9e0a-8d3c6b5a4f21",
            name="petstore_prod",
            alias="petstore",
            server_name="petstore_prod",
            url=None,
            transport=MCPTransport.http,
            spec_path="https://example.com/petstore.yaml",
        )

        result = await self._call(server, "petstore-list_pets", "delete_pet")

        assert result.isError is True
        assert "not found in registry" in result.content[0].text


class TestToolAuthorizationIsNotConditionalOnLogging:
    """`call_tool` used to run `pre_call_tool_check` — the only place tool-level
    MCP entitlements are enforced — inside `if proxy_logging_obj:`, so a caller
    reached with no logging object got no authorization decision at all. Both
    production call sites pass the module-level `ProxyLogging` singleton, which
    is never None, so this was not a live hole; the invariant being restored is
    that an authorization decision cannot be skipped by an absent logger.
    """

    @staticmethod
    def _manager_with_scoped_server() -> tuple[MCPServerManager, UserAPIKeyAuth]:
        manager = MCPServerManager()
        manager.registry["srv-gated"] = MCPServer(
            server_id="srv-gated",
            name="gated_server",
            server_name="gated_server",
            alias="gated_server",
            url="http://127.0.0.1:1/mcp",
            transport=MCPTransport.http,
            auth_type=MCPAuth.none,
        )
        for tool_name in ("read_only_tool", "delete_everything"):
            manager.tool_name_to_mcp_server_name_mapping[tool_name] = "gated_server"
        user = UserAPIKeyAuth(
            api_key="sk-caller",
            user_id="alice",
            user_role=LitellmUserRoles.INTERNAL_USER.value,
            object_permission=LiteLLM_ObjectPermissionTable(
                object_permission_id="op-gated",
                mcp_servers=["srv-gated"],
                mcp_tool_permissions={"srv-gated": ["read_only_tool"]},
            ),
        )
        return manager, user

    @pytest.mark.asyncio
    async def test_unentitled_tool_refused_without_proxy_logging_obj(self):
        manager, user = self._manager_with_scoped_server()
        upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False))

        with patch.object(manager, "_call_regular_mcp_tool", new=upstream):
            with pytest.raises(HTTPException) as exc:
                await manager.call_tool(
                    server_name="gated_server",
                    name="delete_everything",
                    arguments={},
                    user_api_key_auth=user,
                    proxy_logging_obj=None,
                )

        assert exc.value.status_code == 403
        upstream.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_entitled_tool_still_dispatches_without_proxy_logging_obj(self):
        """The gate must refuse only what the entitlement excludes; an allowed
        tool still reaches the upstream when there is no logging object."""
        manager, user = self._manager_with_scoped_server()
        upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False))

        with patch.object(manager, "_call_regular_mcp_tool", new=upstream):
            await manager.call_tool(
                server_name="gated_server",
                name="read_only_tool",
                arguments={},
                user_api_key_auth=user,
                proxy_logging_obj=None,
            )

        upstream.assert_awaited_once()


class TestSessionResourceScopeIntersect:
    """LIT-4917: the sealed session scope intersects the admitted subject's resolved server
    set at the single convergence point every fan-out and tool call reads, covering the
    exception fallback so a resolver fault never widens a scoped bearer."""

    def _admitted_auth(self, scope):
        from litellm.proxy._types import UserAPIKeyAuth

        auth = UserAPIKeyAuth(user_id="scoped-user")
        auth.mcp_admitted_user_subject = True
        auth.mcp_session_resource_server_id = scope
        return auth

    def test_scope_reader_is_none_for_keys_and_unscoped_subjects(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
        from litellm.proxy._types import UserAPIKeyAuth

        assert MCPServerManager._admitted_session_resource_scope(None) is None
        assert MCPServerManager._admitted_session_resource_scope(UserAPIKeyAuth(user_id="u")) is None
        assert MCPServerManager._admitted_session_resource_scope(self._admitted_auth(None)) is None

    def test_scope_reader_returns_sealed_scope_for_admitted_subjects(self):
        from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager

        assert MCPServerManager._admitted_session_resource_scope(self._admitted_auth("b")) == "b"

    @pytest.mark.asyncio
    async def test_admin_registry_seed_still_bounded_by_session_resource_scope(self):
        """The admin-view registry seed flows through the same scoped exit as every union: a
        session envelope sealed to one server never widens past it, even held by an admin whose
        role resolves the whole registry. Pin for the connect-page-parity change; without the
        single-exit shape, the old early return would hand a per-server bearer the registry."""
        from unittest.mock import AsyncMock, patch

        from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
        from litellm.proxy._types import LitellmUserRoles
        from litellm.types.mcp import MCPTransport
        from litellm.types.mcp_server.mcp_server_manager import MCPServer

        manager = MCPServerManager()
        for sid in ("granted-id", "other-id"):
            manager.registry[sid] = MCPServer(
                server_id=sid, name=sid, server_name=sid, url="https://example.com/mcp", transport=MCPTransport.http
            )
        auth = self._admitted_auth("granted-id")
        auth.user_role = LitellmUserRoles.PROXY_ADMIN
        with (
            patch.object(MCPServerManager, "get_allow_all_keys_server_ids", return_value=[]),
            patch.object(
                MCPServerManager,
                "_get_active_submitted_mcp_server_ids_for_user",
                new_callable=AsyncMock,
                return_value=[],
            ),
        ):
            assert await manager.get_allowed_mcp_servers(auth) == ["granted-id"]
            auth.mcp_session_resource_server_id = None
            assert set(await manager.get_allowed_mcp_servers(auth)) == {"granted-id", "other-id"}

    @pytest.mark.asyncio
    async def test_get_allowed_mcp_servers_scopes_past_operator_open_union(self):
        """The intersect applies AFTER the operator-open (allow_all_keys) union, so a scoped
        bearer cannot reach an allow-all server outside its scope, and applies on the
        exception fallback so a resolver fault yields the scoped subset of allow-all rather
        than the whole set."""
        from unittest.mock import AsyncMock, patch

        from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager

        manager = MCPServerManager()
        auth = self._admitted_auth("granted-id")
        with (
            patch.object(MCPServerManager, "get_allow_all_keys_server_ids", return_value=["open-id", "granted-id"]),
            patch(
                "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_allowed_mcp_servers",
                new_callable=AsyncMock,
                return_value=["granted-id", "other-id"],
            ),
            patch.object(
                MCPServerManager,
                "_get_active_submitted_mcp_server_ids_for_user",
                new_callable=AsyncMock,
                return_value=[],
            ),
        ):
            allowed = await manager.get_allowed_mcp_servers(auth)
        assert allowed == ["granted-id"]

        with (
            patch.object(MCPServerManager, "get_allow_all_keys_server_ids", return_value=["open-id", "granted-id"]),
            patch(
                "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_allowed_mcp_servers",
                new_callable=AsyncMock,
                side_effect=RuntimeError("resolver down"),
            ),
            patch.object(
                MCPServerManager,
                "_get_active_submitted_mcp_server_ids_for_user",
                new_callable=AsyncMock,
                return_value=[],
            ),
        ):
            fallback = await manager.get_allowed_mcp_servers(auth)
        assert fallback == ["granted-id"]


class TestClientForwardedDiscoveryFailureIsNotFatal:
    """A failed OAuth metadata discovery may only brick the flows the gateway runs itself.

    ``true_passthrough`` / ``oauth_delegate`` forward the caller's own bearer and mint nothing, so an
    upstream that publishes no RFC 9728 metadata (an internal API, or any IdP unreachable from the
    pod) must still serve sessions instead of 503-ing before the upstream is ever contacted.
    """

    @staticmethod
    def _config(auth_type: MCPAuthType, dcr_bridge: bool | None) -> dict[str, dict[str, object]]:
        entry: Final[dict[str, object]] = {
            "url": "https://up.example.com/mcp",
            "transport": MCPTransport.http,
            "auth_type": auth_type,
            **({"oauth2_flow": "authorization_code"} if auth_type == MCPAuth.oauth2 else {}),
            **({"dcr_bridge": dcr_bridge} if dcr_bridge is not None else {}),
        }
        return {"upstream": entry}

    async def _registered(self, manager: MCPServerManager, auth_type: MCPAuthType, dcr_bridge: bool | None):
        with (
            patch.object(manager, "_discover_oauth_metadata_for_server", new=AsyncMock(return_value=None)),
            patch.object(manager, "initialize_tool_name_to_mcp_server_name_mapping"),
        ):
            await manager.load_servers_from_config(self._config(auth_type, dcr_bridge))
        return next(iter(manager.config_mcp_servers.values()))

    @pytest.mark.parametrize(
        "auth_type, dcr_bridge, serves_without_endpoints",
        [
            (MCPAuth.true_passthrough, None, True),
            (MCPAuth.true_passthrough, True, True),
            (MCPAuth.oauth_delegate, None, True),
            (MCPAuth.oauth_delegate, True, True),
            (MCPAuth.oauth2, None, False),
            (MCPAuth.oauth2_token_exchange, None, False),
        ],
    )
    @pytest.mark.parametrize("failure", ["incomplete", "timed_out"])
    @pytest.mark.asyncio
    async def test_discovery_failure_blocks_only_gateway_run_flows(
        self,
        auth_type: MCPAuthType,
        dcr_bridge: bool | None,
        serves_without_endpoints: bool,
        failure: str,
    ):
        manager = MCPServerManager()
        server = await self._registered(manager, auth_type, dcr_bridge)
        manager._set_oauth_discovery_deferred(server.server_id, True)

        async def never_returns(_server):
            await asyncio.Future()

        discovery_patch: Final = (
            {"new": AsyncMock(return_value=None)} if failure == "incomplete" else {"side_effect": never_returns}
        )
        with (
            patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCP_METADATA_TIMEOUT", 0.01),
            patch.object(manager, "_discover_oauth_metadata_for_server", **discovery_patch),
        ):
            if not serves_without_endpoints:
                with pytest.raises(HTTPException) as exc:
                    await manager.ensure_oauth_metadata_discovered(server)
                assert exc.value.status_code == 503
                return

            resolved = await manager.ensure_oauth_metadata_discovered(server)

        assert resolved is manager.config_mcp_servers[server.server_id]
        assert resolved.authorization_url is None
        assert resolved.token_url is None
        assert manager._oauth_discovery_slot(server.server_id) is not None

    @pytest.mark.parametrize(
        "auth_type, serves_the_listing",
        [(MCPAuth.true_passthrough, True), (MCPAuth.oauth_delegate, True), (MCPAuth.oauth2, False)],
    )
    @pytest.mark.asyncio
    async def test_listing_leg_serves_a_forwarding_server_whose_discovery_failed(
        self, auth_type: MCPAuthType, serves_the_listing: bool
    ):
        """The listing leg is where the 503 became an empty tool list, so pin the fix there too.

        ``_get_tools_from_server`` is the per-server leg the aggregate absorbs: a failure here is what
        the fan-out turns into HTTP 200 with ``tools: []``, which is why the outage carried no
        diagnostic. A forwarding server must now reach its upstream, and a gateway-run flow must still
        surface the fault rather than be silently listed as empty.
        """
        manager = MCPServerManager()
        server = await self._registered(manager, auth_type, None)
        manager._set_oauth_discovery_deferred(server.server_id, True)
        manager._fetch_tools_with_timeout = AsyncMock(
            return_value=[MCPTool(name="list_reports", description="d", inputSchema={"type": "object"})]
        )

        with patch.object(manager, "_discover_oauth_metadata_for_server", new=AsyncMock(return_value=None)):
            if not serves_the_listing:
                with pytest.raises(MCPServerListError):
                    await manager._get_tools_from_server(server=server)
                return
            tools = await manager._get_tools_from_server(server=server)

        assert [tool.name for tool in tools] == ["upstream-list_reports"]
        manager._fetch_tools_with_timeout.assert_awaited_once()

    @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
    @pytest.mark.asyncio
    async def test_client_forwarded_servers_keep_discovering_their_front_door_endpoints(self, auth_type: MCPAuthType):
        """Exempting these modes from the FAILURE must not exempt them from discovery itself.

        ``/authorize``, ``/token`` and ``/register`` read the discovered endpoints for these servers
        (``_resolve_ephemeral_dcr_client`` mints for ``true_passthrough`` whatever ``dcr_bridge``
        says), so an exemption written into the unresolved-endpoints predicate would disarm the slot
        and silently drop a working front door.
        """
        manager = MCPServerManager()
        metadata: Final = MCPOAuthMetadata(
            authorization_url="https://idp.example.com/authorize",
            token_url="https://idp.example.com/token",
            registration_url="https://idp.example.com/register",
        )
        with (
            patch.object(manager, "_discover_oauth_metadata_for_server", new=AsyncMock(return_value=metadata)),
            patch.object(manager, "initialize_tool_name_to_mcp_server_name_mapping"),
        ):
            await manager.load_servers_from_config(self._config(auth_type, None))
            server = next(iter(manager.config_mcp_servers.values()))
            resolved = await manager.ensure_oauth_metadata_discovered(server)

        assert resolved.authorization_url == "https://idp.example.com/authorize"
        assert resolved.token_url == "https://idp.example.com/token"
        assert resolved.registration_url == "https://idp.example.com/register"
        assert manager.config_mcp_servers[server.server_id].authorization_url == "https://idp.example.com/authorize"
        assert manager._oauth_discovery_slot(server.server_id) is None


class TestResolveOpenapiToolAuth:
    """The credential matrix for a ``spec_path`` server's two OpenAPI dispatch arms.

    A per-server ``x-mcp-{alias}-authorization`` is already a complete header value and is forwarded
    verbatim; a BYOK credential is a raw secret that takes the auth-type prefix. Conflating them
    ships ``Bearer Bearer <token>``, so every cell pins which of the two a value came from.
    """

    @staticmethod
    def _server(auth_type: MCPAuthType = MCPAuth.oauth_delegate) -> MCPServer:
        return MCPServer(
            server_id="srv-openapi",
            name="report_api",
            server_name="report_api",
            alias="report_api",
            url="https://api.internal.example.com",
            transport=MCPTransport.http,
            auth_type=auth_type,
            spec_path="https://api.internal.example.com/openapi.json",
            extra_headers=["X-Tenant"],
        )

    @pytest.mark.parametrize(
        "per_server, byok, expected_auth, expected_extra_keys, expected_credential",
        [
            (
                {"report_api": "Bearer caller-token"},
                None,
                "Bearer caller-token",
                {"X-Tenant"},
                "Bearer caller-token",
            ),
            (
                {"report_api": {"Authorization": "Bearer caller-token", "X-Trace": "abc"}},
                None,
                "Bearer caller-token",
                {"X-Tenant", "X-Trace"},
                {"Authorization": "Bearer caller-token", "X-Trace": "abc"},
            ),
            (
                {"report_api": {"X-Api-Key": "k1"}},
                "byok-secret",
                "Bearer byok-secret",
                {"X-Tenant", "X-Api-Key"},
                "byok-secret",
            ),
            ({"report_api": {"X-Api-Key": "k1"}}, None, None, {"X-Tenant", "X-Api-Key"}, None),
            (None, "byok-secret", "Bearer byok-secret", {"X-Tenant"}, "byok-secret"),
            (None, None, None, {"X-Tenant"}, None),
            ({"other_server": "Bearer wrong"}, None, None, {"X-Tenant"}, None),
        ],
    )
    def test_credential_matrix(
        self,
        per_server: dict | None,
        byok: str | None,
        expected_auth: str | None,
        expected_extra_keys: set,
        expected_credential: object,
    ):
        auth_value, forwarded, credential = _resolve_openapi_tool_auth(
            mcp_server=self._server(),
            mcp_auth_header=byok,
            mcp_server_auth_headers=per_server,
            raw_headers={"x-tenant": "acme", "authorization": "Bearer admission-key"},
            user_api_key_auth=None,
        )

        assert auth_value == expected_auth
        assert set(forwarded or {}) == expected_extra_keys
        assert credential == expected_credential

    def test_per_server_value_is_never_re_prefixed(self):
        """The regression that a naive wiring produces: the caller already sent ``Bearer <token>``."""
        auth_value, _, credential = _resolve_openapi_tool_auth(
            mcp_server=self._server(auth_type=MCPAuth.api_key),
            mcp_auth_header="byok-secret",
            mcp_server_auth_headers={"report_api": "Bearer caller-token"},
            raw_headers=None,
            user_api_key_auth=None,
        )

        assert auth_value == "Bearer caller-token"
        assert credential == "Bearer caller-token"
        assert not auth_value.startswith("ApiKey ")

    def test_per_server_authorization_is_not_also_left_in_forwarded_headers(self):
        """``resolve_openapi_upstream_auth`` pops Authorization out of the forwarded headers, so a
        second copy there would give the passthrough arm two sources to reconcile."""
        _, forwarded, _ = _resolve_openapi_tool_auth(
            mcp_server=self._server(),
            mcp_auth_header=None,
            mcp_server_auth_headers={"report_api": {"Authorization": "Bearer caller-token"}},
            raw_headers={"x-tenant": "acme"},
            user_api_key_auth=None,
        )

        assert "Authorization" not in (forwarded or {})


class TestOpenApiHandlerRelaysUpstreamAuth:
    """`_call_openapi_tool_handler` must not flatten a re-auth signal into a generic message.

    Its catch-all turned every exception into "Error calling OpenAPI tool ...", which is an isError
    result but loses the status, so the REST surface could no longer relay a 401 with the upstream's
    WWW-Authenticate and the streamable surface could not name the status the caller must act on.
    """

    @staticmethod
    def _server() -> MCPServer:
        return MCPServer(
            server_id="srv-openapi",
            name="report_api",
            server_name="report_api",
            url="https://api.example.com",
            transport=MCPTransport.http,
            auth_type=MCPAuth.oauth_delegate,
            spec_path="https://api.example.com/openapi.json",
        )

    @pytest.mark.asyncio
    async def test_upstream_auth_error_keeps_its_type(self):
        from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
        from litellm.proxy._experimental.mcp_server.tool_registry import global_mcp_tool_registry

        manager = MCPServerManager()
        server = self._server()

        async def raising_handler(**_kwargs):
            raise MCPUpstreamAuthError(status_code=401, www_authenticate="Bearer realm=x", server_name="report_api")

        tool = MagicMock()
        tool.handler = raising_handler
        with patch.object(global_mcp_tool_registry, "get_tool", return_value=tool):
            with pytest.raises(MCPUpstreamAuthError) as exc:
                await manager._call_openapi_tool_handler(server, "list_reports", {})

        assert exc.value.status_code == 401
        assert exc.value.www_authenticate == "Bearer realm=x"

    @pytest.mark.asyncio
    async def test_other_failures_still_become_an_error_result(self):
        from litellm.proxy._experimental.mcp_server.tool_registry import global_mcp_tool_registry

        manager = MCPServerManager()

        async def raising_handler(**_kwargs):
            raise RuntimeError("upstream returned HTTP 503")

        tool = MagicMock()
        tool.handler = raising_handler
        with patch.object(global_mcp_tool_registry, "get_tool", return_value=tool):
            result = await manager._call_openapi_tool_handler(self._server(), "list_reports", {})

        assert result.isError is True
        assert "upstream returned HTTP 503" in result.content[0].text
