6ffab873e2
The gateway already authorised writes for a product_manager — ai-mcp-proxy filters tool discovery by consumer group, so create-product is simply absent from a read-only user's turn. Only the system prompt was holding the agent back, and it now describes how to write instead: restate the record, ask for confirmation, never invent a missing field, report the identifier, and stop on a refusal rather than working around it. That flow cannot terminate without history. Each turn built a fresh Agent with no message_history, so the user's "yes" arrived at an agent that had no idea what it was agreeing to. History is now kept per A2A contextId in a bounded in-memory map — enough for confirm-then-act, deliberately not durable, and documented as needing sticky routing beyond one replica. Verified end to end: pm-user creates S99_TEST1 across two turns and the row appears in MySQL; ro-user asking for the same thing is told no write tool exists, because for that token none does. Both test rows removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.7 KiB
Markdown
135 lines
5.7 KiB
Markdown
# 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** through the Kong gateways; thinks
|
|
through the **Kong AI gateway**. It is one MCP client among others, exactly as
|
|
LibreChat is — nothing under it changes to accommodate it.
|
|
|
|
## Running it
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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 Kong AI gateway
|
|
──► 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 fails at Kong 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 | nothing yet. `Approvisionnement.check_availability` comes with discontinuation decisions |
|
|
| Exposes | `describe_catalog` |
|
|
|
|
Who may write is decided by Kong, not by this agent. `ai-mcp-proxy` carries a
|
|
per-tool ACL — `create-product` allows the `admin` and `product_manager`
|
|
consumer groups — and the group comes from `realm_access.roles` in the end
|
|
user's own token. The filtering happens at **discovery**, not just 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.
|