Skip to content

ServiceAccountCredentialExchanger: ID Token kind #4458

@lpezet

Description

@lpezet

Describe the Bug:
Authenticating MCP tool deployed in Cloud Run from agent in GCP (Cloud Run/GCF/Agent Engine/etc.).

Steps to Reproduce:

  1. Sample code with remote MCP server deployed in Cloud Run for example with "Require authentication" (i.e. NOT public):
auth_scheme=OAuth2(
    flows=OAuthFlows(
        clientCredentials=OAuthFlowClientCredentials(
            tokenUrl="https://oauth2.googleapis.com/token",
            scopes=SCOPES,
        )
    )
)
auth_credential = AuthCredential(
    auth_type=AuthCredentialTypes.SERVICE_ACCOUNT, service_account=ServiceAccount(use_default_credential = True, scopes=SCOPES.keys())
)
mcp_toolset = McpToolset(
    connection_params=StreamableHTTPConnectionParams(url=MCP_URL),
    auth_scheme=auth_scheme,
    auth_credential=auth_credential,
)
root_agent = LlmAgent(
  name='My_Agent',
  model='gemini-2.5-flash',
  description=(
      'Agent to discuss with.'
  ),
  sub_agents=[],
  instruction='Use available tools to greet your interlocutor.',
  tools=[
    mcp_toolset
  ]
)
  1. Run adk web
  2. Select agent and send message
  3. Guts spilling too graphic to convey here but in short:
...
2026-02-11 23:40:52,949 - INFO - _client.py:1740 - HTTP Request: POST https://XYZ.us-central1.run.app/mcp "HTTP/1.1 401 Unauthorized"
2026-02-11 23:40:52,950 - WARNING - session_context.py:190 - Error on session runner task: Attempted to exit cancel scope in a different task than it was entered in
2026-02-11 23:40:52,950 - INFO - mcp_session_manager.py:174 - Retrying get_tools due to error: Failed to create MCP session: Failed to create MCP session: Attempted to exit cancel scope in a different task than it was entered in
...
    raise ConnectionError(f'Failed to create MCP session: {e}') from e
ConnectionError: Failed to create MCP session: Failed to create MCP session: Attempted to exit cancel scope in a different task than it was entered in

Expected Behavior:
Specify ID token as "token kind" + audience so that:

  1. No guts spilling
  2. and MCP tool called successfully, using id_token

Observed Behavior:
Failed to call MCP tool because using Access Token instead of ID Token.

Environment Details:

My requirements.txt:

google-cloud-aiplatform[agent_engines,adk,langchain,ag2,llama_index]>=1.112.0
  • google-adk==1.24.1
  • Windows WSL2/Ubuntu 22.04.5 LTS
  • Python 3.11.0rc1

Model Information:

I don't believe it matters here:

  • gemini-2.5-flash

Work around
Hack to inject new definition of exchange_credential() in ServiceAccountCredentialExchanger and force the use of ID Token:

from typing import Optional
import google.auth
from google.auth.transport.requests import Request
import google.adk.tools.openapi_tool.auth.credential_exchangers.service_account_exchanger as mod
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_schemes import AuthScheme
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import AuthCredentialMissingError

def newExchange_credential(
    self,
    auth_scheme: AuthScheme,
    auth_credential: Optional[AuthCredential] = None,
) -> AuthCredential:
  """Exchanges the service account auth credential for an access token.

  If auth_credential contains a service account credential, it will be used
  to fetch an access token. Otherwise, the default service credential will be
  used for fetching an access token.

  Args:
      auth_scheme: The auth scheme.
      auth_credential: The auth credential.

  Returns:
      An AuthCredential in HTTPBearer format, containing the access token.
  """
  if (
      auth_credential is None
      or auth_credential.service_account is None
      or (
          auth_credential.service_account.service_account_credential is None
          and not auth_credential.service_account.use_default_credential
      )
  ):
    raise AuthCredentialMissingError(
        "Service account credentials are missing. Please provide them, or set"
        " `use_default_credential = True` to use application default"
        " credential in a hosted service like Cloud Run."
    )

  try:
    if auth_credential.service_account.use_default_credential:
      credentials, project_id = google.auth.default(
          scopes=["https://www.googleapis.com/auth/cloud-platform"],
      )
      quota_project_id = (
          getattr(credentials, "quota_project_id", None) or project_id
      )
    else:
      config = auth_credential.service_account
      credentials = service_account.Credentials.from_service_account_info(
          config.service_account_credential.model_dump(), scopes=config.scopes
      )
      quota_project_id = None

    credentials.refresh(Request())

    updated_credential = AuthCredential(
        auth_type=AuthCredentialTypes.HTTP,  # Store as a bearer token
        http=HttpAuth(
            scheme="bearer",
            credentials=HttpCredentials(token=credentials.id_token), # <--- all this for that!
            additional_headers={
                "x-goog-user-project": quota_project_id,
            }
            if quota_project_id
            else None,
        ),
    )
    return updated_credential

  except Exception as e:
    raise AuthCredentialMissingError(
        f"Failed to exchange service account token: {e}"
    ) from e

mod.ServiceAccountCredentialExchanger.exchange_credential = newExchange_credential

My 2 cents

The problem I believe is in the choice//approach taken for the use of token used in AuthCredential().
When opting for default credentials (ADC isn't it?), google.auth.default(...) is called and will return more than just token (the id_token in particular):

    try:
      if auth_credential.service_account.use_default_credential:
        credentials, project_id = google.auth.default(
            scopes=["https://www.googleapis.com/auth/cloud-platform"],
        )

Source: https://github.com/google/adk-python/blob/v1.24.1/src/google/adk/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.py#L76

But then when creating (and returning) the credentials, the credentials.token is used:

      updated_credential = AuthCredential(
          auth_type=AuthCredentialTypes.HTTP,  # Store as a bearer token
          http=HttpAuth(
              scheme="bearer",
              credentials=HttpCredentials(token=credentials.token),
              additional_headers={
                  "x-goog-user-project": quota_project_id,
              }
              if quota_project_id
              else None,
          ),
      )
      return updated_credential

Source: https://github.com/google/adk-python/blob/v1.24.1/src/google/adk/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.py#L92

I could be wrong, but this setup doesn't work then when authenticating service-to-service.

See my workaround/hack above, as I could not find a better way to provide a different implementation for ServiceAccountCredentialExchanger since CredentialManager is instantiated by BaseLlmFlow and, even if it wasn't, ServiceAccountCredentialExchanger is also instantiated in CredentialManager.

NB: What he said --> # TODO: Move ServiceAccountCredentialExchanger to the auth module

Metadata

Metadata

Assignees

No one assigned

    Labels

    auth[Component] This issue is related to authorization

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions