Agents Acting for People Part 2: Token Exchange for MCP Tools in ADK
Part 1 showed why an agent's tool should reach a target system with a token minted for that user and that target, using OAuth 2.0 Token Exchange (RFC 8693). This part is the implementation for MCP...
Status, November 2026: written against Google ADK 2.13 and Orrery 0.20.0. The code is in
core/orrery_core/security/token_exchange.pyandcore/orrery_core/tools/mcp.py, and the design is recorded in ADR-004.
Part 1 showed why an agent's tool should reach a target system with a token minted for that user and that target, using OAuth 2.0 Token Exchange (RFC 8693). This part is the implementation for MCP servers in Google ADK. The exchange itself is one HTTP POST. Six problems around it took most of the code.
Configuration is one block per server:
servers:
- name: github
url: https://mcp.example.com/github
auth:
type: token_exchange
audience: github-mcp # the audience the identity provider issues for
scope: "issues:read" # optional, as narrow as the tools need
tools: [list_issues, get_issue]1. Keep the User's Token Away From the Model
The exchange needs the user's own token, the subject token. In ADK the obvious place to put per-turn data is session state, and that is the wrong place. Tools write to session state, tool output steers what tools do, and both ADK's api_server and most front doors have endpoints that return session state verbatim.
Instead the HTTP front door puts the verified token on a CallerIdentity held in a Python ContextVar, set for the length of the turn:
@dataclass(frozen=True)
class CallerIdentity:
subject: str
username: str
groups: tuple[str, ...] = ()
subject_token: str | None = field(default=None, repr=False, compare=False)repr=Falsekeeps it out of every log line and traceback that prints the identity.A
ContextVaris copied intoasyncio.to_threadworkers and into ADK'sAgentToolsub-runs, so a specialist agent's MCP call still sees its caller. No tool argument carries it, so the model never sees it and can never change it.Only transports that verified a bearer token set one. Slack, Google Chat and the scheduled triage sweep have no user token, so for them an exchanging server is refused rather than reached as the platform.
2. One Exchange, Not One Per Model Step
A single agent turn can call the same MCP server a dozen times. The broker caches each exchanged token in memory until 30 seconds before it expires, keyed by a digest of the subject token, plus the audience and scope:
key = (hashlib.sha256(subject_token.encode()).hexdigest(), audience, scope or "")The key uses the subject token, not the user name, on purpose. When Alice's console token is refreshed, the next call exchanges again. So an exchanged token never outlives the token it came from by more than its own short lifetime. A revoked console session stops working downstream within one exchanged-token lifetime: after that the identity provider refuses the next exchange.
Concurrent calls for the same key share one exchange. It runs in its own task behind asyncio.shield, so one caller hitting its deadline doesn't cancel the exchange the others are waiting on.
3. Remember Failures Too
Every model step asks the MCP server for its tool list, and listing needs Alice's token. If Alice is not allowed a GitHub token, then without a memory of failures each step is another identity-provider round trip. The broker caches failures per key:
A refusal (the provider said no) for 30 seconds.
An outage or a misconfiguration (timeout, 5xx, 429, a rejected client) with exponential backoff from 5 to 60 seconds.
Exchanges also run on a dedicated four-thread pool. Every blocking tool in Orrery (Kubernetes, Kafka, Docker) shares the event loop's default executor, and an identity-provider brownout should hold four threads of its own, not all of those.
4. "You May Not" Is Not "It's Broken"
The broker sorts each failure, because three parts of the platform need to react differently:
Provider answer | Meaning | Tool result |
|---|---|---|
Any other non-200 (400, 403, …) | This user may not have this token |
|
Timeout, 5xx, 429 | The provider is unavailable |
|
401, or | The platform's own client is wrong, for everyone |
|
The last row matters in practice. A rotated client secret makes every user fail at once. It must look like an outage on a dashboard, not like a thousand individual refusals. Some platform mistakes still arrive looking like refusals: Keycloak's older permission model answers a client that may not exchange with 403 access_denied. So 403s are logged at WARNING. If every user is refused at once, check the platform's client before the users.
The broker also checks the answer before using it: token_type must be Bearer, and issued_token_type must be an access token. RFC 8693 lets a provider return other kinds of token, and sending one of those as a bearer is a bug that would only surface at the target. An answer that fails the check is treated as a refusal.
5. Fit ADK's MCP Toolset Without Losing the Error
ADK's McpToolset has a header_provider hook, called with the context before each request. It is the natural place to add Authorization: Bearer …, and it brings a useful side effect: ADK pools MCP sessions by the merged headers, and when a tool-list TTL is set (Orrery's default is 300 seconds) it caches listings under the same key. So each user's token gets its own MCP session and listing, and one session never carries two users.
The catch: ADK turns an exception from the header provider into a bare {"error": "..."}, so "Alice may not" and "the provider is down" arrive at the model looking the same. So each MCP tool's run_async is wrapped, and the credential is resolved there first:
async def run_with_deadline(*, args, tool_context):
try:
async with asyncio.timeout(seconds):
# Warms the broker's cache, which the header provider then reads.
await credential() # raises CredentialUnavailable
return await run(args=args, tool_context=tool_context)
except CredentialUnavailable as exc:
return {
"status": "access_denied" if exc.is_refusal else "error",
"error_type": "credential_unavailable",
"message": f"{name}: {exc}",
}By the time ADK calls the header provider, the token is already in the broker's cache. A caller with no token at all is offered no tools from that server: get_tools returns an empty list, and the turn goes on with everything else.
6. When the Target Says 401
A token can be revoked before it expires. The MCP client's HTTP transport reports every failed request to the toolset. A 401 on a call (a POST), or on the GET that opens an SSE session, makes it:
drops the token from the cache, matched by its value and not by "the current user". The 401 may arrive on an MCP session's background task, and it must not evict a newer token issued in the meantime. A token under 30 seconds old is kept, so a server that rejects everything can't turn each call into an identity-provider round trip.
discards that token's pooled session on a rejected POST. Over the older SSE transport, a 401 kills the session while ADK's pool keeps handing it back, and later calls would fail without sending a request at all.
tells the model what happened, if the rejection came during this call:
error_type: credential_rejected, "the call may have been cut off partway: check its effect before calling it again". A generic "execution failed" invites the model to repeat a change that went through. The shared circuit breaker leaves this one out, so one user's revoked token can't open the circuit for everyone.
A 401 on streamable HTTP's optional GET stream, or on a closing DELETE, changes nothing: acting on those would kill a session that is still in use.

Audit Who Got What
Every round trip to the provider writes an audit event: who asked, for which audience and scope, and how it ended (issued, refused, unavailable, misconfigured, error). The same outcome feeds a Prometheus counter, and a target's 401 writes a token_rejected_by_target event of its own. A cache hit issues nothing, so it writes nothing, and the tokens themselves never reach the audit log or the metrics. "Who obtained a GitHub token on Tuesday?" is then a log query, not an investigation.
On the Identity Provider
The platform needs a confidential client that is allowed to perform exchanges, and nothing more:
it accepts as subject tokens only those issued to the console (and to A2A callers, if you use A2A);
it may only request the audiences of the MCP servers you configured;
its secret lives in a secret store and stays out of
repr(), like the subject token.
In Keycloak 26, with standard token exchange, that means three settings. Turn on token exchange for the platform's client. Add an audience mapper so console tokens include that client in their aud. Grant the target audiences through the client's scopes. Check your provider's documentation for its own version of each.
The Pattern
Keep the user's token in turn-scoped memory, never in state the model or tools can reach.
Cache tokens by the token they came from, and cache failures too.
Sort failures into refusal, outage and misconfiguration, because each needs a different reaction.
Resolve credentials where an error can still be reported as what it is.
Refuse when there is no user. Never fall back to the platform's own credentials.
Per-user tokens decide what a tool may do. They don't decide who is allowed to ask, or who may approve a dangerous action when the caller is another agent. Part 3 covers those principles, which apply to any agent platform, built on ADK or not.