Skip to content

Commit e8526f7

Browse files
wukathcopybara-github
authored andcommitted
fix: Fix credential manager so that it supports the ServiceAccountCredentialExchanger
This fixes MCP authentication for gcloud service accounts. Previously it was failing to authenticate tool calls. Co-authored-by: Kathy Wu <[email protected]> PiperOrigin-RevId: 826639044
1 parent a0df75b commit e8526f7

File tree

5 files changed

+178
-24
lines changed

5 files changed

+178
-24
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# MCP Service Account Agent Sample
2+
3+
This agent demonstrates how to connect to a remote MCP server using a gcloud service account for authentication. It uses Streamable HTTP for communication.
4+
5+
## Setup
6+
7+
Before running the agent, you need to configure the MCP server URL and your service account credentials in `agent.py`.
8+
9+
1. **Configure MCP Server URL:**
10+
Update the `MCP_SERVER_URL` variable with the URL of your MCP server instance.
11+
12+
```python
13+
# agent.py
14+
# TODO: Update this to the production MCP server url and scopes.
15+
MCP_SERVER_URL = "https://test.sandbox.googleapis.com/mcp"
16+
```
17+
18+
2. **Set up Service Account Credentials:**
19+
- Obtain the JSON key file for your gcloud service account.
20+
- In `agent.py`, find the `ServiceAccountCredential` object and populate its parameters (e.g., `project_id`, `private_key`, `client_email`, etc.) with the corresponding values from your JSON key file.
21+
22+
```python
23+
# agent.py
24+
# TODO: Update this to the user's service account credentials.
25+
auth_credential=AuthCredential(
26+
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
27+
service_account=ServiceAccount(
28+
service_account_credential=ServiceAccountCredential(
29+
type_="service_account",
30+
project_id="example",
31+
private_key_id="123",
32+
private_key="123",
33+
client_email="[email protected]",
34+
client_id="123",
35+
auth_uri="https://accounts.google.com/o/oauth2/auth",
36+
token_uri="https://oauth2.googleapis.com/token",
37+
auth_provider_x509_cert_url=(
38+
"https://www.googleapis.com/oauth2/v1/certs"
39+
),
40+
client_x509_cert_url="https://www.googleapis.com/robot/v1/metadata/x509/example.iam.gserviceaccount.com",
41+
universe_domain="googleapis.com",
42+
),
43+
scopes=SCOPES.keys(),
44+
),
45+
),
46+
```
47+
48+
## Running the Agent
49+
50+
Once configured, you can run the agent.
51+
52+
For example:
53+
```bash
54+
adk web
55+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from . import agent
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
from fastapi.openapi.models import OAuth2
17+
from fastapi.openapi.models import OAuthFlowClientCredentials
18+
from fastapi.openapi.models import OAuthFlows
19+
from google.adk.agents.llm_agent import LlmAgent
20+
from google.adk.auth.auth_credential import AuthCredential
21+
from google.adk.auth.auth_credential import AuthCredentialTypes
22+
from google.adk.auth.auth_credential import ServiceAccount
23+
from google.adk.auth.auth_credential import ServiceAccountCredential
24+
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPServerParams
25+
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
26+
27+
# TODO: Update this to the production MCP server url and scopes.
28+
MCP_SERVER_URL = "https://test.sandbox.googleapis.com/mcp"
29+
SCOPES = {"https://www.googleapis.com/auth/cloud-platform": ""}
30+
31+
root_agent = LlmAgent(
32+
model="gemini-2.0-flash",
33+
name="enterprise_assistant",
34+
instruction="""
35+
Help the user with the tools available to you.
36+
""",
37+
tools=[
38+
MCPToolset(
39+
connection_params=StreamableHTTPServerParams(
40+
url=MCP_SERVER_URL,
41+
),
42+
auth_scheme=OAuth2(
43+
flows=OAuthFlows(
44+
clientCredentials=OAuthFlowClientCredentials(
45+
tokenUrl="https://oauth2.googleapis.com/token",
46+
scopes=SCOPES,
47+
)
48+
)
49+
),
50+
# TODO: Update this to the user's service account credentials.
51+
auth_credential=AuthCredential(
52+
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
53+
service_account=ServiceAccount(
54+
service_account_credential=ServiceAccountCredential(
55+
type_="service_account",
56+
project_id="example",
57+
private_key_id="123",
58+
private_key="123",
59+
client_email="[email protected]",
60+
client_id="123",
61+
auth_uri="https://accounts.google.com/o/oauth2/auth",
62+
token_uri="https://oauth2.googleapis.com/token",
63+
auth_provider_x509_cert_url=(
64+
"https://www.googleapis.com/oauth2/v1/certs"
65+
),
66+
client_x509_cert_url="https://www.googleapis.com/robot/v1/metadata/x509/example.iam.gserviceaccount.com",
67+
universe_domain="googleapis.com",
68+
),
69+
scopes=SCOPES.keys(),
70+
),
71+
),
72+
)
73+
],
74+
)

src/google/adk/auth/credential_manager.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from fastapi.openapi.models import OAuth2
2121

2222
from ..agents.callback_context import CallbackContext
23+
from ..tools.openapi_tool.auth.credential_exchangers.service_account_exchanger import ServiceAccountCredentialExchanger
2324
from ..utils.feature_decorator import experimental
2425
from .auth_credential import AuthCredential
2526
from .auth_credential import AuthCredentialTypes
@@ -84,7 +85,6 @@ def __init__(
8485
self._discovery_manager = OAuth2DiscoveryManager()
8586

8687
# Register default exchangers and refreshers
87-
# TODO: support service account credential exchanger
8888
from .exchanger.oauth2_credential_exchanger import OAuth2CredentialExchanger
8989
from .refresher.oauth2_credential_refresher import OAuth2CredentialRefresher
9090

@@ -96,6 +96,12 @@ def __init__(
9696
AuthCredentialTypes.OPEN_ID_CONNECT, oauth2_exchanger
9797
)
9898

99+
# TODO: Move ServiceAccountCredentialExchanger to the auth module
100+
self._exchanger_registry.register(
101+
AuthCredentialTypes.SERVICE_ACCOUNT,
102+
ServiceAccountCredentialExchanger(),
103+
)
104+
99105
oauth2_refresher = OAuth2CredentialRefresher()
100106
self._refresher_registry.register(
101107
AuthCredentialTypes.OAUTH2, oauth2_refresher
@@ -207,9 +213,15 @@ async def _exchange_credential(
207213
if not exchanger:
208214
return credential, False
209215

210-
exchanged_credential = await exchanger.exchange(
211-
credential, self._auth_config.auth_scheme
212-
)
216+
if isinstance(exchanger, ServiceAccountCredentialExchanger):
217+
exchanged_credential = exchanger.exchange_credential(
218+
self._auth_config.auth_scheme, credential
219+
)
220+
else:
221+
exchanged_credential = await exchanger.exchange(
222+
credential, self._auth_config.auth_scheme
223+
)
224+
213225
return exchanged_credential, True
214226

215227
async def _refresh_credential(

tests/unittests/auth/test_credential_manager.py

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from unittest.mock import ANY
1516
from unittest.mock import AsyncMock
1617
from unittest.mock import Mock
1718
from unittest.mock import patch
@@ -30,6 +31,7 @@
3031
from google.adk.auth.auth_schemes import ExtendedOAuth2
3132
from google.adk.auth.auth_tool import AuthConfig
3233
from google.adk.auth.credential_manager import CredentialManager
34+
from google.adk.auth.credential_manager import ServiceAccountCredentialExchanger
3335
from google.adk.auth.oauth2_discovery import AuthorizationServerMetadata
3436
import pytest
3537

@@ -422,36 +424,32 @@ async def test_validate_credential_oauth2_missing_scheme_info(
422424
await manager._validate_credential()
423425

424426
@pytest.mark.asyncio
425-
async def test_exchange_credentials_service_account(self):
427+
async def test_exchange_credentials_service_account(
428+
self, service_account_credential, oauth2_auth_scheme
429+
):
426430
"""Test _exchange_credential with service account credential."""
427-
mock_service_account = Mock(spec=ServiceAccount)
428-
mock_credential = Mock(spec=AuthCredential)
429-
mock_credential.auth_type = AuthCredentialTypes.SERVICE_ACCOUNT
430-
431431
auth_config = Mock(spec=AuthConfig)
432-
auth_config.auth_scheme = Mock()
432+
auth_config.auth_scheme = oauth2_auth_scheme
433433

434-
# Mock exchanger
435-
mock_exchanger = Mock()
436-
mock_exchanger.exchange = AsyncMock(return_value=mock_credential)
434+
exchanged_credential = Mock(spec=AuthCredential)
437435

438436
manager = CredentialManager(auth_config)
439437

440-
# Mock the exchanger registry to return our mock exchanger
441438
with patch.object(
442-
manager._exchanger_registry,
443-
"get_exchanger",
444-
return_value=mock_exchanger,
445-
):
439+
ServiceAccountCredentialExchanger,
440+
"exchange_credential",
441+
return_value=exchanged_credential,
442+
autospec=True,
443+
) as mock_exchange_credential:
446444
result, was_exchanged = await manager._exchange_credential(
447-
mock_credential
445+
service_account_credential
448446
)
449447

450-
mock_exchanger.exchange.assert_called_once_with(
451-
mock_credential, auth_config.auth_scheme
452-
)
453-
assert result == mock_credential
454-
assert was_exchanged is True
448+
mock_exchange_credential.assert_called_once_with(
449+
ANY, oauth2_auth_scheme, service_account_credential
450+
)
451+
assert result == exchanged_credential
452+
assert was_exchanged is True
455453

456454
@pytest.mark.asyncio
457455
async def test_exchange_credential_no_exchanger(self):

0 commit comments

Comments
 (0)