Build the Catalogue agent: A2A in, MCP out, inference through Kong
First of the six agents, and the one that gives the A2A facade for the others to interoperate with. A turn exchanges the user's token once per audience, builds one MCP toolset per server with those tokens, runs the model through the Kong AI gateway, and drops the toolsets. Per-turn MCP clients are the point, not an implementation detail: a token that outlives its turn can be replayed after the session ended. Several narrow tokens rather than one broad one, so a prompt injection that talks the agent into the wrong domain is refused at Kong rather than succeeding quietly. a2a-sdk rather than FastA2A, revising the May decision. That decision rested on Agent.to_a2a() being removed in PydanticAI v2 — which has now happened, so the argument no longer separates them. With four other runtimes due to interoperate, the protocol author's implementation is the safer base. httpx2 rather than httpx, which PydanticAI deprecates for OpenAI-compatible providers and drops in v3. Four things about a2a-sdk 1.1 that no documentation gave and that were established against the running library, all recorded where they bite: a2a.types are protobuf, not pydantic; DefaultRequestHandler speaks 0.3 and V2 is needed for 1.0; a client must send A2A-Version: 1.0 or the server assumes 0.3 and answers VERSION_NOT_SUPPORTED on a perfectly valid request; and an executor must enqueue a Task before any status update. Verified: the app builds with no deprecation warnings, serves its Agent Card, and fails a turn cleanly with "no bearer token" rather than answering from the model's own knowledge — which would hide a broken identity chain behind a plausible reply. Seven tests cover the exchange shape (scope not audience, per-audience caching, refusal) and the card. The Keycloak client does not exist yet, so no end-to-end run has happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""Token exchange: the end user's token in, one narrowly-scoped token per MCP
|
||||
server out.
|
||||
|
||||
Two measured facts about Keycloak 26.6 shape this, both of which cost time to
|
||||
discover and neither of which is obvious from the RFC:
|
||||
|
||||
**`actor_token` is silently ignored.** Standard Token Exchange accepts the
|
||||
parameter, returns a valid token, and emits no `act` claim — no error, no
|
||||
warning. Delegation therefore cannot be built on RFC 8693 `act`. The actor is
|
||||
identified by `azp`, which carries this agent's client id.
|
||||
|
||||
**The audience is requested as a scope.** Passing `audience=mcp-catalog` returns
|
||||
"Requested audience not available". Passing `scope=openid mcp-catalog-audience`
|
||||
works, and the resulting token carries `aud: [mcp-catalog]`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx2
|
||||
|
||||
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
|
||||
|
||||
class TokenExchangeError(RuntimeError):
|
||||
"""Keycloak refused the exchange. Carries its response, which names the cause."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangedToken:
|
||||
access_token: str
|
||||
expires_at: float
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
# A conversation turn lasts seconds and a token five minutes, so this
|
||||
# margin only matters for a turn that stalls on a slow model.
|
||||
return time.monotonic() >= self.expires_at - 30
|
||||
|
||||
|
||||
class TokenExchanger:
|
||||
"""Exchanges a user token for audience-scoped tokens.
|
||||
|
||||
Caches per (subject token, scope) for the lifetime of one turn. Deliberately
|
||||
not a long-lived cache keyed by user: a token that outlives the turn it was
|
||||
minted for is a token that can be replayed after the user's session ended.
|
||||
"""
|
||||
|
||||
def __init__(self, client: httpx2.AsyncClient, *, token_endpoint: str, client_id: str, client_secret: str) -> None:
|
||||
self._client = client
|
||||
self._endpoint = token_endpoint
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._cache: dict[tuple[str, str], ExchangedToken] = {}
|
||||
|
||||
async def exchange(self, subject_token: str, audience_scope: str) -> str:
|
||||
key = (subject_token, audience_scope)
|
||||
cached = self._cache.get(key)
|
||||
if cached and not cached.expired:
|
||||
return cached.access_token
|
||||
|
||||
response = await self._client.post(
|
||||
self._endpoint,
|
||||
data={
|
||||
"grant_type": GRANT_TYPE,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": SUBJECT_TOKEN_TYPE,
|
||||
# Not `audience=...`. See the module docstring.
|
||||
"scope": f"openid {audience_scope}",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise TokenExchangeError(
|
||||
f"exchange for {audience_scope!r} failed: HTTP {response.status_code} {response.text[:200]}"
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
token = ExchangedToken(
|
||||
access_token=payload["access_token"],
|
||||
expires_at=time.monotonic() + float(payload.get("expires_in", 60)),
|
||||
)
|
||||
self._cache[key] = token
|
||||
return token.access_token
|
||||
Reference in New Issue
Block a user