Files
classic-models-agent-catalogue/src/catalogue_agent/mcp_clients.py
T
Stéphane Tailland ba8892b0ad 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>
2026-08-28 08:45:11 +02:00

72 lines
2.4 KiB
Python

"""MCP toolsets, rebuilt for every conversation turn.
The rule this enforces is the one thing the identity design depends on: **an MCP
client is built per turn, with tokens exchanged for that turn**, never held open
with a fixed token.
That is not a limitation of any framework — it is what keeps the blast radius of
a leaked token to one turn, and what makes `azp`/`sub` in the audit trail mean
something. PydanticAI takes `headers` at toolset construction, which is exactly
the shape this needs; the same constraint on other runtimes (langchain-mcp-adapters
issues #172/#194/#235, CrewAI's MCPServerAdapter) has the same answer.
"""
from __future__ import annotations
from dataclasses import dataclass
from pydantic_ai.mcp import MCPToolset, StreamableHttpTransport
from .config import Settings
from .identity import TokenExchanger
@dataclass(frozen=True)
class McpTarget:
"""One MCP server this agent is allowed to reach."""
name: str
url: str
audience_scope: str
def targets(settings: Settings) -> list[McpTarget]:
"""The catalog domain read/write, plus sales read-only.
sales is here to answer "does this product actually sell?" before a pricing
or discontinuation decision. Read only — this agent never touches orders,
and the scope it requests reflects that.
"""
return [
McpTarget("catalog", settings.mcp_catalog_url, settings.mcp_catalog_scope),
McpTarget("sales", settings.mcp_sales_url, settings.mcp_sales_scope),
]
async def build_toolsets(
settings: Settings,
exchanger: TokenExchanger,
subject_token: str,
) -> list[MCPToolset]:
"""One toolset per MCP server, each carrying its own audience-scoped token.
Several narrow tokens rather than one broad one, deliberately: a token
minted for `mcp-catalog` is refused by the sales gateway, so a prompt
injection that talks the agent into reaching the wrong domain fails at
Kong rather than succeeding quietly.
"""
toolsets: list[MCPToolset] = []
for target in targets(settings):
token = await exchanger.exchange(subject_token, target.audience_scope)
toolsets.append(
MCPToolset(
StreamableHttpTransport(
url=target.url,
headers={"Authorization": f"Bearer {token}"},
verify=settings.verify,
),
id=target.name,
)
)
return toolsets