Catalogue

Product management agent for the Classic Models catalog domain. Serves a product manager: what exists, how it is priced, whether it sells, what to discontinue.

Reached over A2A; reads data over MCP from the domain servers; thinks through the estate's LLM endpoint. It is one MCP client among others, as LibreChat is — nothing under it changes to accommodate it.

Running it

uv venv && uv pip install -e ".[dev]"
export CATALOGUE_CLIENT_SECRET=...        # Keycloak client secret, no default
export CATALOGUE_CA_BUNDLE=/path/to/sttlab-local-ca.crt
python -m catalogue_agent

Everything else has a working default in config.py and is overridable with CATALOGUE_-prefixed variables.

curl localhost:8080/.well-known/agent-card.json

curl -X POST localhost:8080/ \
  -H 'Content-Type: application/json' \
  -H 'A2A-Version: 1.0' \
  -H "Authorization: Bearer $USER_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage",
       "params":{"message":{"messageId":"m1","role":"ROLE_USER",
                            "parts":[{"text":"How many product lines are there?"}]}}}'

A2A-Version: 1.0 is required. Without it the SDK assumes 0.3 and answers VERSION_NOT_SUPPORTED, which reads as a protocol error even though the request is fine.

How a turn works

A2A SendMessage  ──►  exchange the user's token, once per audience
                 ──►  build one MCP toolset per server, with that turn's tokens
                 ──►  PydanticAI runs the model through the LLM endpoint
                 ──►  answer, toolsets dropped

The MCP client is built per turn. A token that outlives the turn it was minted for is a token that can be replayed after the user's session ended, and holding one long-lived connection would mean one long-lived token.

Several narrow tokens rather than one broad one: 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 is refused at the endpoint rather than succeeding quietly.

What it can and cannot do

Reads catalog (products, product lines), sales (read-only, to judge whether a product sells)
Writes catalog: create-product, and only after the user confirms the exact record
Calls Approvisionnement.check_availability, before recommending a discontinuation
Exposes describe_catalog

Who may write is decided by the MCP server, not by this agent. It authorises each tool against the roles in the end user's own token — create-product needs admin or product_manager — and it does so at discovery, not only at call time:

pm-user   catalog  ['create-product', 'get-product', 'list-products']
ro-user   catalog  ['get-product', 'list-products']

So a read-only user's turn has no write tool in it at all, and no prompt injection can conjure one. The agent holds no standing write access of its own; the system prompt's confirm-before-writing rule is a courtesy on top of that, not the control.

Writing needs conversation history — the agent restates the record, the user says yes, and "yes" only means something if the previous turn is still there. History is kept in memory, keyed by A2A contextId, bounded, and never persisted. More than one replica therefore needs sticky routing on contextId or a shared store.

Skills are decision-shaped on purpose. An agent exposing a generic query has become a data proxy and defeats the per-domain scoping the estate is built on, so skills get added when another agent has a specific question — not in advance.

Two Keycloak facts this depends on

Both measured against Keycloak 26.6, both costly to rediscover:

actor_token is silently ignored. Standard Token Exchange accepts it, returns a valid token and emits no act claim — no error. Delegation cannot be built on RFC 8693 act; the actor is identified by azp, which carries this agent's client id while sub stays the human.

The audience is requested as a scope. audience=mcp-catalog returns "Requested audience not available". scope=openid mcp-catalog-audience works.

Library choices

Pinned to what was current on 2026-08-28, ranges below the next major so patch fixes arrive but an upgrade stays deliberate.

a2a-sdk rather than FastA2A. The earlier design said FastA2A, on the grounds that Agent.to_a2a() was going away in PydanticAI v2 — which has since happened, so that argument no longer distinguishes them. This estate exists to prove A2A interoperability across five runtimes, and the reference implementation is the safer thing for four other stacks to interoperate with.

httpx2 rather than httpx. PydanticAI deprecates httpx clients for OpenAI-compatible providers and removes them in v3.

Notes on a2a-sdk 1.1 that are not guessable, all established against the running library:

  • a2a.types are protobuf messages, not pydantic models.
  • Use DefaultRequestHandlerV2; DefaultRequestHandler speaks 0.3.
  • JSON-RPC methods are SendMessage, GetTask… The 0.3 spelling (message/send) still works — enable_v0_3_compat is on.
  • An executor must enqueue a Task before any status update when context.current_task is None.

Not done yet

  • The Keycloak client does not exist. classic-models-agent-catalogue has to be declared in infra-keycloak/k8s/keycloak/configmap-realm.yaml, with mcp-catalog-audience and mcp-sales-audience as optional scopes. Until then the exchange fails and the agent says so.
  • Writes, and the A2A call to Approvisionnement.
  • Deployment manifests — they belong in classic-models-agent-platform once a second agent shows what is actually shared.

Deployment

gitea.sttlab.eu/stt/classic-models-agent-catalogue:v0.1.0

gitea's own registry rather than ghcr, so the image sits beside the source it was built from and inherits the repo's visibility. The repo is public, so the cluster pulls it with no credentials at all — no imagePullSecret to create by hand, and no GitHub token to keep in step.

Built from the Dockerfile here — two stages, so the runtime image carries no build toolchain; non-root, read-only root filesystem, all capabilities dropped. Built and pushed by hand for now: gitea Actions is not set up on this instance, so there is no pipeline to do it.

docker login gitea.sttlab.eu          # gitea username + a token with package:write
docker build -t gitea.sttlab.eu/stt/classic-models-agent-catalogue:vX.Y.Z .
docker push  gitea.sttlab.eu/stt/classic-models-agent-catalogue:vX.Y.Z

k8s/ is what runs; dev/ is the Flux plumbing that applies it. Three manual applies close the loop once — nothing can watch ./dev before the namespace and the source that serves it exist:

kubectl apply -f k8s/namespace.yaml
kubectl apply -f dev/gitrepository.yaml
kubectl apply -f dev/bootstrap-kustomization.yaml

After that every file here reconciles from git, dev/ included. Never kubectl apply the objects under k8s/ afterwards — Flux reverts them within its interval and the cluster silently stops matching what you edited.

What the deployment assumes:

Namespace agents — one for the estate; what isolates an agent is its Keycloak client, not its namespace
Secret CATALOGUE_CLIENT_SECRET, from the same Vault entry the realm import reads, through the vault-keycloak store
CA k8s/ca-configmap.yaml holds the lab CA certificate — public by construction, checked in so the deployment has no hidden prerequisite. Regenerate it when the CA rotates (expires 2026-11-24)
Image pull none — the gitea package is public, like its repo
DNS every *.sttlab.local name resolves in-cluster already; no hostAliases needed
Replicas exactly one. Conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question
S
Description
No description provided
Readme 240 KiB
Languages
Python 97.7%
Dockerfile 2.3%