Skip to content

MCP mTLS transport may inject ADC Authorization when an existing auth header is lowercase #6200

Description

@h-tsuboi918

🔴 Required Information

Describe the Bug:

In the MCP mTLS transport path, ADK may inject ADC / execution-environment Service Account credentials even when an Authorization header is already present on the MCP request.

The immediate cause is that _RefreshableAsyncCredentials.before_request() checks for an existing Authorization header case-sensitively:

if 'Authorization' in headers:

However, in the mTLS path, the MCP SDK uses httpx.AsyncClient, and ADK's _GoogleAuthAsyncTransport.handle_async_request() builds a plain dict from httpx.Request.headers before passing it to AsyncAuthorizedSession.request():

headers_dict = dict(request.headers)

httpx lowercases header names internally, so an existing Authorization header may be passed as authorization. In that case, the case-sensitive check misses the existing header and ADC credentials may be refreshed and injected into the same request.

As a result, an MCP server can receive an opaque access token for the runtime Service Account instead of the intended user / 3LO token.

Steps to Reproduce:

  1. Use ADK v2.3.0 or current main.
  2. Configure McpToolset with StreamableHTTPConnectionParams or SseConnectionParams.
  3. Ensure the MCP request already has a user / 3LO Authorization header, for example through MCP auth credential handling or header_provider.
  4. Run an MCP tool call in an environment where the mTLS transport path is enabled:
    • GOOGLE_API_USE_CLIENT_CERTIFICATE is unset or set to true
    • google.auth.aio is available
    • ADC is available
    • ADK can create _GoogleAuthAsyncTransport in _get_mtls_transport()
  5. In _GoogleAuthAsyncTransport.handle_async_request(), dict(request.headers) can produce a lowercase authorization key.
  6. _RefreshableAsyncCredentials.before_request() checks 'Authorization' in headers and misses the lowercase key.

Expected Behavior:

HTTP header names are case-insensitive. _RefreshableAsyncCredentials.before_request() should treat Authorization, authorization, and any other casing as the same header.

If any Authorization header is already present, ADK should not inject ADC / Service Account credentials.

Observed Behavior:

An existing Authorization header can be missed when its key is lowercase. In that case, ADK may refresh ADC credentials and inject an execution-environment token into the MCP request.

This can cause the MCP server to receive a Service Account / ADC token instead of the intended user / 3LO token.

Environment Details:

  • ADK Library Version (pip show google-adk): v2.3.0 and current main
  • Desktop OS: Linux
  • Python Version (python -V): Python 3.11

Model Information:

  • Are you using LiteLLM: N/A
  • Which model is being used: N/A. This issue occurs in the MCP HTTP transport path before model behavior is relevant.

🟡 Optional Information

Regression:

This appears related to the mTLS transport work introduced in v2.3.0. The issue is not about list_tools / discovery auth propagation. It affects the MCP HTTP request path when the mTLS transport bridges httpx requests into google-auth-aio AsyncAuthorizedSession.

Logs:

N/A. The behavior can be demonstrated at the header-normalization level:

dict(httpx.Request(..., headers={"Authorization": "Bearer user_token"}).headers)

contains authorization, not Authorization.

Screenshots / Video:

N/A.

Additional Context:

The existing Authorization header is not only a user-supplied static header. ADK has MCP header propagation paths intended to pass user / 3LO credentials to the MCP server during MCP tool execution.

After a 3LO flow completes, ADK resolves the user access token as an AuthCredential. In the MCP integration, that credential is converted into an HTTP header by McpToolset / McpTool. For OAuth2 credentials, the implementation generates a header like this:

headers = {"Authorization": f"Bearer {credential.oauth2.access_token}"}

For tool execution, the flow is roughly:

  1. McpTool._run_async_impl() extracts auth headers from the resolved credential.
  2. If header_provider is configured, it also obtains dynamic headers from ReadonlyContext.
  3. The auth headers and dynamic headers are merged.
  4. The merged headers are passed to MCPSessionManager.create_session(headers=final_headers).
  5. MCPSessionManager passes those headers to the MCP SDK's streamablehttp_client / sse_client.

The discovery path (McpToolset.get_tools() / list_tools) has a similar flow through McpToolset._execute_with_session(), where headers from header_provider and exchanged credentials are merged and passed to create_session(headers=...).

Therefore, after a successful 3LO flow, the MCP HTTP request is expected to already contain:

Authorization: Bearer <user_3lo_access_token>

That header is required by the MCP server to identify the user context. If the later mTLS transport / Google auth bridge injects ADC or Service Account credentials into the same request, the principal observed by the MCP server can change from the user to the runtime Service Account.

This issue is not about failing to obtain the user token, nor is it about the MCP header propagation path failing to set a header. The problem occurs after the user / 3LO Authorization header has already reached create_session(headers=...): in the mTLS transport path, dict(request.headers) can represent it as lowercase authorization, and _RefreshableAsyncCredentials.before_request() does a case-sensitive check for Authorization.

This bug report focuses especially on the MCP tool-call request path. Independently of list_tools / discovery behavior, the tool-call HTTP request path should preserve and respect an existing Authorization header case-insensitively.

Suggested fix:

if any(key.lower() == "authorization" for key in headers):
    logger.debug("Authorization header already present, not overwriting")
    return

However, the header handling itself should still be fixed because HTTP header names are case-insensitive.

Related issues / PRs:

Minimal Reproduction Code:

import httpx

request = httpx.Request(
    "POST",
    "https://example.com/mcp",
    headers={"Authorization": "Bearer user_token"},
)

print(dict(request.headers))
# contains "authorization", not "Authorization"

How often has this issue occurred?:

  • Always (100%) when the mTLS transport path is active and the existing Authorization header is represented as lowercase authorization.

Metadata

Metadata

Assignees

Labels

mcp[Component] Issues about MCP supporttools[Component] This issue is related to tools

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions