---
title: "Agents Acting for People Part 2: Token Exchange for MCP Tools in ADK"
url: "https://tirraflow.com/posts/agents-acting-for-people-part-2-token-exchange-for-mcp-tools-in-adk"
author: "taoufiq"
published: "2026-11-07T13:40:39Z"
updated: "2026-11-07T13:38:39Z"
category: "Security"
difficulty: "advanced"
series: "Agents Acting for People"
series_part: 2
series_url: "https://tirraflow.com/series/agents-acting-for-people"
tags: ["OAuth2", "Agents", "ADK", "Security", "MCP"]
---

# Agents Acting for People Part 2: Token Exchange for MCP Tools in ADK

> **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](https://tirraflow.com/posts/agents-acting-for-people-part-1-how-oauth-works-for-agent-tools) 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:

```yaml
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:

```python
@dataclass(frozen=True)
class CallerIdentity:
    subject: str
    username: str
    groups: tuple[str, ...] = ()
    subject_token: str | None = field(default=None, repr=False, compare=False)
```

- `repr=False` keeps it out of every log line and traceback that prints the identity.
- A `ContextVar` is copied into `asyncio.to_thread` workers and into ADK's `AgentTool` sub-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:

```python
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 | `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:

```python
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.

![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](https://tirraflow.com/uploads/8ab91b47-304a-4048-b420-7eb62db26872.png)

---

## 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](https://tirraflow.com/posts/agents-acting-for-people-part-3-security-principles-for-any-agent-platform) covers those principles, which apply to any agent platform, built on ADK or not.
