An agent talks to URLs that honour a contract: an MCP endpoint, an OIDC token endpoint, an OpenAI-shaped LLM endpoint. Whatever serves those URLs is replaceable, and naming it in an agent's code or docs implies a coupling that does not exist. Every product name is gone from prose, comments and identifiers, replaced by what the agent actually depends on. Where a behaviour of the current middleware leaks into the contract — the second audience scope, the tool ACL — the text now says what the caller must do and why, not who imposes it. One string survives and cannot be fixed here: the broker client an agent must name in its audience scopes is itself named after the middleware. Closing that means renaming it in Keycloak and following the rename through the gateway configuration and LibreChat. Recorded as a blocker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
classic-models-agent-platform
Shared ground for the classic-models agent estate: templates, conventions, and
the identity plumbing every agent repeats. What k8s-platform is to the
gateway estate, this is to the agent estate.
Nothing runs from this repo. It holds the ${VAR}-parameterised manifests a
domain's Flux Kustomization builds, plus the reference documentation below.
The estate this plugs into
The agents replace LibreChat as the MCP client and orchestrator. Everything below them already exists and does not change: an MCP server per domain, an OpenAI-shaped LLM endpoint, Keycloak, and the domain APIs behind them.
Name none of the middleware. An agent talks to URLs that honour a contract. Whatever serves those URLs today is replaceable — swap it and nothing in an agent changes, as long as the contract holds. So write "the MCP server", "the LLM endpoint", "the gateway"; never a product name, in code, comments, commit messages or docs. Where a behaviour of the current middleware leaks into the contract, say what the caller must do and why, not who imposes it.
| Agent | Repo | Framework | Mode |
|---|---|---|---|
| Assistant | classic-models-agent-assistant |
LangGraph | conversational |
| Ventes | classic-models-agent-ventes |
CrewAI | conversational |
| Catalogue | classic-models-agent-catalogue |
PydanticAI + FastA2A | conversational |
| Support | classic-models-agent-support |
Google ADK | conversational |
| Approvisionnement | classic-models-agent-flows |
n8n | autonomous |
| Recouvrement | classic-models-agent-flows |
n8n | autonomous |
Five runtimes is deliberate. The point is proving A2A interoperability between stacks that do not know each other; a single framework would prove nothing.
Topology
A2A between agents, MCP between an agent and data.
Assistant ──→ every agent (orchestration; holds no scopes itself)
Ventes ──→ Approvisionnement, Recouvrement
Support ──→ Ventes
Catalogue ──→ Approvisionnement
An A2A call returns an answer, not data. Ventes asks Approvisionnement "can
I promise delivery on 15 March?" and receives yes/no — it never gains read
access to the catalog domain. Agent Cards therefore expose narrow,
decision-shaped skills (check_availability, check_creditworthiness), never a
generic query. An agent that exposes "ask me anything about my domain" has
become a data proxy and defeats the scoping.
The Assistant holds no scopes at all. It is the component that sees raw user text, so it is the prompt-injection target, and it must be unable to exfiltrate anything.
Identity
Each agent is its own Keycloak client. That is what makes the actor
identifiable: the exchanged token carries azp = the agent's client id
alongside sub = the end user.
{ "sub": "alice", "azp": "classic-models-agent-ventes",
"aud": ["mcp-catalog"], "scope": "openid profile email mcp-catalog-audience" }
Two measured facts that constrain the implementation:
actor_token is silently ignored. Keycloak 26.6's Standard Token Exchange
accepts the parameter, returns a valid token, and emits no act claim. No
error. Do not build delegation on RFC 8693 act — use azp.
The audience is requested via a scope, not the audience parameter. Passing
audience=mcp-catalog returns Requested audience not available. Pass
scope=openid mcp-catalog-audience.
The exchange
Do what LibreChat already does — a POST per target audience, roughly twenty lines. Do not build a sidecar, and do not push the exchange down into the gateway; both were considered and rejected as over-engineering, the second also because it would leave the agent holding one broad token instead of several narrow ones.
POST https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=<the agent's client id>
client_secret=<from the agent's Secret>
subject_token=<the end user's access token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
scope=openid mcp-<domain>-audience
Neither LangGraph nor CrewAI injects a per-call MCP token natively
(langchain-mcp-adapters issues #172/#194/#235; CrewAI's MCPServerAdapter fixes
headers at construction). This is a non-issue: build the MCP client once per
conversation turn with freshly exchanged tokens. A turn lasts seconds, an
access token five minutes.
Endpoints
MCP servers, one per domain:
| Domain | URL | Audience scopes to request |
|---|---|---|
| catalog | https://catalog-dev.sttlab.local/catalog-mcp |
mcp-catalog-audience kong-token-exchange-broker-audience |
| customers | https://customers-dev.sttlab.local/customers-mcp |
mcp-customers-audience kong-token-exchange-broker-audience |
| payments | https://payments-dev.sttlab.local/payments-mcp |
mcp-payments-audience kong-token-exchange-broker-audience |
| sales | https://sales-dev.sttlab.local/sales-mcp |
mcp-sales-audience kong-token-exchange-broker-audience |
| workforce | https://workforce-dev.sttlab.local/workforce-mcp |
mcp-workforce-audience kong-token-exchange-broker-audience |
Two scopes per server, not one. The MCP endpoint exchanges the token again
on its own account before reaching the domain API, so the token must also name
the broker client that performs that second exchange. Without it the call fails
with a bare 401 and no usable detail — the real cause (invalid status code
received from the token endpoint (403)) appears only in the server's own log.
Measured on catalog: with both scopes the MCP server answers 200, with
mcp-catalog-audience alone it answers 401. LibreChat has always requested
both.
This is a property of the contract as it stands, not of any agent's design. The broker client is still named after the middleware that introduced it, which is a leak worth closing — see the blockers.
LLM inference goes through the estate's LLM endpoint, never direct to a model provider:
https://llm-dev.sttlab.local/llm/v1/chat/completions
It speaks the OpenAI protocol, applies rate limiting and prompt guards, and routes onward to a provider. Sending inference anywhere else bypasses all of that, and loses the audit trail with it.
Authenticate to it with an exchanged token carrying the llm-proxy audience,
not a static API key. A key proves which application is calling; the token
carries azp and sub, so inference is attributable to the same (agent, end
user) pair as every tool call.
These hostnames are lab-only with a self-signed certificate. /etc/hosts:
192.168.139.103 catalog-dev.sttlab.local
192.168.139.105 llm-dev.sttlab.local
192.168.139.106 customers-dev.sttlab.local
192.168.139.107 payments-dev.sttlab.local
192.168.139.108 sales-dev.sttlab.local
192.168.139.109 workforce-dev.sttlab.local
192.168.139.101 keycloak.sttlab.local
The CA is sttlab-local-ca (cert-manager ClusterIssuer). Trust that one CA
rather than disabling verification.
Adding an agent
An agent is a declaration first. Put it here, let the generator produce the rest — the Keycloak client is generated from this, never hand-written in the agent's own repo, so the realm stays the single source of truth.
name: ventes
client_id: classic-models-agent-ventes
framework: crewai
surfaces: [conversational]
mcp_servers: [catalog, sales]
scopes: [catalog:read, sales:read, sales:write]
a2a_calls: [approvisionnement, recouvrement]
limits:
max_tool_calls_per_run: 20
max_records_read_per_run: 500
scopes becomes the client's optionalClientScopes; mcp_servers becomes the
audience scopes it may request; a2a_calls becomes the A2A authorisations.
The realm actually deployed is infra-keycloak/k8s/keycloak/configmap-realm.yaml
— note that the copy under the gateway estate is older, partial, and not
authoritative.
The realm file is re-imported with --override true on every pod start. An
import-realm initContainer replays it before Keycloak serves traffic, so the
file wins over the database and nothing created through the admin console
survives a restart. Add clients here, never in the console.
Client secrets and user passwords in that file are ${KC_SECRET_*} /
${KC_USER_*} placeholders, substituted from the keycloak-realm-secrets
Secret (Vault + ExternalSecret). Never commit a literal secret back into it.
Serving A2A
The a2a-sdk 1.x server rejects a request that does not carry the protocol
version header:
A2A-Version: 1.0
DefaultRequestHandler speaks the older 0.3 shape; use
DefaultRequestHandlerV2, and mount both create_agent_card_routes(...) and
create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True). An executor must
enqueue the Task before any status update, or the client blocks waiting for a
task it never saw. AgentInterface names the field protocol_binding, not
transport.
Known blockers
No employeeNumber in tokens. Sales territory scoping (a rep sees their
territory, a manager sees all) resolves through
employee → officeCode → offices.territory, but the token carries no employee
identity, so the API cannot filter server-side. The realm's users
(admin-user, pm-user, …) are role placeholders, not real employees.
The broker client is named after the middleware. Every agent must request
kong-token-exchange-broker-audience alongside the server's own audience, so
the one thing an agent cannot avoid naming is the very thing it should not know
about. Closing it means renaming the client and its scope in Keycloak, and
following that rename through the gateway configuration, LibreChat and each
agent's config. Until then, treat the string as an opaque identifier owned by
the realm.
Conventions
- Deliverables in English: code, comments, READMEs, Agent Cards. Conversation with the maintainer is in French.
- GitOps throughout. Never
kubectl applyan object carryingkustomize.toolkit.fluxcd.io/name— Flux reverts it within its interval, and tests run against config that no longer exists. - Secrets never committed. Follow the
secret.example.yamlpattern used by theinfra-*repos.