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.py and core/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)

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:

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

access_denied: the circuit breaker ignores it

Timeout, 5xx, 429

The provider is unavailable

error: the breaker counts it

401, or invalid_client / unauthorized_client with any status

The platform's own client is wrong, for everyone

error, logged at ERROR and audited as token_exchange_misconfigured

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:

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.

Token exchange around an ADK MCP tool call: the caller's token from a ContextVar, the broker's cache and failure cache, the exchange on its own pool, the header provider and per-user session pool, and the 401 path back to the cache

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:

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

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.

Read on Tirraflow