🔴 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:
- Use ADK v2.3.0 or current
main.
- Configure
McpToolset with StreamableHTTPConnectionParams or SseConnectionParams.
- Ensure the MCP request already has a user / 3LO
Authorization header, for example through MCP auth credential handling or header_provider.
- 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()
- In
_GoogleAuthAsyncTransport.handle_async_request(), dict(request.headers) can produce a lowercase authorization key.
_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:
McpTool._run_async_impl() extracts auth headers from the resolved credential.
- If
header_provider is configured, it also obtains dynamic headers from ReadonlyContext.
- The auth headers and dynamic headers are merged.
- The merged headers are passed to
MCPSessionManager.create_session(headers=final_headers).
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.
🔴 Required Information
Describe the Bug:
In the MCP mTLS transport path, ADK may inject ADC / execution-environment Service Account credentials even when an
Authorizationheader is already present on the MCP request.The immediate cause is that
_RefreshableAsyncCredentials.before_request()checks for an existing Authorization header case-sensitively:However, in the mTLS path, the MCP SDK uses
httpx.AsyncClient, and ADK's_GoogleAuthAsyncTransport.handle_async_request()builds a plain dict fromhttpx.Request.headersbefore passing it toAsyncAuthorizedSession.request():httpxlowercases header names internally, so an existingAuthorizationheader may be passed asauthorization. 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:
main.McpToolsetwithStreamableHTTPConnectionParamsorSseConnectionParams.Authorizationheader, for example through MCP auth credential handling orheader_provider.GOOGLE_API_USE_CLIENT_CERTIFICATEis unset or set totruegoogle.auth.aiois available_GoogleAuthAsyncTransportin_get_mtls_transport()_GoogleAuthAsyncTransport.handle_async_request(),dict(request.headers)can produce a lowercaseauthorizationkey._RefreshableAsyncCredentials.before_request()checks'Authorization' in headersand misses the lowercase key.Expected Behavior:
HTTP header names are case-insensitive.
_RefreshableAsyncCredentials.before_request()should treatAuthorization,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:
mainModel Information:
🟡 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 bridgeshttpxrequests intogoogle-auth-aioAsyncAuthorizedSession.Logs:
N/A. The behavior can be demonstrated at the header-normalization level:
contains
authorization, notAuthorization.Screenshots / Video:
N/A.
Additional Context:
The existing
Authorizationheader 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 byMcpToolset/McpTool. For OAuth2 credentials, the implementation generates a header like this:For tool execution, the flow is roughly:
McpTool._run_async_impl()extracts auth headers from the resolved credential.header_provideris configured, it also obtains dynamic headers fromReadonlyContext.MCPSessionManager.create_session(headers=final_headers).MCPSessionManagerpasses those headers to the MCP SDK'sstreamablehttp_client/sse_client.The discovery path (
McpToolset.get_tools()/list_tools) has a similar flow throughMcpToolset._execute_with_session(), where headers fromheader_providerand exchanged credentials are merged and passed tocreate_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 lowercaseauthorization, and_RefreshableAsyncCredentials.before_request()does a case-sensitive check forAuthorization.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:
However, the header handling itself should still be fixed because HTTP header names are case-insensitive.
Related issues / PRs:
Minimal Reproduction Code:
How often has this issue occurred?:
authorization.