mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
fix: Support API-Key for MCP Tool authentication
PiperOrigin-RevId: 776641474
This commit is contained in:
committed by
Copybara-Service
parent
20279d9a50
commit
045aea9b15
@@ -15,12 +15,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi.openapi.models import APIKeyIn
|
||||||
from google.genai.types import FunctionDeclaration
|
from google.genai.types import FunctionDeclaration
|
||||||
from google.oauth2.credentials import Credentials
|
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
from .._gemini_schema_util import _to_gemini_schema
|
from .._gemini_schema_util import _to_gemini_schema
|
||||||
@@ -58,6 +57,9 @@ class MCPTool(BaseAuthenticatedTool):
|
|||||||
|
|
||||||
Internally, the tool initializes from a MCP Tool, and uses the MCP Session to
|
Internally, the tool initializes from a MCP Tool, and uses the MCP Session to
|
||||||
call the tool.
|
call the tool.
|
||||||
|
|
||||||
|
Note: For API key authentication, only header-based API keys are supported.
|
||||||
|
Query and cookie-based API keys will result in authentication errors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -134,7 +136,19 @@ class MCPTool(BaseAuthenticatedTool):
|
|||||||
async def _get_headers(
|
async def _get_headers(
|
||||||
self, tool_context: ToolContext, credential: AuthCredential
|
self, tool_context: ToolContext, credential: AuthCredential
|
||||||
) -> Optional[dict[str, str]]:
|
) -> Optional[dict[str, str]]:
|
||||||
headers = None
|
"""Extracts authentication headers from credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_context: The tool context of the current invocation.
|
||||||
|
credential: The authentication credential to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of headers to add to the request, or None if no auth.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If API key authentication is configured for non-header location.
|
||||||
|
"""
|
||||||
|
headers: Optional[dict[str, str]] = None
|
||||||
if credential:
|
if credential:
|
||||||
if credential.oauth2:
|
if credential.oauth2:
|
||||||
headers = {"Authorization": f"Bearer {credential.oauth2.access_token}"}
|
headers = {"Authorization": f"Bearer {credential.oauth2.access_token}"}
|
||||||
@@ -167,10 +181,33 @@ class MCPTool(BaseAuthenticatedTool):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
elif credential.api_key:
|
elif credential.api_key:
|
||||||
# For API keys, we'll add them as headers since MCP typically uses header-based auth
|
if (
|
||||||
# The specific header name would depend on the API, using a common default
|
not self._credentials_manager
|
||||||
# TODO Allow user to specify the header name for API keys.
|
or not self._credentials_manager._auth_config
|
||||||
headers = {"X-API-Key": credential.api_key}
|
):
|
||||||
|
error_msg = (
|
||||||
|
"Cannot find corresponding auth scheme for API key credential"
|
||||||
|
f" {credential}"
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
elif (
|
||||||
|
self._credentials_manager._auth_config.auth_scheme.in_
|
||||||
|
!= APIKeyIn.header
|
||||||
|
):
|
||||||
|
error_msg = (
|
||||||
|
"MCPTool only supports header-based API key authentication."
|
||||||
|
" Configured location:"
|
||||||
|
f" {self._credentials_manager._auth_config.auth_scheme.in_}"
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
else:
|
||||||
|
headers = {
|
||||||
|
self._credentials_manager._auth_config.auth_scheme.name: (
|
||||||
|
credential.api_key
|
||||||
|
)
|
||||||
|
}
|
||||||
elif credential.service_account:
|
elif credential.service_account:
|
||||||
# Service accounts should be exchanged for access tokens before reaching this point
|
# Service accounts should be exchanged for access tokens before reaching this point
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
|
||||||
from typing import Dict
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -268,8 +266,102 @@ class TestMCPTool:
|
|||||||
assert headers == {"Authorization": f"Basic {expected_encoded}"}
|
assert headers == {"Authorization": f"Basic {expected_encoded}"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_headers_api_key(self):
|
async def test_get_headers_api_key_with_valid_header_scheme(self):
|
||||||
"""Test header generation for API Key credentials."""
|
"""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(
|
tool = MCPTool(
|
||||||
mcp_tool=self.mock_mcp_tool,
|
mcp_tool=self.mock_mcp_tool,
|
||||||
mcp_session_manager=self.mock_session_manager,
|
mcp_session_manager=self.mock_session_manager,
|
||||||
@@ -278,11 +370,37 @@ class TestMCPTool:
|
|||||||
credential = AuthCredential(
|
credential = AuthCredential(
|
||||||
auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
|
auth_type=AuthCredentialTypes.API_KEY, api_key="my_api_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_context = Mock(spec=ToolContext)
|
tool_context = Mock(spec=ToolContext)
|
||||||
headers = await tool._get_headers(tool_context, credential)
|
|
||||||
|
|
||||||
assert headers == {"X-API-Key": "my_api_key"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_headers_no_credential(self):
|
async def test_get_headers_no_credential(self):
|
||||||
@@ -318,6 +436,48 @@ class TestMCPTool:
|
|||||||
# Should return None as service account credentials are not supported for direct header generation
|
# Should return None as service account credentials are not supported for direct header generation
|
||||||
assert headers is None
|
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
|
||||||
|
expected_response = {"result": "authenticated_success"}
|
||||||
|
self.mock_session.call_tool = AsyncMock(return_value=expected_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 == expected_response
|
||||||
|
# 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
|
@pytest.mark.asyncio
|
||||||
async def test_run_async_impl_retry_decorator(self):
|
async def test_run_async_impl_retry_decorator(self):
|
||||||
"""Test that the retry decorator is applied correctly."""
|
"""Test that the retry decorator is applied correctly."""
|
||||||
@@ -350,6 +510,45 @@ class TestMCPTool:
|
|||||||
|
|
||||||
assert headers == {"Authorization": "custom custom_token"}
|
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
|
||||||
|
)
|
||||||
|
|
||||||
def test_init_validation(self):
|
def test_init_validation(self):
|
||||||
"""Test that initialization validates required parameters."""
|
"""Test that initialization validates required parameters."""
|
||||||
# This test ensures that the MCPTool properly handles its dependencies
|
# This test ensures that the MCPTool properly handles its dependencies
|
||||||
|
|||||||
Reference in New Issue
Block a user