- Two audience scopes per MCP server, not one: Kong's own openid-connect
plugin re-exchanges the token, so it must also name
kong-token-exchange-broker. Measured on catalog.
- Drop the resolved Konnect-not-persisting blocker; the cause was a dead
Konnect org, not Konnect refusing writes.
- Drop the resolved kong-token-exchange-broker secret mismatch; its
client_credentials returns 200.
- Record the real blocker: PydanticAI's MCP client cannot open a session
against ai-mcp-proxy.
- Document the realm's override-on-start import and ${KC_SECRET_*}
placeholders, and the a2a-sdk 1.x serving requirements.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.1 KiB
classic-models-agent-platform
Shared ground for the classic-models agent estate: templates, conventions, and
the identity plumbing every agent repeats. Plays the role kong-k8s-platform
plays for the gateways.
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: Kong gateways per domain, MCP
servers served by Kong's ai-mcp-proxy, Keycloak, and the domain APIs.
| 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 or move the exchange into Kong; 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, served by Kong:
| 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 gateway's own openid-connect plugin
exchanges the token again before reaching the backend, so the token must also
name kong-token-exchange-broker. Without it the call fails with a bare 401
and the real cause appears only in the gateway log — invalid status code
received from the token endpoint (403). Measured on catalog: with both scopes
the MCP server answers 200, with mcp-catalog-audience alone it answers
401. LibreChat has always requested both.
LLM inference goes through the Kong AI gateway, never direct to a provider:
https://llm-dev.sttlab.local/llm/v1/chat/completions
It applies rate limiting, prompt guards and semantic guards, and routes to OpenRouter. Sending inference anywhere else bypasses all of them.
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 kong-v2/classic-models-realm.yaml is an older partial copy and is
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.
PydanticAI's MCP client cannot open a session against Kong's
ai-mcp-proxy. It fails with Failed to initialize server session and
swallows the cause. Everything else was eliminated: identity, TLS, DNS and the
token are all fine, and the three handshake steps — initialize (200 with a
session id), the SSE GET (200), notifications/initialized (202) — all
succeed with curl using the very token the agent minted. This blocks the
Catalogue agent end to end, and with it the Kong AI gateway path, which has
therefore never been exercised.
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.