# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import inspect
from unittest.mock import AsyncMock
from unittest.mock import create_autospec
from unittest.mock import Mock
from unittest.mock import patch

from google.adk.agents.context import Context
from google.adk.agents.invocation_context import InvocationContext
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import HttpAuth
from google.adk.auth.auth_credential import HttpCredentials
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.auth.auth_credential import ServiceAccount
from google.adk.features import FeatureName
from google.adk.features._feature_registry import temporary_feature_override
from google.adk.sessions.session import Session
from google.adk.tools.mcp_tool import mcp_tool
from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var
from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager
from google.adk.tools.mcp_tool.mcp_tool import MCPTool
from google.adk.tools.tool_context import ToolContext
from google.genai.types import FunctionDeclaration
from google.genai.types import Type
from mcp.types import CallToolResult
from mcp.types import TextContent
import pytest


# Mock MCP Tool from mcp.types
class MockMCPTool:
  """Mock MCP Tool for testing."""

  def __init__(
      self,
      name="test_tool",
      description="Test tool description",
      outputSchema=None,
      meta=None,
  ):
    self.name = name
    self.description = description
    self.meta = meta
    self.inputSchema = {
        "type": "object",
        "properties": {
            "param1": {"type": "string", "description": "First parameter"},
            "param2": {"type": "integer", "description": "Second parameter"},
        },
        "required": ["param1"],
    }
    self.outputSchema = outputSchema


class TestMCPToolLegacy:
  """Legacy tests for MCPTool."""

  @pytest.fixture(autouse=True)
  def disable_feature_flag(self):
    with temporary_feature_override(
        FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, False
    ):
      yield

  def setup_method(self):
    self.mock_mcp_tool = MockMCPTool()
    self.mock_session_manager = Mock(spec=MCPSessionManager)
    self.mock_session = AsyncMock()
    self.mock_session_manager.create_session = AsyncMock(
        return_value=self.mock_session
    )

  def test_get_declaration(self):
    """Test function declaration generation."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    declaration = tool._get_declaration()

    assert isinstance(declaration, FunctionDeclaration)
    assert declaration.name == "test_tool"
    assert declaration.description == "Test tool description"
    assert declaration.parameters is not None


class TestMCPToolWithJsonSchema:
  """Tests for MCPTool with JSON_SCHEMA_FOR_FUNC_DECL enabled."""

  @pytest.fixture(autouse=True)
  def enable_feature_flag(self):
    with temporary_feature_override(
        FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True
    ):
      yield

  def setup_method(self):
    self.mock_mcp_tool = MockMCPTool()
    self.mock_session_manager = Mock(spec=MCPSessionManager)
    self.mock_session = AsyncMock()
    self.mock_session_manager.create_session = AsyncMock(
        return_value=self.mock_session
    )

  def test_get_declaration_with_json_schema_for_func_decl_enabled(self):
    """Test function declaration generation with json schema for func decl enabled."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    with temporary_feature_override(
        FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True
    ):
      declaration = tool._get_declaration()

    assert isinstance(declaration, FunctionDeclaration)
    assert declaration.name == "test_tool"
    assert declaration.description == "Test tool description"
    assert declaration.parameters is None
    assert declaration.parameters_json_schema is not None
    assert declaration.response is None
    assert declaration.response_json_schema is None

  def test_get_declaration_with_output_schema_and_json_schema_for_func_decl_enabled(
      self,
  ):
    """Test function declaration generation with an output schema and json schema for func decl enabled."""
    output_schema = {
        "type": "object",
        "properties": {
            "status": {
                "type": "string",
                "description": "The status of the operation",
            },
        },
    }

    tool = MCPTool(
        mcp_tool=MockMCPTool(outputSchema=output_schema),
        mcp_session_manager=self.mock_session_manager,
    )

    with temporary_feature_override(
        FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True
    ):
      declaration = tool._get_declaration()

    assert isinstance(declaration, FunctionDeclaration)
    assert declaration.response is None
    assert declaration.response_json_schema == output_schema

  def test_get_declaration_with_empty_output_schema_and_json_schema_for_func_decl_enabled(
      self,
  ):
    """Test function declaration with an empty output schema and json schema for func decl enabled."""
    tool = MCPTool(
        mcp_tool=MockMCPTool(outputSchema={}),
        mcp_session_manager=self.mock_session_manager,
    )

    with temporary_feature_override(
        FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True
    ):
      declaration = tool._get_declaration()

    assert declaration.response is None
    assert not declaration.response_json_schema


class TestMCPTool:
  """Test suite for MCPTool class."""

  def setup_method(self):
    """Set up test fixtures."""
    self.mock_mcp_tool = MockMCPTool()
    self.mock_session_manager = Mock(spec=MCPSessionManager)
    self.mock_session = AsyncMock()
    self.mock_session_manager.create_session = AsyncMock(
        return_value=self.mock_session
    )

  def test_init_basic(self):
    """Test basic initialization without auth."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    assert tool.name == "test_tool"
    assert tool.description == "Test tool description"
    assert tool._mcp_tool == self.mock_mcp_tool
    assert tool._mcp_session_manager == self.mock_session_manager

  def test_init_with_auth(self):
    """Test initialization with authentication."""
    # Create real auth scheme instances instead of mocks
    from fastapi.openapi.models import OAuth2

    auth_scheme = OAuth2(flows={})
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2,
        oauth2=OAuth2Auth(client_id="test_id", client_secret="test_secret"),
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    # The auth config is stored in the parent class _credentials_manager
    assert tool._credentials_manager is not None
    assert tool._credentials_manager._auth_config.auth_scheme == auth_scheme
    assert (
        tool._credentials_manager._auth_config.raw_auth_credential
        == auth_credential
    )

  def test_init_with_empty_description(self):
    """Test initialization with empty description."""
    mock_tool = MockMCPTool(description=None)
    tool = MCPTool(
        mcp_tool=mock_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    assert tool.description == ""

  @pytest.mark.asyncio
  async def test_run_async_impl_no_auth(self):
    """Test running tool without authentication."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    # Mock the session response - must return CallToolResult
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    # Verify the result matches the model_dump output
    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    self.mock_session_manager.create_session.assert_called_once_with(
        headers=None
    )
    # Fix: call_tool uses 'arguments' parameter, not positional args
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool", arguments=args, progress_callback=None, meta=None
    )

  @pytest.mark.asyncio
  async def test_run_async_impl_adds_ui_widget(self):
    """Test running tool adds UiWidget to actions."""
    meta = {"ui": {"resourceUri": "ui://test-app"}}
    mock_tool = MockMCPTool(meta=meta)
    tool = MCPTool(
        mcp_tool=mock_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"
    args = {"param1": "test_value"}

    # tool_context.actions.render_ui_widgets is None initially
    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")

    assert tool_context.actions.render_ui_widgets is not None
    assert len(tool_context.actions.render_ui_widgets) == 1
    widget = tool_context.actions.render_ui_widgets[0]

    assert widget.id == "test-call-id"
    assert widget.provider == "mcp"
    assert widget.payload["resource_uri"] == "ui://test-app"
    assert widget.payload["tool"] == mock_tool
    assert widget.payload["tool_args"] == args

  @pytest.mark.asyncio
  async def test_run_async_impl_with_oauth2(self):
    """Test running tool with OAuth2 authentication."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    # Create OAuth2 credential
    oauth2_auth = OAuth2Auth(access_token="test_access_token")
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2, oauth2=oauth2_auth
    )

    # Mock the session response - must return CallToolResult
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=credential
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    # Check that headers were passed correctly
    self.mock_session_manager.create_session.assert_called_once()
    call_args = self.mock_session_manager.create_session.call_args
    headers = call_args[1]["headers"]
    assert headers == {"Authorization": "Bearer test_access_token"}

  @patch.object(mcp_tool, "propagate", autospec=True)
  @pytest.mark.asyncio
  async def test_run_async_impl_with_trace_context(self, mock_propagate):
    """Test running tool with trace context injection."""
    mock_propagator = Mock()

    def inject_context(carrier, context=None) -> None:
      carrier["traceparent"] = (
          "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01"
      )
      carrier["tracestate"] = "foo=bar"
      carrier["baggage"] = "baz=qux"

    mock_propagator.inject.side_effect = inject_context
    mock_propagate.get_global_textmap.return_value = mock_propagator

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    self.mock_session_manager.create_session.assert_called_once_with(
        headers=None
    )
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool",
        arguments=args,
        progress_callback=None,
        meta={
            "traceparent": (
                "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01"
            ),
            "tracestate": "foo=bar",
            "baggage": "baz=qux",
        },
    )

  @pytest.mark.asyncio
  async def test_get_headers_oauth2(self):
    """Test header generation for OAuth2 credentials."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    oauth2_auth = OAuth2Auth(access_token="test_token")
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2, oauth2=oauth2_auth
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, credential)

    assert headers == {"Authorization": "Bearer test_token"}

  @pytest.mark.asyncio
  async def test_get_headers_http_bearer(self):
    """Test header generation for HTTP Bearer credentials."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    http_auth = HttpAuth(
        scheme="bearer", credentials=HttpCredentials(token="bearer_token")
    )
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.HTTP, http=http_auth
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, credential)

    assert headers == {"Authorization": "Bearer bearer_token"}

  @pytest.mark.asyncio
  async def test_get_headers_http_basic(self):
    """Test header generation for HTTP Basic credentials."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    http_auth = HttpAuth(
        scheme="basic",
        credentials=HttpCredentials(username="user", password="pass"),
    )
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.HTTP, http=http_auth
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, credential)

    # Should create Basic auth header with base64 encoded credentials
    import base64

    expected_encoded = base64.b64encode(b"user:pass").decode()
    assert headers == {"Authorization": f"Basic {expected_encoded}"}

  @pytest.mark.asyncio
  @pytest.mark.parametrize(
      "token, expected_headers",
      [
          (
              "some-token",
              {
                  "Authorization": "some-scheme some-token",
                  "X-Custom-Header": "custom-value",
              },
          ),
          (
              None,
              {"X-Custom-Header": "custom-value"},
          ),
      ],
  )
  async def test_get_headers_http_adds_additional_headers(
      self, token, expected_headers
  ):
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )
    http_auth = HttpAuth(
        scheme="some-scheme",
        credentials=HttpCredentials(token=token),
        additional_headers={"X-Custom-Header": "custom-value"},
    )
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.HTTP, http=http_auth
    )

    tool_context = create_autospec(ToolContext, instance=True)
    headers = await tool._get_headers(tool_context, credential)

    assert headers == expected_headers

  @pytest.mark.asyncio
  async def test_get_headers_api_key_with_valid_header_scheme(self):
    """Test header generation for API Key credentials with header-based auth scheme."""
    from fastapi.openapi.models import APIKey
    from fastapi.openapi.models import APIKeyIn
    from google.adk.auth.auth_schemes import AuthSchemeType

    # Create auth scheme for header-based API key
    auth_scheme = APIKey(**{
        "type": AuthSchemeType.apiKey,
        "in": APIKeyIn.header,
        "name": "X-Custom-API-Key",
    })
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, auth_credential)

    assert headers == {"X-Custom-API-Key": "my_api_key"}

  @pytest.mark.asyncio
  async def test_get_headers_api_key_with_query_scheme_raises_error(self):
    """Test that API Key with query-based auth scheme raises ValueError."""
    from fastapi.openapi.models import APIKey
    from fastapi.openapi.models import APIKeyIn
    from google.adk.auth.auth_schemes import AuthSchemeType

    # Create auth scheme for query-based API key (not supported)
    auth_scheme = APIKey(**{
        "type": AuthSchemeType.apiKey,
        "in": APIKeyIn.query,
        "name": "api_key",
    })
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    tool_context = Mock(spec=ToolContext)

    with pytest.raises(
        ValueError,
        match="McpTool only supports header-based API key authentication",
    ):
      await tool._get_headers(tool_context, auth_credential)

  @pytest.mark.asyncio
  async def test_get_headers_api_key_with_cookie_scheme_raises_error(self):
    """Test that API Key with cookie-based auth scheme raises ValueError."""
    from fastapi.openapi.models import APIKey
    from fastapi.openapi.models import APIKeyIn
    from google.adk.auth.auth_schemes import AuthSchemeType

    # Create auth scheme for cookie-based API key (not supported)
    auth_scheme = APIKey(**{
        "type": AuthSchemeType.apiKey,
        "in": APIKeyIn.cookie,
        "name": "session_id",
    })
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    tool_context = Mock(spec=ToolContext)

    with pytest.raises(
        ValueError,
        match="McpTool only supports header-based API key authentication",
    ):
      await tool._get_headers(tool_context, auth_credential)

  @pytest.mark.asyncio
  async def test_get_headers_api_key_without_auth_config_raises_error(self):
    """Test that API Key without auth config raises ValueError."""
    # Create tool without auth scheme/config
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )
    tool_context = Mock(spec=ToolContext)

    with pytest.raises(
        ValueError,
        match="Cannot find corresponding auth scheme for API key credential",
    ):
      await tool._get_headers(tool_context, credential)

  @pytest.mark.asyncio
  async def test_get_headers_api_key_without_credentials_manager_raises_error(
      self,
  ):
    """Test that API Key without credentials manager raises ValueError."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    # Manually set credentials manager to None to simulate error condition
    tool._credentials_manager = None

    credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )
    tool_context = Mock(spec=ToolContext)

    with pytest.raises(
        ValueError,
        match="Cannot find corresponding auth scheme for API key credential",
    ):
      await tool._get_headers(tool_context, credential)

  @pytest.mark.asyncio
  async def test_get_headers_no_credential(self):
    """Test header generation with no credentials."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, None)

    assert headers is None

  @pytest.mark.asyncio
  async def test_get_headers_service_account(self):
    """Test header generation for service account credentials."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    # Create service account credential
    service_account = ServiceAccount(
        scopes=["test"], use_default_credential=True
    )
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
        service_account=service_account,
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, credential)

    # Should return None as service account credentials are not supported for direct header generation
    assert headers is None

  @pytest.mark.asyncio
  async def test_run_async_impl_with_api_key_header_auth(self):
    """Test running tool with API key header authentication end-to-end."""
    from fastapi.openapi.models import APIKey
    from fastapi.openapi.models import APIKeyIn
    from google.adk.auth.auth_schemes import AuthSchemeType

    # Create auth scheme for header-based API key
    auth_scheme = APIKey(**{
        "type": AuthSchemeType.apiKey,
        "in": APIKeyIn.header,
        "name": "X-Service-API-Key",
    })
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="test_service_key"
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    # Mock the session response - must return CallToolResult
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="authenticated_success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=auth_credential
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    # Check that headers were passed correctly with custom API key header
    self.mock_session_manager.create_session.assert_called_once()
    call_args = self.mock_session_manager.create_session.call_args
    headers = call_args[1]["headers"]
    assert headers == {"X-Service-API-Key": "test_service_key"}

  @pytest.mark.asyncio
  async def test_run_async_impl_retry_decorator(self):
    """Test that the retry decorator is applied correctly."""
    # This is more of an integration test to ensure the decorator is present
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    # Check that the method has the retry decorator
    assert hasattr(tool._run_async_impl, "__wrapped__")

  @pytest.mark.asyncio
  async def test_get_headers_http_custom_scheme(self):
    """Test header generation for custom HTTP scheme."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    http_auth = HttpAuth(
        scheme="custom", credentials=HttpCredentials(token="custom_token")
    )
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.HTTP, http=http_auth
    )

    tool_context = Mock(spec=ToolContext)
    headers = await tool._get_headers(tool_context, credential)

    assert headers == {"Authorization": "custom custom_token"}

  @pytest.mark.asyncio
  async def test_get_headers_api_key_error_logging(self):
    """Test that API key errors are logged correctly."""
    from fastapi.openapi.models import APIKey
    from fastapi.openapi.models import APIKeyIn
    from google.adk.auth.auth_schemes import AuthSchemeType

    # Create auth scheme for query-based API key (not supported)
    auth_scheme = APIKey(**{
        "type": AuthSchemeType.apiKey,
        "in": APIKeyIn.query,
        "name": "api_key",
    })
    auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
    )

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        auth_scheme=auth_scheme,
        auth_credential=auth_credential,
    )

    tool_context = Mock(spec=ToolContext)

    # Test with logging
    with patch("google.adk.tools.mcp_tool.mcp_tool.logger") as mock_logger:
      with pytest.raises(ValueError):
        await tool._get_headers(tool_context, auth_credential)

      # Verify error was logged
      mock_logger.error.assert_called_once()
      logged_message = mock_logger.error.call_args[0][0]
      assert (
          "McpTool only supports header-based API key authentication"
          in logged_message
      )

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_true_no_confirmation(self):
    """Test require_confirmation=True with no confirmation in context."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=True,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = None
    tool_context.request_confirmation = Mock()
    args = {"param1": "test_value"}

    result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {
        "error": (
            "This tool call requires confirmation, please approve or reject."
        )
    }
    tool_context.request_confirmation.assert_called_once()

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_true_rejected(self):
    """Test require_confirmation=True with rejection in context."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=True,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = Mock(confirmed=False)
    args = {"param1": "test_value"}

    result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {"error": "This tool call is rejected."}

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_true_confirmed(self):
    """Test require_confirmation=True with confirmation in context."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=True,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = Mock(confirmed=True)
    args = {"param1": "test_value"}

    with patch(
        "google.adk.tools.base_authenticated_tool.BaseAuthenticatedTool.run_async",
        new_callable=AsyncMock,
    ) as mock_super_run_async:
      await tool.run_async(args=args, tool_context=tool_context)
      mock_super_run_async.assert_called_once_with(
          args=args, tool_context=tool_context
      )

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_callable_with_arg_filtering(
      self,
  ):
    """Test require_confirmation=callable with argument filtering."""

    async def _require_confirmation_func(
        param1: str, tool_context: ToolContext
    ):
      return True

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=_require_confirmation_func,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = None
    tool_context.request_confirmation = Mock()
    args = {"param1": "test_value", "extra_arg": 123}

    with patch.object(
        tool, "_invoke_callable", new_callable=AsyncMock
    ) as mock_invoke_callable:
      mock_invoke_callable.return_value = (
          True  # Mock the return of require_confirmation
      )

      result = await tool.run_async(args=args, tool_context=tool_context)
      expected_args_to_call = {
          "param1": "test_value",
          "tool_context": tool_context,
      }
      mock_invoke_callable.assert_called_once_with(
          _require_confirmation_func, expected_args_to_call
      )

      assert result == {
          "error": (
              "This tool call requires confirmation, please approve or reject."
          )
      }
      tool_context.request_confirmation.assert_called_once()

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_callable_true_no_confirmation(
      self,
  ):
    """Test require_confirmation=callable with no confirmation in context."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=lambda **kwargs: True,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = None
    tool_context.request_confirmation = Mock()
    args = {"param1": "test_value"}

    result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {
        "error": (
            "This tool call requires confirmation, please approve or reject."
        )
    }
    tool_context.request_confirmation.assert_called_once()

  def test_init_validation(self):
    """Test that initialization validates required parameters."""
    # This test ensures that the MCPTool properly handles its dependencies
    with pytest.raises(TypeError):
      MCPTool()  # Missing required parameters

    with pytest.raises(TypeError):
      MCPTool(mcp_tool=self.mock_mcp_tool)  # Missing session manager

  @pytest.mark.asyncio
  async def test_run_async_impl_with_header_provider_no_auth(self):
    """Test running tool with header_provider but no auth."""
    expected_headers = {"X-Tenant-ID": "test-tenant"}
    header_provider = Mock(return_value=expected_headers)
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        header_provider=header_provider,
    )

    # Mock the session response - must return CallToolResult
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    tool_context._invocation_context = Mock()
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    header_provider.assert_called_once()
    self.mock_session_manager.create_session.assert_called_once_with(
        headers=expected_headers
    )
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool", arguments=args, progress_callback=None, meta=None
    )

  @pytest.mark.asyncio
  async def test_run_async_impl_with_async_header_provider_no_auth(self):
    """Test running tool with an async header_provider and no authentication."""
    expected_headers = {"X-Tenant-ID": "test-tenant"}

    async def header_provider(_context):
      return expected_headers

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        header_provider=header_provider,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="response text")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    tool_context._invocation_context = Mock()
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    self.mock_session_manager.create_session.assert_called_once_with(
        headers=expected_headers
    )
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool", arguments=args, progress_callback=None, meta=None
    )

  @pytest.mark.asyncio
  async def test_run_async_impl_with_header_provider_and_oauth2(self):
    """Test running tool with header_provider and OAuth2 auth."""
    dynamic_headers = {"X-Tenant-ID": "test-tenant"}
    header_provider = Mock(return_value=dynamic_headers)
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        header_provider=header_provider,
    )

    oauth2_auth = OAuth2Auth(access_token="test_access_token")
    credential = AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2, oauth2=oauth2_auth
    )

    # Mock the session response - must return CallToolResult
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    tool_context._invocation_context = Mock()
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=credential
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    header_provider.assert_called_once()
    self.mock_session_manager.create_session.assert_called_once()
    call_args = self.mock_session_manager.create_session.call_args
    headers = call_args[1]["headers"]
    assert headers == {
        "Authorization": "Bearer test_access_token",
        "X-Tenant-ID": "test-tenant",
    }
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool", arguments=args, progress_callback=None, meta=None
    )

  def test_init_with_progress_callback(self):
    """Test initialization with progress_callback."""

    async def my_progress_callback(
        progress: float, total: float | None, message: str | None
    ) -> None:
      pass

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        progress_callback=my_progress_callback,
    )

    assert tool._progress_callback == my_progress_callback

  @pytest.mark.asyncio
  async def test_run_async_impl_with_progress_callback(self):
    """Test running tool with progress_callback."""
    progress_updates = []

    async def my_progress_callback(
        progress: float, total: float | None, message: str | None
    ) -> None:
      progress_updates.append((progress, total, message))

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        progress_callback=my_progress_callback,
    )

    # Mock the session response
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    result = await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    self.mock_session_manager.create_session.assert_called_once_with(
        headers=None
    )
    # Verify progress_callback was passed to call_tool
    self.mock_session.call_tool.assert_called_once_with(
        "test_tool",
        arguments=args,
        progress_callback=my_progress_callback,
        meta=None,
    )

  @pytest.mark.asyncio
  async def test_run_async_impl_with_progress_callback_factory(self):
    """Test running tool with progress_callback factory that receives context."""
    factory_calls = []

    def my_callback_factory(tool_name: str, *, callback_context=None, **kwargs):
      factory_calls.append((tool_name, callback_context))

      async def callback(
          progress: float, total: float | None, message: str | None
      ) -> None:
        pass

      return callback

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        progress_callback=my_callback_factory,
    )

    # Mock the session response
    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    await tool._run_async_impl(
        args=args, tool_context=tool_context, credential=None
    )

    # Verify factory was called with tool name and tool_context as callback_context
    assert len(factory_calls) == 1
    assert factory_calls[0][0] == "test_tool"
    # callback_context is the tool_context itself (ToolContext extends CallbackContext)
    assert factory_calls[0][1] is tool_context

  @pytest.mark.asyncio
  async def test_run_async_require_confirmation_callable_with_context_type(
      self,
  ):
    """Test require_confirmation callable with Context type annotation."""

    async def _require_confirmation_func(param1: str, ctx: Context):
      return True

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
        require_confirmation=_require_confirmation_func,
    )
    tool_context = Mock(spec=ToolContext)
    tool_context.tool_confirmation = None
    tool_context.request_confirmation = Mock()
    args = {"param1": "test_value", "extra_arg": 123}

    with patch.object(
        tool, "_invoke_callable", new_callable=AsyncMock
    ) as mock_invoke_callable:
      mock_invoke_callable.return_value = True

      result = await tool.run_async(args=args, tool_context=tool_context)

      # Verify context is passed with detected parameter name 'ctx'
      expected_args_to_call = {
          "param1": "test_value",
          "ctx": tool_context,
      }
      mock_invoke_callable.assert_called_once_with(
          _require_confirmation_func, expected_args_to_call
      )

      assert result == {
          "error": (
              "This tool call requires confirmation, please approve or reject."
          )
      }
      tool_context.request_confirmation.assert_called_once()

  def test_visibility_property(self):
    """Test visibility property extraction from meta."""
    meta = {"ui": {"visibility": ["app", "debug"]}}
    mock_tool = MockMCPTool(meta=meta)
    tool = MCPTool(
        mcp_tool=mock_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    assert tool.visibility == ["app", "debug"]

  def test_visibility_property_empty(self):
    """Test visibility property when meta is missing or malformed."""
    # Missing meta
    tool1 = MCPTool(
        mcp_tool=MockMCPTool(meta=None),
        mcp_session_manager=self.mock_session_manager,
    )
    assert tool1.visibility == []

    # Malformed meta
    tool2 = MCPTool(
        mcp_tool=MockMCPTool(meta="not a dict"),
        mcp_session_manager=self.mock_session_manager,
    )
    assert tool2.visibility == []

    # Missing ui field
    tool3 = MCPTool(
        mcp_tool=MockMCPTool(meta={}),
        mcp_session_manager=self.mock_session_manager,
    )
    assert tool3.visibility == []

  def test_mcp_app_resource_uri_property_nested(self):
    """Test MCP App resource URI extraction from nested meta format."""
    meta = {"ui": {"resourceUri": "ui://test-resource"}}
    mock_tool = MockMCPTool(meta=meta)
    tool = MCPTool(
        mcp_tool=mock_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    assert tool.mcp_app_resource_uri == "ui://test-resource"

  def test_mcp_app_resource_uri_property_flat(self):
    """Test MCP App resource URI extraction from flat meta format."""
    meta = {"ui/resourceUri": "ui://test-resource-flat"}
    mock_tool = MockMCPTool(meta=meta)
    tool = MCPTool(
        mcp_tool=mock_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    assert tool.mcp_app_resource_uri == "ui://test-resource-flat"

  def test_mcp_app_resource_uri_property_none(self):
    """Test MCP App resource URI when missing or invalid."""
    # Missing meta
    tool1 = MCPTool(
        mcp_tool=MockMCPTool(meta=None),
        mcp_session_manager=self.mock_session_manager,
    )
    assert tool1.mcp_app_resource_uri is None

    # Invalid scheme
    meta = {"ui": {"resourceUri": "http://invalid"}}
    tool2 = MCPTool(
        mcp_tool=MockMCPTool(meta=meta),
        mcp_session_manager=self.mock_session_manager,
    )
    assert tool2.mcp_app_resource_uri is None

  @pytest.mark.asyncio
  @patch(
      "google.adk.tools.mcp_tool.mcp_tool.logger.isEnabledFor",
      return_value=True,
  )
  async def test_run_async_captures_http_debug_info(self, mock_is_enabled):
    """Test that run_async captures HTTP debug info into context.custom_metadata."""
    from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )

    async def mock_call_tool(*args, **kwargs):
      debug_list = _http_debug_var.get(None)
      if debug_list is not None:
        debug_list.append(
            {"url": "https://example.com/api", "status_code": 200}
        )
      return mcp_response

    self.mock_session.call_tool = mock_call_tool

    tool_context = Mock(spec=ToolContext)
    metadata_dict = {}
    tool_context.custom_metadata = metadata_dict

    args = {"param1": "test_value"}

    result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")

    assert "http_debug_info" in metadata_dict
    debug_info = metadata_dict["http_debug_info"]
    assert len(debug_info) == 1
    assert debug_info[0]["url"] == "https://example.com/api"
    assert debug_info[0]["status_code"] == 200

  @pytest.mark.asyncio
  @patch(
      "google.adk.tools.mcp_tool.mcp_tool.logger.isEnabledFor",
      return_value=False,
  )
  async def test_run_async_skips_http_debug_info_when_debug_disabled(
      self, mock_is_enabled
  ):
    """Test that run_async does not capture HTTP debug info when debug logging is disabled."""
    from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )

    async def mock_call_tool(*args, **kwargs):
      debug_list = _http_debug_var.get(None)
      if debug_list is not None:
        debug_list.append(
            {"url": "https://example.com/api", "status_code": 200}
        )
      return mcp_response

    self.mock_session.call_tool = mock_call_tool

    tool_context = Mock(spec=ToolContext)
    metadata_dict = {}
    tool_context.custom_metadata = metadata_dict

    args = {"param1": "test_value"}

    result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    assert "http_debug_info" not in metadata_dict

  @pytest.mark.asyncio
  @patch(
      "google.adk.tools.mcp_tool.mcp_tool.logger.isEnabledFor",
      return_value=True,
  )
  async def test_run_async_captures_http_debug_info_on_error(
      self, mock_is_enabled
  ):
    """Test that run_async captures HTTP debug info even when the tool call fails/raises."""
    from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    async def mock_call_tool(*args, **kwargs):
      debug_list = _http_debug_var.get(None)
      if debug_list is not None:
        debug_list.append(
            {"url": "https://example.com/api", "status_code": 500}
        )
      raise RuntimeError("Tool execution failed")

    self.mock_session.call_tool = mock_call_tool

    tool_context = Mock(spec=ToolContext)
    metadata_dict = {}
    tool_context.custom_metadata = metadata_dict

    args = {"param1": "test_value"}

    with pytest.raises(RuntimeError, match="Tool execution failed"):
      # Under flag=False, the error bubbles up
      with temporary_feature_override(
          FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
      ):
        await tool.run_async(args=args, tool_context=tool_context)

    assert "http_debug_info" in metadata_dict
    debug_info = metadata_dict["http_debug_info"]
    # Retries once on error, so we expect 2 debug entries
    assert len(debug_info) == 2
    assert debug_info[0]["url"] == "https://example.com/api"
    assert debug_info[0]["status_code"] == 500
    assert debug_info[1]["url"] == "https://example.com/api"
    assert debug_info[1]["status_code"] == 500

  @pytest.mark.asyncio
  @patch(
      "google.adk.tools.mcp_tool.mcp_tool.logger.isEnabledFor",
      return_value=True,
  )
  async def test_run_async_captures_http_debug_info_on_graceful_error(
      self, mock_is_enabled
  ):
    """Test that run_async captures HTTP debug info when tool call fails gracefully with McpError."""
    from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var
    from mcp.shared.exceptions import McpError
    from mcp.types import ErrorData

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    async def mock_call_tool(*args, **kwargs):
      debug_list = _http_debug_var.get(None)
      if debug_list is not None:
        debug_list.append(
            {"url": "https://example.com/api", "status_code": 403}
        )
      raise McpError(ErrorData(code=-32000, message="Forbidden"))

    self.mock_session.call_tool = mock_call_tool

    tool_context = Mock(spec=ToolContext)
    metadata_dict = {}
    tool_context.custom_metadata = metadata_dict

    args = {"param1": "test_value"}

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {"error": "MCP tool execution failed: Forbidden"}
    assert "http_debug_info" in metadata_dict
    debug_info = metadata_dict["http_debug_info"]
    # Retries once on error, so we expect 2 debug entries
    assert len(debug_info) == 2
    assert debug_info[0]["url"] == "https://example.com/api"
    assert debug_info[0]["status_code"] == 403
    assert debug_info[1]["url"] == "https://example.com/api"
    assert debug_info[1]["status_code"] == 403


class TestMCPToolGracefulErrorHandling:
  """Tests for the _MCP_GRACEFUL_ERROR_HANDLING feature flag.

  These cover the behavior added by the re-landed fix for the 5-minute
  hang when an MCP tool returns a JSON-RPC error or its underlying
  transport crashes (e.g. AGW + Model Armor 403).
  """

  def setup_method(self):
    """Set up test fixtures."""
    self.mock_mcp_tool = MockMCPTool()
    self.mock_session_manager = Mock(spec=MCPSessionManager)
    self.mock_session = AsyncMock()
    self.mock_session_manager.create_session = AsyncMock(
        return_value=self.mock_session
    )
    # By default, no real SessionContext available — falls back to direct await.
    self.mock_session_manager._get_session_context = Mock(return_value=None)

  @pytest.mark.asyncio
  async def test_run_async_returns_dict_on_mcp_error_when_flag_on(self):
    """When the flag is on, McpError surfaces as `{"error": "..."}`."""
    from mcp.shared.exceptions import McpError
    from mcp.types import ErrorData

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    error_data = ErrorData(code=-32000, message="Client error '403 Forbidden'")
    tool._run_async_impl = AsyncMock(side_effect=McpError(error_data))

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {
        "error": "MCP tool execution failed: Client error '403 Forbidden'"
    }

  @pytest.mark.asyncio
  async def test_run_async_returns_dict_on_generic_exception_when_flag_on(
      self,
  ):
    """When the flag is on, unexpected exceptions become `{"error": "..."}`."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    tool._run_async_impl = AsyncMock(
        side_effect=ConnectionError("Failed to create MCP session")
    )

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool.run_async(args=args, tool_context=tool_context)

    assert result == {
        "error": (
            "Unexpected error during MCP tool execution: Failed to create"
            " MCP session"
        )
    }

  @pytest.mark.asyncio
  async def test_run_async_propagates_mcp_error_when_flag_off(self):
    """Regression guard: with the flag off, exceptions still bubble up.

    This protects downstream consumers that haven't migrated yet from a
    silent behavior change.
    """
    from mcp.shared.exceptions import McpError
    from mcp.types import ErrorData

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    error_data = ErrorData(code=-32000, message="Client error '403 Forbidden'")
    tool._run_async_impl = AsyncMock(side_effect=McpError(error_data))

    tool_context = Mock(spec=ToolContext)
    args = {"param1": "test_value"}

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
    ):
      with pytest.raises(McpError):
        await tool.run_async(args=args, tool_context=tool_context)

  @pytest.mark.asyncio
  async def test_run_async_impl_uses_run_guarded_when_session_context_present(
      self,
  ):
    """When _get_session_context returns a real SessionContext, use it.

    This is what protects against the 5-minute hang on 403: `_run_guarded`
    races the tool call against the background session task.
    """
    import asyncio

    from google.adk.tools.mcp_tool.session_context import SessionContext

    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = Mock(
        return_value=AsyncMock(return_value=mcp_response)()
    )

    # Real SessionContext stub: subclass to override _run_guarded so we
    # don't need a live MCP server, but keep isinstance(SessionContext) True.
    class StubSessionContext(SessionContext):

      def __init__(self):
        # Skip the parent __init__ — we don't need the underlying client.
        self._run_guarded_called_with: list = []

      async def _run_guarded(self, coro):
        self._run_guarded_called_with.append(coro)
        return await coro

    stub = StubSessionContext()
    self.mock_session_manager._get_session_context = Mock(return_value=stub)

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool._run_async_impl(
          args={"param1": "x"}, tool_context=tool_context, credential=None
      )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    assert len(stub._run_guarded_called_with) == 1
    # Verify the coro passed in was actually a coroutine (not a Mock).
    assert asyncio.iscoroutine(stub._run_guarded_called_with[0])

  @pytest.mark.asyncio
  async def test_run_async_impl_falls_back_when_get_session_context_returns_none(
      self,
  ):
    """If the session manager returns None, do a direct await (no _run_guarded).

    Prevents AttributeError-style failures for callers that don't use
    SessionContext (or for legacy session managers).
    """
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)
    self.mock_session_manager._get_session_context = Mock(return_value=None)

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool._run_async_impl(
          args={"param1": "x"}, tool_context=tool_context, credential=None
      )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")

  @pytest.mark.asyncio
  async def test_run_async_impl_falls_back_when_get_session_context_returns_mock(
      self,
  ):
    """Backward-compat guard: a Mock from _get_session_context falls back too.

    Many existing tests use Mock(spec=MCPSessionManager) which auto-returns
    Mock() objects from any attribute access. Without the isinstance check,
    we'd try to await a Mock and explode.
    """
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)
    # Auto-return a Mock instead of None (default Mock() behavior).
    self.mock_session_manager._get_session_context = Mock(return_value=Mock())

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
    ):
      result = await tool._run_async_impl(
          args={"param1": "x"}, tool_context=tool_context, credential=None
      )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")

  @pytest.mark.asyncio
  async def test_run_async_impl_skips_run_guarded_when_flag_off(self):
    """When the flag is off, the SessionContext is never consulted."""
    tool = MCPTool(
        mcp_tool=self.mock_mcp_tool,
        mcp_session_manager=self.mock_session_manager,
    )

    mcp_response = CallToolResult(
        content=[TextContent(type="text", text="success")]
    )
    self.mock_session.call_tool = AsyncMock(return_value=mcp_response)

    # Set up a tracker — should NEVER be called when the flag is off.
    self.mock_session_manager._get_session_context = Mock(return_value=None)

    tool_context = ToolContext(invocation_context=Mock())
    tool_context.function_call_id = "test-call-id"

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
    ):
      result = await tool._run_async_impl(
          args={"param1": "x"}, tool_context=tool_context, credential=None
      )

    assert result == mcp_response.model_dump(exclude_none=True, mode="json")
    self.mock_session_manager._get_session_context.assert_not_called()
